mirror of
https://github.com/token2/TOTPVault.git
synced 2026-06-22 23:42:25 -07:00
Add local Keycloak test container
This commit is contained in:
+9
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user