Add local Keycloak test container

This commit is contained in:
Tamir Suliman
2026-05-17 12:03:04 +02:00
parent 1aeaf6aee5
commit abcea2b9a2
7 changed files with 159 additions and 16 deletions
+9 -7
View File
@@ -56,12 +56,14 @@ GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# ── OAuth/OIDC: Keycloak ─────────────────────────────────────────────────────
# Leave blank to disable this provider.
# Defaults match the bundled local Keycloak container for testing.
# Local callback URL: http://localhost:8080/auth/callback/keycloak
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=
KEYCLOAK_BASE_URL=
KEYCLOAK_REALM=
# Optional override; defaults to APP_URL + /auth/callback/keycloak.
KEYCLOAK_REDIRECT_URI=
KEYCLOAK_CLIENT_ID=totpvault
KEYCLOAK_CLIENT_SECRET=totpvault-dev-secret
KEYCLOAK_BASE_URL=http://localhost:8081
KEYCLOAK_INTERNAL_BASE_URL=http://keycloak:8080
KEYCLOAK_REALM=totpvault
KEYCLOAK_REDIRECT_URI=http://localhost:8080/auth/callback/keycloak
KEYCLOAK_SCOPE=openid email profile
KEYCLOAK_ADMIN_USERNAME=admin
KEYCLOAK_ADMIN_PASSWORD=admin
+15
View File
@@ -449,6 +449,21 @@ Then register these callback URLs with the providers you want to enable:
For Keycloak, create an OpenID Connect client in your realm, set the valid redirect URI to the callback URL above, and set `KEYCLOAK_BASE_URL` to the Keycloak server root, for example `https://sso.example.com`. TOTPVault uses `KEYCLOAK_BASE_URL` and `KEYCLOAK_REALM` to read the realm's `/.well-known/openid-configuration` discovery document.
The Docker Compose setup includes a local Keycloak test server on `http://localhost:8081`. On first startup, it imports `docker/keycloak/totpvault-realm.json` with:
| Setting | Value |
|---|---|
| Admin console | `http://localhost:8081` |
| Admin username | `admin` |
| Admin password | `admin` |
| Realm | `totpvault` |
| Client ID | `totpvault` |
| Client secret | `totpvault-dev-secret` |
| Test user | `totpuser` |
| Test password | `totpuser` |
These defaults are for local testing only. For production, use your own Keycloak deployment, change the client secret, set `KEYCLOAK_BASE_URL` to the public Keycloak URL, and set `KEYCLOAK_INTERNAL_BASE_URL` only if the PHP container needs a different internal network URL for token and userinfo calls.
After updating `.env`, rebuild/restart the app:
```bash
+1
View File
@@ -84,6 +84,7 @@ return [
'client_id' => getenv('KEYCLOAK_CLIENT_ID') ?: '',
'client_secret' => getenv('KEYCLOAK_CLIENT_SECRET') ?: '',
'base_url' => rtrim(getenv('KEYCLOAK_BASE_URL') ?: '', '/'),
'internal_base_url' => rtrim(getenv('KEYCLOAK_INTERNAL_BASE_URL') ?: '', '/'),
'realm' => getenv('KEYCLOAK_REALM') ?: '',
'redirect_uri' => getenv('KEYCLOAK_REDIRECT_URI') ?: ($appUrl . '/auth/callback/keycloak'),
'scope' => getenv('KEYCLOAK_SCOPE') ?: 'openid email profile',
+1
View File
@@ -51,6 +51,7 @@ return [
'client_id' => getenv('KEYCLOAK_CLIENT_ID') ?: '',
'client_secret' => getenv('KEYCLOAK_CLIENT_SECRET') ?: '',
'base_url' => rtrim(getenv('KEYCLOAK_BASE_URL') ?: '', '/'),
'internal_base_url' => rtrim(getenv('KEYCLOAK_INTERNAL_BASE_URL') ?: '', '/'),
'realm' => getenv('KEYCLOAK_REALM') ?: '',
'redirect_uri' => getenv('KEYCLOAK_REDIRECT_URI') ?: (rtrim(getenv('APP_URL') ?: 'https://totp.token2.swiss', '/') . '/auth/callback/keycloak'),
'scope' => getenv('KEYCLOAK_SCOPE') ?: 'openid email profile',
+40 -5
View File
@@ -37,11 +37,12 @@ services:
- MICROSOFT_CLIENT_SECRET=${MICROSOFT_CLIENT_SECRET:-}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-}
- KEYCLOAK_BASE_URL=${KEYCLOAK_BASE_URL:-}
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-}
- KEYCLOAK_REDIRECT_URI=${KEYCLOAK_REDIRECT_URI:-}
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-totpvault}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-totpvault-dev-secret}
- KEYCLOAK_BASE_URL=${KEYCLOAK_BASE_URL:-http://localhost:8081}
- KEYCLOAK_INTERNAL_BASE_URL=${KEYCLOAK_INTERNAL_BASE_URL:-http://keycloak:8080}
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-totpvault}
- KEYCLOAK_REDIRECT_URI=${KEYCLOAK_REDIRECT_URI:-http://localhost:8080/auth/callback/keycloak}
- KEYCLOAK_SCOPE=${KEYCLOAK_SCOPE:-openid email profile}
volumes:
# Mount the Docker-specific config as config/config.php inside the container.
@@ -52,6 +53,8 @@ services:
depends_on:
db:
condition: service_healthy
keycloak:
condition: service_healthy
networks:
- totpvault-net
healthcheck:
@@ -61,6 +64,36 @@ services:
retries: 3
start_period: 10s
# ── Keycloak identity provider for local OIDC testing ─────────────────────
keycloak:
image: quay.io/keycloak/keycloak:26.6.1
container_name: totpvault-keycloak
restart: unless-stopped
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME:-admin}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
KC_HTTP_ENABLED: "true"
KC_HOSTNAME_STRICT: "false"
KC_HOSTNAME_URL: ${KEYCLOAK_BASE_URL:-http://localhost:8081}
ports:
- "8081:8080"
volumes:
- ./docker/keycloak/totpvault-realm.json:/opt/keycloak/data/import/totpvault-realm.json:ro
- totpvault-keycloak-data:/opt/keycloak/data
networks:
- totpvault-net
healthcheck:
test:
[
"CMD-SHELL",
"/bin/bash -ec \"exec 3<>/dev/tcp/127.0.0.1/8080; printf 'GET /realms/totpvault/.well-known/openid-configuration HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3; grep -q 'HTTP/1.1 200' <&3\""
]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
# ── MariaDB database ──────────────────────────────────────────────────────
db:
image: mariadb:10.11
@@ -95,6 +128,8 @@ volumes:
driver: local
totpvault-sessions:
driver: local
totpvault-keycloak-data:
driver: local
networks:
totpvault-net:
+62
View File
@@ -0,0 +1,62 @@
{
"realm": "totpvault",
"enabled": true,
"displayName": "TOTPVault",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"clients": [
{
"clientId": "totpvault",
"name": "TOTPVault Local",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"secret": "totpvault-dev-secret",
"redirectUris": [
"http://localhost:8080/auth/callback/keycloak"
],
"webOrigins": [
"http://localhost:8080"
],
"attributes": {
"post.logout.redirect.uris": "http://localhost:8080/*"
},
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
],
"users": [
{
"username": "totpuser",
"enabled": true,
"emailVerified": true,
"firstName": "TOTP",
"lastName": "User",
"email": "totpuser@example.com",
"credentials": [
{
"type": "password",
"value": "totpuser",
"temporary": false
}
]
}
]
}
+31 -4
View File
@@ -47,8 +47,15 @@ public function __construct(string $providerName) {
}
unset($_SESSION['oauth_state']);
$token = $this->exchangeCode($code);
$userInfo = $this->fetchUserInfo($token['access_token']);
$token = $this->exchangeCode($code);
try {
$userInfo = $this->fetchUserInfo($token['access_token']);
} catch (RuntimeException $e) {
if (empty($token['id_token'])) {
throw $e;
}
$userInfo = $this->decodeJwtPayload($token['id_token']);
}
return $this->normalizeUser($userInfo);
}
@@ -58,20 +65,23 @@ public function __construct(string $providerName) {
}
$baseUrl = rtrim($this->provider['base_url'] ?? '', '/');
$internalBaseUrl = rtrim($this->provider['internal_base_url'] ?? '', '/');
$discoveryBaseUrl = $internalBaseUrl !== '' ? $internalBaseUrl : $baseUrl;
$realm = trim($this->provider['realm'] ?? '');
if ($baseUrl === '' || $realm === '') {
return;
}
$discoveryUrl = "{$baseUrl}/realms/" . rawurlencode($realm) . '/.well-known/openid-configuration';
$encodedRealm = rawurlencode($realm);
$discoveryUrl = "{$discoveryBaseUrl}/realms/{$encodedRealm}/.well-known/openid-configuration";
$response = $this->httpGet($discoveryUrl, ['Accept: application/json']);
$discovery = json_decode($response, true);
if (!is_array($discovery)) {
throw new RuntimeException('Failed to read Keycloak OpenID Connect discovery document.');
}
$this->provider['auth_url'] = "{$baseUrl}/realms/{$encodedRealm}/protocol/openid-connect/auth";
foreach ([
'auth_url' => 'authorization_endpoint',
'token_url' => 'token_endpoint',
'userinfo_url' => 'userinfo_endpoint',
] as $configKey => $discoveryKey) {
@@ -219,4 +229,21 @@ private function fetchUserInfo(string $accessToken): array {
]]);
return file_get_contents($url, false, $ctx) ?: throw new RuntimeException("HTTP GET to {$url} failed.");
}
private function decodeJwtPayload(string $jwt): array {
$parts = explode('.', $jwt);
if (count($parts) < 2) {
throw new RuntimeException('Invalid OIDC ID token.');
}
$payload = strtr($parts[1], '-_', '+/');
$payload .= str_repeat('=', (4 - strlen($payload) % 4) % 4);
$json = base64_decode($payload, true);
$data = $json === false ? null : json_decode($json, true);
if (!is_array($data)) {
throw new RuntimeException('Failed to decode OIDC ID token.');
}
return $data;
}
}