222 lines
7.8 KiB
PHP
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']
|
|
);
|
|
}
|
|
|