$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( "", ['Content-Type' => 'text/html; charset=UTF-8'] ); }