$config */ public function __construct( private readonly array $config, ) { } public function isEnabled(): bool { return (bool) ($this->config['enabled'] ?? false); } public function enforceLogin(): bool { return $this->isEnabled() && (bool) ($this->config['enforce_login'] ?? false); } public function allowDesktopPreview(): bool { return (bool) ($this->config['allow_desktop_preview'] ?? false); } public function previewQueryKey(): string { return (string) ($this->config['desktop_preview_query_key'] ?? 'preview-desktop'); } public function isConfigured(): bool { return $this->baseUrl() !== '' && $this->realm() !== '' && $this->clientId() !== '' && $this->clientSecret() !== ''; } public function isAuthenticated(): bool { return isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']); } public function shouldShowDesktop(): bool { if (!$this->enforceLogin()) { return true; } if ($this->isAuthenticated()) { return true; } $previewKey = $this->previewQueryKey(); return $this->allowDesktopPreview() && isset($_GET[$previewKey]); } public function loginUrl(): ?string { if (!$this->isConfigured()) { return null; } $state = bin2hex(random_bytes(16)); $_SESSION['keycloak_oauth_state'] = $state; $params = array_merge( [ 'client_id' => $this->clientId(), 'redirect_uri' => $this->redirectUri(), 'response_type' => 'code', 'scope' => implode(' ', $this->scopes()), 'state' => $state, ], is_array($this->config['extra_authorize_params'] ?? null) ? $this->config['extra_authorize_params'] : [] ); return rtrim($this->baseUrl(), '/') . '/realms/' . rawurlencode($this->realm()) . '/protocol/openid-connect/auth?' . http_build_query($params); } public function logout(): void { unset($_SESSION['desktop_auth'], $_SESSION['keycloak_oauth_state']); } /** * @return array{success: bool, data?: array, error?: string} */ public function exchangeAuthorizationCode(string $code): array { if (!$this->isConfigured()) { return [ 'success' => false, 'error' => 'Keycloak ist noch nicht vollstaendig konfiguriert.', ]; } $response = $this->postForm( $this->tokenEndpoint(), [ 'grant_type' => 'authorization_code', 'code' => $code, 'client_id' => $this->clientId(), 'client_secret' => $this->clientSecret(), 'redirect_uri' => $this->redirectUri(), ] ); if ($response['success'] !== true) { return [ 'success' => false, 'error' => $response['error'] ?? 'Token-Austausch mit Keycloak fehlgeschlagen.', ]; } /** @var array $payload */ $payload = $response['data']; return [ 'success' => true, 'data' => $payload, ]; } /** * @return array{success: bool, data?: array, error?: string} */ public function fetchUserInfo(string $accessToken): array { $response = $this->getJson( $this->userInfoEndpoint(), [ 'Authorization: Bearer ' . $accessToken, ] ); if ($response['success'] !== true) { return [ 'success' => false, 'error' => $response['error'] ?? 'Userinfo-Abruf bei Keycloak fehlgeschlagen.', ]; } /** @var array $payload */ $payload = $response['data']; return [ 'success' => true, 'data' => $payload, ]; } /** * @param array $tokenPayload * @param array $userInfo */ public function establishSession(array $tokenPayload, array $userInfo): void { $_SESSION['desktop_auth'] = [ 'authenticated_at' => time(), 'access_token' => (string) ($tokenPayload['access_token'] ?? ''), 'refresh_token' => (string) ($tokenPayload['refresh_token'] ?? ''), 'id_token' => (string) ($tokenPayload['id_token'] ?? ''), 'expires_in' => (int) ($tokenPayload['expires_in'] ?? 0), 'refresh_expires_in' => (int) ($tokenPayload['refresh_expires_in'] ?? 0), 'token_type' => (string) ($tokenPayload['token_type'] ?? 'Bearer'), 'scope' => (string) ($tokenPayload['scope'] ?? ''), 'user' => [ 'sub' => (string) ($userInfo['sub'] ?? ''), 'username' => (string) ($userInfo['preferred_username'] ?? ''), 'name' => (string) ($userInfo['name'] ?? ''), 'email' => (string) ($userInfo['email'] ?? ''), 'groups' => $this->extractGroups($tokenPayload, $userInfo), ], ]; } public function logoutUrl(?string $redirectPath = null, ?string $idTokenHint = null): string { $params = [ 'client_id' => $this->clientId(), 'post_logout_redirect_uri' => $this->postLogoutRedirectUri($redirectPath), ]; $idToken = $idTokenHint !== null && $idTokenHint !== '' ? $idTokenHint : (string) ($_SESSION['desktop_auth']['id_token'] ?? ''); if ($idToken !== '') { $params['id_token_hint'] = $idToken; } return rtrim($this->baseUrl(), '/') . '/realms/' . rawurlencode($this->realm()) . '/protocol/openid-connect/logout?' . http_build_query($params); } /** * @return array */ public function branding(): array { return is_array($this->config['branding'] ?? null) ? $this->config['branding'] : []; } public function redirectUri(): string { $path = (string) ($this->config['redirect_path'] ?? '/auth/callback'); return $this->requestOrigin() . $path; } public function baseUrl(): string { return trim((string) ($this->config['base_url'] ?? '')); } public function realm(): string { return trim((string) ($this->config['realm'] ?? '')); } public function clientId(): string { return trim((string) ($this->config['client_id'] ?? '')); } public function clientSecret(): string { return trim((string) ($this->config['client_secret'] ?? '')); } /** * @return array */ public function scopes(): array { $scopes = $this->config['scopes'] ?? ['openid']; return is_array($scopes) ? array_values(array_map('strval', $scopes)) : ['openid']; } private function tokenEndpoint(): string { return rtrim($this->baseUrl(), '/') . '/realms/' . rawurlencode($this->realm()) . '/protocol/openid-connect/token'; } private function userInfoEndpoint(): string { return rtrim($this->baseUrl(), '/') . '/realms/' . rawurlencode($this->realm()) . '/protocol/openid-connect/userinfo'; } private function postLogoutRedirectUri(?string $redirectPath = null): string { $path = trim((string) $redirectPath); if ($path === '' || !str_starts_with($path, '/')) { $path = '/'; } return $this->requestOrigin() . $path; } private function requestOrigin(): string { $forwardedProto = trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')); $forwardedHost = trim((string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? '')); $forwardedPort = trim((string) ($_SERVER['HTTP_X_FORWARDED_PORT'] ?? '')); $scheme = $this->normalizeForwardedValue($forwardedProto); if ($scheme === '') { $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; } $host = $this->normalizeForwardedValue($forwardedHost); if ($host === '') { $host = (string) ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost'); } if ($forwardedPort !== '') { $port = $this->normalizeForwardedValue($forwardedPort); $hostWithoutPort = preg_replace('/:\d+$/', '', $host) ?? $host; if ( $port !== '' && !str_contains($host, ':') && !(($scheme === 'https' && $port === '443') || ($scheme === 'http' && $port === '80')) ) { $host = $hostWithoutPort . ':' . $port; } } return $scheme . '://' . $host; } private function normalizeForwardedValue(string $value): string { if ($value === '') { return ''; } $first = trim(explode(',', $value)[0] ?? ''); return strtolower($first); } /** * @param array $tokenPayload * @param array $userInfo * @return array */ private function extractGroups(array $tokenPayload, array $userInfo): array { $groups = []; foreach ([$userInfo, $this->decodeJwtClaims((string) ($tokenPayload['access_token'] ?? '')), $this->decodeJwtClaims((string) ($tokenPayload['id_token'] ?? ''))] as $source) { $candidateGroups = $source['groups'] ?? []; if (!is_array($candidateGroups)) { continue; } foreach ($candidateGroups as $group) { $group = trim((string) $group); if ($group === '') { continue; } $group = trim($group, '/'); $groups[$group] = $group; } } return array_values($groups); } /** * @return array */ private function decodeJwtClaims(string $jwt): array { if ($jwt === '') { return []; } $parts = explode('.', $jwt); if (count($parts) < 2) { return []; } $payload = $parts[1]; $payload .= str_repeat('=', (4 - strlen($payload) % 4) % 4); $decoded = base64_decode(strtr($payload, '-_', '+/'), true); if ($decoded === false) { return []; } $claims = json_decode($decoded, true); return is_array($claims) ? $claims : []; } /** * @param array $fields * @return array{success: bool, data?: array, error?: string} */ private function postForm(string $url, array $fields): array { return $this->requestJson( $url, [ 'method' => 'POST', 'headers' => [ 'Content-Type: application/x-www-form-urlencoded', ], 'body' => http_build_query($fields), ] ); } /** * @param array $headers * @return array{success: bool, data?: array, error?: string} */ private function getJson(string $url, array $headers = []): array { return $this->requestJson( $url, [ 'method' => 'GET', 'headers' => $headers, 'body' => null, ] ); } /** * @param array{method: string, headers: array, body: ?string} $request * @return array{success: bool, data?: array, error?: string} */ private function requestJson(string $url, array $request): array { if (function_exists('curl_init')) { $ch = curl_init($url); if ($ch === false) { return [ 'success' => false, 'error' => 'cURL konnte nicht initialisiert werden.', ]; } curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $request['method'], CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $request['headers']), CURLOPT_TIMEOUT => 20, ]); if ($request['body'] !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $request['body']); } $raw = curl_exec($ch); $curlError = curl_error($ch); $statusCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($raw === false) { return [ 'success' => false, 'error' => 'Keycloak-Request fehlgeschlagen: ' . $curlError, ]; } return $this->decodeJsonResponse($raw, $statusCode); } $context = stream_context_create([ 'http' => [ 'method' => $request['method'], 'header' => implode("\r\n", array_merge(['Accept: application/json'], $request['headers'])), 'content' => $request['body'] ?? '', 'timeout' => 20, 'ignore_errors' => true, ], ]); $raw = @file_get_contents($url, false, $context); if ($raw === false) { return [ 'success' => false, 'error' => 'Keycloak-Request fehlgeschlagen.', ]; } $statusCode = 200; if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $matches) === 1) { $statusCode = (int) $matches[1]; } return $this->decodeJsonResponse($raw, $statusCode); } /** * @return array{success: bool, data?: array, error?: string} */ private function decodeJsonResponse(string $raw, int $statusCode): array { $decoded = json_decode($raw, true); if (!is_array($decoded)) { return [ 'success' => false, 'error' => 'Antwort von Keycloak ist kein gueltiges JSON.', ]; } if ($statusCode >= 400) { $message = (string) ($decoded['error_description'] ?? $decoded['error'] ?? ('HTTP ' . $statusCode)); return [ 'success' => false, 'error' => $message, ]; } return [ 'success' => true, 'data' => $decoded, ]; } }