From 1ae7b9837851ef27fcb869b2c554730a53dbce39 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Mon, 8 Jun 2026 02:45:36 +0200 Subject: [PATCH] keycloak --- config/keycloak.php | 22 ++ docs/keycloak-theme-handoff.md | 13 + partials/auth/login_screen.php | 54 ++++ public/assets/auth/login.css | 116 +++++++++ public/assets/desktop/desktop.js | 16 ++ public/auth/callback/index.php | 82 +++++++ public/auth/keycloak/index.php | 22 ++ public/auth/login/index.php | 26 ++ public/auth/logout/index.php | 28 +++ public/index.php | 31 ++- src/App/App.php | 17 +- src/App/ConfigLoader.php | 48 ++++ src/App/KeycloakAuth.php | 407 +++++++++++++++++++++++++++++++ temp/KEYCLOAK_CLIENT_ANLEGEN.md | 158 ++++++++++++ 14 files changed, 1036 insertions(+), 4 deletions(-) create mode 100644 config/keycloak.php create mode 100644 partials/auth/login_screen.php create mode 100644 public/assets/auth/login.css create mode 100644 public/auth/callback/index.php create mode 100644 public/auth/keycloak/index.php create mode 100644 public/auth/login/index.php create mode 100644 public/auth/logout/index.php create mode 100644 src/App/ConfigLoader.php create mode 100644 src/App/KeycloakAuth.php create mode 100644 temp/KEYCLOAK_CLIENT_ANLEGEN.md diff --git a/config/keycloak.php b/config/keycloak.php new file mode 100644 index 00000000..28110c2a --- /dev/null +++ b/config/keycloak.php @@ -0,0 +1,22 @@ + true, + 'enforce_login' => true, + 'allow_desktop_preview' => false, + 'desktop_preview_query_key' => 'preview-desktop', + 'base_url' => 'https://auth.kusche.berlin', + 'realm' => 'KuscheBerlin', + 'client_id' => 'desktop-kusche-berlin', + 'client_secret' => 'aeBX6f65E0RlNfksHldekcWgP37JDLRw', + 'redirect_path' => '/auth/callback', + 'scopes' => ['openid', 'profile', 'email'], + 'extra_authorize_params' => [], + 'branding' => [ + 'product_name' => 'Kusche.Berlin', + 'headline' => 'Desktop Login', + 'subheadline' => 'Geschuetzter Zugang zur Desktop-Shell und kuenftigen Modulen.', + ], +]; diff --git a/docs/keycloak-theme-handoff.md b/docs/keycloak-theme-handoff.md index 5c2b1b4d..2b984583 100644 --- a/docs/keycloak-theme-handoff.md +++ b/docs/keycloak-theme-handoff.md @@ -19,12 +19,25 @@ Diese Datei beschreibt die Uebergabepunkte zwischen der Desktop-Shell und dem sp ## Technische Schnittstellen - Desktop-Shell verweist auf die Login-App `desktop-login`. +- Root `/` kann jetzt vor der Desktop-Shell auf einen eigenen Login-Screen umleiten. +- Login-Handoff zu Keycloak laeuft ueber `/auth/keycloak`. +- Callback-Endpunkt ist unter `/auth/callback` vorbereitet. - Keycloak-Theme liegt spaeter getrennt im Keycloak-Theme-System. - Gemeinsame Branding-Werte sollten aus einer zentralen Theme-Definition abgeleitet werden. +## Aktueller Stand im Projekt + +- `config/keycloak.php` enthaelt die Grundkonfiguration. +- `config//keycloak.php` kann die Werte je Umgebung ueberschreiben. +- `public/auth/login/` rendert den Desktop-Login-Screen. +- `public/auth/keycloak/` leitet auf die Keycloak-Authorize-URL weiter. +- `public/auth/callback/` prueft den Ruecksprungzustand, tauscht den Authorization Code gegen Token und legt die Session an. +- `public/auth/logout/` beendet die lokale Session und kann Keycloak-Logout anstossen. + ## Zu erledigen ausserhalb dieser V1 - Keycloak-Theme erzeugen - Theme dem passenden Client oder Realm zuweisen - Login-Templates, CSS und Assets im Theme pflegen +- Session-Validierung, Refresh und spaetere Token-Erneuerung sauber nachziehen - Ruecksprung aus Keycloak sauber in die Desktop-Shell fuehren diff --git a/partials/auth/login_screen.php b/partials/auth/login_screen.php new file mode 100644 index 00000000..b10e4ac4 --- /dev/null +++ b/partials/auth/login_screen.php @@ -0,0 +1,54 @@ + + + + + + <?= htmlspecialchars($headline, ENT_QUOTES) ?> + + + +
+ +
+ + diff --git a/public/assets/auth/login.css b/public/assets/auth/login.css new file mode 100644 index 00000000..5462f5dc --- /dev/null +++ b/public/assets/auth/login.css @@ -0,0 +1,116 @@ +:root { + color-scheme: dark; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: #f8fafc; + background: + radial-gradient(circle at top, rgba(255, 222, 173, 0.28), transparent 28%), + linear-gradient(145deg, #101827 0%, #18253d 52%, #0f172a 100%); +} + +.login-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.login-panel { + width: min(480px, 100%); + padding: 28px; + border-radius: 28px; + background: rgba(15, 23, 42, 0.58); + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 24px 80px rgba(2, 6, 23, 0.42); + backdrop-filter: blur(24px); +} + +.login-panel-top { + margin-bottom: 24px; +} + +.login-kicker { + margin: 0 0 12px; + font-size: 12px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: #cbd5e1; +} + +.login-panel h1 { + margin: 0 0 10px; + font-size: 36px; + line-height: 1.05; +} + +.login-copy { + margin: 0; + color: #dbe5f1; + line-height: 1.5; +} + +.login-actions { + display: grid; + gap: 12px; + margin-bottom: 24px; +} + +.login-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 48px; + padding: 0 16px; + border-radius: 14px; + text-decoration: none; + font-weight: 700; +} + +.login-button-primary { + color: #082032; + background: linear-gradient(180deg, #a7f3d0, #6ee7b7); +} + +.login-button-secondary { + color: #f8fafc; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.login-button-disabled { + color: #cbd5e1; + background: rgba(255, 255, 255, 0.08); + border: 1px dashed rgba(255, 255, 255, 0.18); +} + +.login-meta { + display: grid; + gap: 14px; + margin: 0; +} + +.login-meta div { + display: grid; + gap: 4px; +} + +.login-meta dt { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #94a3b8; +} + +.login-meta dd { + margin: 0; + color: #e2e8f0; + word-break: break-word; +} diff --git a/public/assets/desktop/desktop.js b/public/assets/desktop/desktop.js index f34f4926..7ebd5582 100644 --- a/public/assets/desktop/desktop.js +++ b/public/assets/desktop/desktop.js @@ -45,6 +45,20 @@ if (payloadNode) { return `${escapeHtml(app.icon || 'AP')}`; }; + const updateSessionDisplay = () => { + const accountNode = document.querySelector('.tray-pill:last-of-type'); + + if (!accountNode) { + return; + } + + const displayName = payload.session?.display_name; + + if (typeof displayName === 'string' && displayName.trim() !== '') { + accountNode.textContent = displayName; + } + }; + const buildTaskbarButtonContent = (record) => ` ${buildAppIconMarkup(record.definition, 'taskbar-app-icon')} ${escapeHtml(record.title)} @@ -702,6 +716,8 @@ if (payloadNode) { iconsRoot?.appendChild(icon); }); + updateSessionDisplay(); + startButton?.setAttribute('aria-expanded', 'false'); startButton?.addEventListener('click', () => { if (!startMenu || !startButton) { diff --git a/public/auth/callback/index.php b/public/auth/callback/index.php new file mode 100644 index 00000000..4660ec92 --- /dev/null +++ b/public/auth/callback/index.php @@ -0,0 +1,82 @@ +exchangeAuthorizationCode($code); + + if (($tokenExchange['success'] ?? false) !== true) { + http_response_code(502); + $message = (string) ($tokenExchange['error'] ?? 'Token-Austausch mit Keycloak fehlgeschlagen.'); + } else { + /** @var array $tokenPayload */ + $tokenPayload = $tokenExchange['data']; + $accessToken = (string) ($tokenPayload['access_token'] ?? ''); + + if ($accessToken === '') { + http_response_code(502); + $message = 'Keycloak hat kein Access Token geliefert.'; + } else { + $userInfo = $auth->fetchUserInfo($accessToken); + + if (($userInfo['success'] ?? false) !== true) { + http_response_code(502); + $message = (string) ($userInfo['error'] ?? 'Userinfo-Abruf fehlgeschlagen.'); + } else { + /** @var array $userPayload */ + $userPayload = $userInfo['data']; + $auth->establishSession($tokenPayload, $userPayload); + header('Location: ' . $redirectTarget, true, 302); + exit; + } + } + } +} +?> + + + + + <?= htmlspecialchars($title, ENT_QUOTES) ?> + + + +
+ +
+ + diff --git a/public/auth/keycloak/index.php b/public/auth/keycloak/index.php new file mode 100644 index 00000000..7495d485 --- /dev/null +++ b/public/auth/keycloak/index.php @@ -0,0 +1,22 @@ +loginUrl(); + +if ($loginUrl === null) { + header('Location: /auth/login'); + exit; +} + +header('Location: ' . $loginUrl, true, 302); +exit; diff --git a/public/auth/login/index.php b/public/auth/login/index.php new file mode 100644 index 00000000..0bb68dd4 --- /dev/null +++ b/public/auth/login/index.php @@ -0,0 +1,26 @@ +allowDesktopPreview() + ? '/?' . urlencode($auth->previewQueryKey()) . '=1' + : null; + +$loginScreen = [ + 'branding' => $auth->branding(), + 'login_url' => $auth->isConfigured() ? '/auth/keycloak' : null, + 'preview_url' => $previewUrl, + 'keycloak_ready' => $auth->isConfigured(), + 'callback_url' => $auth->redirectUri(), +]; + +require $projectRoot . '/partials/auth/login_screen.php'; diff --git a/public/auth/logout/index.php b/public/auth/logout/index.php new file mode 100644 index 00000000..7e022d29 --- /dev/null +++ b/public/auth/logout/index.php @@ -0,0 +1,28 @@ +isConfigured() && $auth->isAuthenticated()) { + $logoutUrl = $auth->logoutUrl(); +} + +$auth->logout(); + +if ($logoutUrl !== null) { + header('Location: ' . $logoutUrl, true, 302); + exit; +} + +header('Location: /auth/login', true, 302); +exit; diff --git a/public/index.php b/public/index.php index 276425b4..a18bc314 100644 --- a/public/index.php +++ b/public/index.php @@ -5,8 +5,35 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/src/App/bootstrap.php'; use App\App; +use App\ConfigLoader; +use App\KeycloakAuth; -$app = new App(dirname(__DIR__)); +session_start(); + +$projectRoot = dirname(__DIR__); +$keycloakConfig = ConfigLoader::load($projectRoot, 'keycloak'); +$auth = new KeycloakAuth($keycloakConfig); + +if (!$auth->shouldShowDesktop()) { + $previewUrl = null; + + if ($auth->allowDesktopPreview()) { + $previewUrl = '/?' . urlencode($auth->previewQueryKey()) . '=1'; + } + + $loginScreen = [ + 'branding' => $auth->branding(), + 'login_url' => $auth->isConfigured() ? '/auth/keycloak' : null, + 'preview_url' => $previewUrl, + 'keycloak_ready' => $auth->isConfigured(), + 'callback_url' => $auth->redirectUri(), + ]; + + require $projectRoot . '/partials/auth/login_screen.php'; + exit; +} + +$app = new App($projectRoot); $desktopPayload = $app->desktopPayload(); -require dirname(__DIR__) . '/partials/desktop/shell.php'; +require $projectRoot . '/partials/desktop/shell.php'; diff --git a/src/App/App.php b/src/App/App.php index 1780fb04..ede795a3 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -30,6 +30,11 @@ final class App $apps = AppIconResolver::decorate($registry->all(), $activeSkin); $windows = (new WindowManager($registry))->defaultWindows(); $activeWidgetIds = DesktopState::defaultWidgetIds(); + $isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']); + $authUser = $isAuthenticated && is_array($_SESSION['desktop_auth']['user'] ?? null) + ? $_SESSION['desktop_auth']['user'] + : []; + $displayName = (string) ($authUser['name'] ?? $authUser['username'] ?? 'Gast'); return [ 'meta' => [ @@ -45,9 +50,14 @@ final class App 'storage_scope' => 'guest', 'workspace' => DesktopState::defaultWorkspace(), 'login' => [ - 'authenticated' => false, - 'mode' => 'public-home', + 'authenticated' => $isAuthenticated, + 'mode' => $isAuthenticated ? 'authenticated' : 'login-required', 'keycloak_theme_handoff' => '/docs/keycloak-theme-handoff.md', + 'user' => [ + 'name' => (string) ($authUser['name'] ?? ''), + 'username' => (string) ($authUser['username'] ?? ''), + 'email' => (string) ($authUser['email'] ?? ''), + ], ], ], 'apps' => $apps, @@ -58,6 +68,9 @@ final class App 'active_ids' => $activeWidgetIds, ], 'tray' => DesktopState::trayItems(), + 'session' => [ + 'display_name' => $displayName, + ], 'menu' => [ 'quick_actions' => DesktopState::quickActions(), ], diff --git a/src/App/ConfigLoader.php b/src/App/ConfigLoader.php new file mode 100644 index 00000000..77bb992b --- /dev/null +++ b/src/App/ConfigLoader.php @@ -0,0 +1,48 @@ + + */ + public static function load(string $projectRoot, string $name): array + { + $basePath = $projectRoot . '/config/' . $name . '.php'; + $config = is_file($basePath) ? require $basePath : []; + + $appEnv = getenv('APP_ENV') ?: 'local'; + $envPath = $projectRoot . '/config/' . $appEnv . '/' . $name . '.php'; + + if (!is_file($envPath)) { + return $config; + } + + /** @var array $envConfig */ + $envConfig = require $envPath; + + return self::merge($config, $envConfig); + } + + /** + * @param array $base + * @param array $override + * @return array + */ + private static function merge(array $base, array $override): array + { + foreach ($override as $key => $value) { + if (is_array($value) && isset($base[$key]) && is_array($base[$key])) { + $base[$key] = self::merge($base[$key], $value); + continue; + } + + $base[$key] = $value; + } + + return $base; + } +} diff --git a/src/App/KeycloakAuth.php b/src/App/KeycloakAuth.php new file mode 100644 index 00000000..e8820f9f --- /dev/null +++ b/src/App/KeycloakAuth.php @@ -0,0 +1,407 @@ + $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'] ?? ''), + ], + ]; + } + + public function logoutUrl(): string + { + $params = [ + 'client_id' => $this->clientId(), + 'post_logout_redirect_uri' => $this->postLogoutRedirectUri(), + ]; + + $idToken = (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'); + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost'); + + return $scheme . '://' . $host . $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 + { + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost'); + + return $scheme . '://' . $host . '/auth/login'; + } + + /** + * @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, + ]; + } +} diff --git a/temp/KEYCLOAK_CLIENT_ANLEGEN.md b/temp/KEYCLOAK_CLIENT_ANLEGEN.md new file mode 100644 index 00000000..cd46ab16 --- /dev/null +++ b/temp/KEYCLOAK_CLIENT_ANLEGEN.md @@ -0,0 +1,158 @@ +# Keycloak Client Anlegen + +Diese Schritte legen den Client fuer `desktop.kusche.berlin` in Keycloak an. + +## Ziel + +Wir brauchen einen eigenen OpenID-Client fuer die Desktop-Shell, damit: + +- der Login ueber Keycloak laeuft +- der Ruecksprung sauber auf `desktop.kusche.berlin` erfolgt +- spaeter ein eigenes Login-Theme fuer diesen Client genutzt werden kann + +## Vorab benoetigte Daten + +Vor dem Anlegen solltest du diese Werte kennen: + +- Keycloak Basis-URL + - Beispiel: `https://auth.kusche.berlin` +- Realm-Name + - Beispiel: `kusche-berlin` +- Staging-Domain + - Beispiel: `https://desktop-staging.kusche.berlin` +- Live-Domain + - Beispiel: `https://desktop.kusche.berlin` + +## Client anlegen + +1. In Keycloak einloggen. +2. Den passenden Realm auswaehlen. +3. `Clients` aufrufen. +4. `Create client` klicken. + +## Grunddaten + +Diese Werte setzen: + +- `Client type`: `OpenID Connect` +- `Client ID`: `desktop-kusche-berlin` +- `Name`: `Desktop Kusche Berlin` +- `Description`: `Desktop Shell Login fuer Kusche.Berlin` + +Dann `Next`. + +## Capability config + +Diese Werte setzen: + +- `Client authentication`: `On` +- `Authorization`: `Off` +- `Standard flow`: `On` +- `Direct access grants`: `Off` +- `Implicit flow`: `Off` +- `Service accounts roles`: `Off` + +Dann `Next`. + +## Login config + +Diese Werte setzen: + +- `Root URL`: + - Staging zuerst: `https://desktop-staging.kusche.berlin` +- `Home URL`: + - `https://desktop-staging.kusche.berlin/` +- `Valid redirect URIs`: + - `https://desktop-staging.kusche.berlin/auth/callback` + - spaeter zusaetzlich live: + - `https://desktop.kusche.berlin/auth/callback` +- `Valid post logout redirect URIs`: + - `https://desktop-staging.kusche.berlin/` + - spaeter zusaetzlich live: + - `https://desktop.kusche.berlin/` +- `Web origins`: + - `https://desktop-staging.kusche.berlin` + - spaeter zusaetzlich live: + - `https://desktop.kusche.berlin` + +Dann `Save`. + +## Client Secret holen + +Nach dem Speichern: + +1. Den neuen Client oeffnen. +2. Auf `Credentials` gehen. +3. Das generierte Secret kopieren. + +Dieses Secret bitte noch nicht ins Repo schreiben. + +## Optional aber sinnvoll + +Diese Einstellungen danach noch pruefen: + +- `Login theme` + - spaeter auf das eigene Desktop-Theme setzen +- `Consent required` + - `Off` +- `Display on consent screen` + - `Off` + +## Werte fuer unser Projekt + +Nach dem Anlegen brauchen wir diese finalen Werte: + +- `base_url` + - Beispiel: `https://auth.kusche.berlin` +- `realm` + - Beispiel: `kusche-berlin` +- `client_id` + - `desktop-kusche-berlin` +- `client_secret` + - aus `Credentials` + +## Danach im Projekt eintragen + +Die Werte kommen danach in eine Umgebungsdatei, nicht in die allgemeine Basisdatei. + +Geplant ist: + +- `config/staging/keycloak.php` +- `config/prod/keycloak.php` + +Beispielstruktur: + +```php + true, + 'enforce_login' => true, + 'allow_desktop_preview' => false, + 'base_url' => 'https://auth.kusche.berlin', + 'realm' => 'kusche-berlin', + 'client_id' => 'desktop-kusche-berlin', + 'client_secret' => 'HIER_DAS_SECRET', + 'redirect_path' => '/auth/callback', + 'scopes' => ['openid', 'profile', 'email'], +]; +``` + +## Wichtig + +- `client_secret` gehoert nicht in `config/keycloak.php` +- nur in `config/staging/keycloak.php` bzw. `config/prod/keycloak.php` +- wenn Staging und Live unterschiedliche Keycloak-Clients bekommen sollen, dann je Umgebung eigene `client_id` und eigenes Secret nutzen + +## Rueckmeldung an Codex + +Wenn der Client angelegt ist, brauche ich von dir nur: + +- `base_url` +- `realm` +- `client_id` +- ob Staging und Live denselben Client nutzen sollen oder getrennte Clients + +Das Secret musst du mir nicht im Chat schicken, wenn du es lieber direkt lokal in die Env-Datei eintragen willst.