Files
barometer-server/lib/keycloak_auth.php
tom.hempel f1caa9e681
Some checks failed
PHPUnit / test (push) Has been cancelled
initial prototype based on nat-as-server
2026-06-29 12:39:55 +02:00

222 lines
7.8 KiB
PHP

<?php
/**
* Minimal OpenID Connect support for Keycloak-backed website login.
*
* Required .env values:
* - QDB_KEYCLOAK_ISSUER, e.g. https://.../realms/...
* - QDB_KEYCLOAK_CLIENT_ID
* - QDB_KEYCLOAK_CLIENT_SECRET
*
* Optional:
* - QDB_KEYCLOAK_REDIRECT_URI (defaults to current /api/auth/keycloak-callback)
* - QDB_KEYCLOAK_USERNAME_CLAIM (defaults to preferred_username)
* - QDB_KEYCLOAK_DISPLAY_NAME (defaults to University Konstanz)
*/
function qdb_keycloak_config(): array
{
$issuer = rtrim((string)(qdb_env_get('QDB_KEYCLOAK_ISSUER') ?? ''), '/');
$clientId = (string)(qdb_env_get('QDB_KEYCLOAK_CLIENT_ID') ?? '');
$clientSecret = (string)(qdb_env_get('QDB_KEYCLOAK_CLIENT_SECRET') ?? '');
return [
'issuer' => $issuer,
'client_id' => $clientId,
'client_secret' => $clientSecret,
'redirect_uri' => (string)(qdb_env_get('QDB_KEYCLOAK_REDIRECT_URI') ?? qdb_keycloak_default_redirect_uri()),
'username_claim' => (string)(qdb_env_get('QDB_KEYCLOAK_USERNAME_CLAIM') ?? 'preferred_username'),
'display_name' => (string)(qdb_env_get('QDB_KEYCLOAK_DISPLAY_NAME') ?? 'University Konstanz'),
];
}
function qdb_keycloak_is_configured(?array $config = null): bool
{
$config ??= qdb_keycloak_config();
return $config['issuer'] !== '' && $config['client_id'] !== '' && $config['client_secret'] !== '';
}
function qdb_keycloak_default_redirect_uri(): string
{
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$scheme = $https ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php';
$base = rtrim(str_replace('\\', '/', dirname($script)), '/');
return $scheme . '://' . $host . $base . '/auth/keycloak-callback';
}
function qdb_keycloak_frontend_url(): string
{
$configured = (string)(qdb_env_get('QDB_KEYCLOAK_FRONTEND_URL') ?? '');
if ($configured !== '') {
return $configured;
}
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$scheme = $https ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php';
$apiBase = rtrim(str_replace('\\', '/', dirname($script)), '/');
$root = preg_replace('#/api$#', '', $apiBase) ?: '';
return $scheme . '://' . $host . $root . '/website/index.html';
}
function qdb_keycloak_state_signing_key(): string
{
$secret = qdb_env_get('QDB_KEYCLOAK_STATE_SECRET')
?? qdb_env_get('QDB_MASTER_KEY')
?? qdb_env_get('QDB_KEYCLOAK_CLIENT_SECRET')
?? '';
if ($secret === '') {
throw new RuntimeException('No signing secret available for Keycloak state');
}
return $secret;
}
function qdb_keycloak_base64url(string $bytes): string
{
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
function qdb_keycloak_unbase64url(string $value): string
{
$padded = strtr($value, '-_', '+/');
$padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4);
$decoded = base64_decode($padded, true);
if ($decoded === false) {
throw new InvalidArgumentException('Invalid base64url value');
}
return $decoded;
}
function qdb_keycloak_create_state(string $nonce): string
{
$payload = json_encode([
'nonce' => $nonce,
'exp' => time() + 600,
], JSON_THROW_ON_ERROR);
$body = qdb_keycloak_base64url($payload);
$sig = qdb_keycloak_base64url(hash_hmac('sha256', $body, qdb_keycloak_state_signing_key(), true));
return $body . '.' . $sig;
}
function qdb_keycloak_verify_state(string $state): array
{
$parts = explode('.', $state, 2);
if (count($parts) !== 2) {
json_error('INVALID_STATE', 'Invalid Keycloak state', 400);
}
[$body, $sig] = $parts;
$expected = qdb_keycloak_base64url(hash_hmac('sha256', $body, qdb_keycloak_state_signing_key(), true));
if (!hash_equals($expected, $sig)) {
json_error('INVALID_STATE', 'Invalid Keycloak state', 400);
}
$payload = json_decode(qdb_keycloak_unbase64url($body), true);
if (!is_array($payload) || (int)($payload['exp'] ?? 0) < time()) {
json_error('INVALID_STATE', 'Expired Keycloak state', 400);
}
return $payload;
}
function qdb_keycloak_discovery(array $config): array
{
$url = $config['issuer'] . '/.well-known/openid-configuration';
$json = qdb_keycloak_http_json($url);
foreach (['authorization_endpoint', 'token_endpoint', 'userinfo_endpoint'] as $key) {
if (empty($json[$key])) {
throw new RuntimeException("Keycloak discovery missing $key");
}
}
return $json;
}
function qdb_keycloak_http_json(string $url, array $options = []): array
{
$context = stream_context_create(['http' => [
'method' => $options['method'] ?? 'GET',
'header' => $options['headers'] ?? '',
'content' => $options['content'] ?? '',
'timeout' => 10,
'ignore_errors' => true,
]]);
$raw = file_get_contents($url, false, $context);
if ($raw === false) {
throw new RuntimeException('Keycloak request failed');
}
$status = 200;
foreach (($http_response_header ?? []) as $header) {
if (preg_match('#^HTTP/\S+\s+(\d+)#', $header, $m)) {
$status = (int)$m[1];
break;
}
}
$json = json_decode($raw, true);
if ($status < 200 || $status >= 300 || !is_array($json)) {
throw new RuntimeException('Keycloak returned an invalid response');
}
return $json;
}
function qdb_keycloak_exchange_code(array $config, array $discovery, string $code): array
{
$body = http_build_query([
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $config['redirect_uri'],
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
]);
return qdb_keycloak_http_json($discovery['token_endpoint'], [
'method' => 'POST',
'headers' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $body,
]);
}
function qdb_keycloak_userinfo(array $discovery, string $accessToken): array
{
return qdb_keycloak_http_json($discovery['userinfo_endpoint'], [
'headers' => "Authorization: Bearer $accessToken\r\n",
]);
}
function qdb_keycloak_claim_username(array $config, array $claims): string
{
$claim = $config['username_claim'] ?: 'preferred_username';
$username = trim((string)($claims[$claim] ?? ''));
if ($username === '' && $claim !== 'email') {
$username = trim((string)($claims['email'] ?? ''));
}
if ($username === '') {
json_error('KEYCLOAK_NO_USERNAME', 'University login did not provide a usable username', 403);
}
return $username;
}
function qdb_keycloak_redirect(string $url, int $status = 302): never
{
if (defined('QDB_TESTING')) {
throw new \Tests\Support\RawHttpResponse('', ['Location' => $url], $status);
}
http_response_code($status);
header('Location: ' . $url, true, $status);
exit;
}
function qdb_keycloak_finish_html(string $token, string $user, string $role): never
{
$target = qdb_keycloak_frontend_url() . '#/';
$payload = json_encode([
'token' => $token,
'user' => $user,
'role' => $role,
'target' => $target,
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_THROW_ON_ERROR);
qdb_http_download(
"<!doctype html><meta charset=\"utf-8\"><script>const d=$payload;localStorage.setItem('qdb_token',d.token);localStorage.setItem('qdb_user',d.user);localStorage.setItem('qdb_role',d.role);location.replace(d.target);</script>",
['Content-Type' => 'text/html; charset=UTF-8']
);
}