query->get('app', ''); if (!$this->apps->isValidApp($appKey)) { $this->logger->warning('auth.login.unknown_app', [ 'app' => $appKey, 'ip' => $request->getClientIp(), ]); throw $this->createNotFoundException('Unknown or missing app key'); } $verifier = $this->base64UrlEncode(random_bytes(64)); $challenge = $this->base64UrlEncode(hash('sha256', $verifier, true)); $state = bin2hex(random_bytes(16)); // Plain Symfony session cookie, used only for this short // browser <-> Keycloak <-> browser leg. Not the app's session. $session = $request->getSession(); $session->set('pkce_verifier', $verifier); $session->set('oauth_state', $state); $session->set('oauth_app', $appKey); $params = http_build_query([ 'client_id' => $this->kcClientId, 'response_type' => 'code', 'scope' => 'openid profile email', 'redirect_uri' => $this->kcRedirectUri, 'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', ]); $this->logger->info('auth.login.start', [ 'app' => $appKey, 'ip' => $request->getClientIp(), ]); return new RedirectResponse( "{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/auth?{$params}" ); } #[Route('/auth/callback', methods: ['GET'])] public function callback(Request $request): Response { $session = $request->getSession(); $expectedState = (string) $session->get('oauth_state', ''); $givenState = (string) $request->query->get('state', ''); if ($expectedState === '' || !hash_equals($expectedState, $givenState)) { $this->logger->warning('auth.callback.state_mismatch', [ 'ip' => $request->getClientIp(), 'had_expected_state' => $expectedState !== '', ]); return new Response('Invalid or missing state', 401); } $appKey = (string) $session->get('oauth_app'); $returnUrl = $this->apps->resolveReturnUrl($appKey); // Everything from here to the role check either succeeds or throws // (Keycloak unreachable, code rejected, bad signature, expired token, // no sid claim) and ends as an anonymous 500. Log the cause, then let // it through untouched — the response behaviour is deliberately // unchanged. try { $response = $this->client->request( 'POST', "{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/token", [ 'body' => [ 'grant_type' => 'authorization_code', 'client_id' => $this->kcClientId, 'client_secret' => $this->kcClientSecret, 'code' => $request->query->get('code'), 'redirect_uri' => $this->kcRedirectUri, 'code_verifier' => $session->get('pkce_verifier'), ], ] ); $tokens = $response->toArray(); $idClaims = $this->idTokenDecoder->decode($tokens['id_token']); $kcSid = $idClaims['sid'] ?? null; if (!$kcSid) { throw new \RuntimeException('Keycloak did not issue a "sid" claim on the ID token'); } $accessClaims = $this->idTokenDecoder->decode($tokens['access_token']); } catch (\Throwable $e) { $this->logger->error('auth.callback.failed', [ 'app' => $appKey, 'ip' => $request->getClientIp(), 'exception' => $e, ]); throw $e; } // Authorization gate: does this user hold the role required for // the app they're trying to enter? Checked against the access // token (not the ID token), since that's where client roles live. // This is the only enforcement point — the proxy trusts any // already-established session, by design, since "may this user // use app X" is a login-time question, not a per-request one. // // The full role list is kept on the session so /auth/me can report // the app's finer-grained permissions. Those are display data for // the app's UI only; they are never re-checked per request here — // the backend authorizes off the forwarded access token. $roles = $this->tokenRoles->extract($accessClaims); if (!in_array($this->apps->accessRole($appKey), $roles, true)) { $this->logger->warning('auth.login.denied', [ 'app' => $appKey, 'required_role' => $this->apps->accessRole($appKey), 'roles' => $roles, 'user_id' => $idClaims['sub'] ?? null, 'email' => $idClaims['email'] ?? null, ]); $session->remove('pkce_verifier'); $session->remove('oauth_state'); $session->remove('oauth_app'); $separator = str_contains($returnUrl, '?') ? '&' : '?'; return new RedirectResponse($returnUrl . $separator . http_build_query(['error' => 'access_denied'])); } $session->remove('pkce_verifier'); $session->remove('oauth_state'); $session->remove('oauth_app'); // Same kcSid -> same entry, whichever app is logging in. If another // app already created this session, this simply refreshes it. $this->store->put($kcSid, [ 'user_id' => $idClaims['sub'], 'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'], 'id_token' => $tokens['id_token'], 'expires_at' => time() + (int) $tokens['expires_in'], 'roles' => $roles, 'profile' => [ 'email' => $idClaims['email'] ?? null, 'email_verified' => $idClaims['email_verified'] ?? null, 'name' => $idClaims['name'] ?? null, 'given_name' => $idClaims['given_name'] ?? null, 'family_name' => $idClaims['family_name'] ?? null, 'preferred_username' => $idClaims['preferred_username'] ?? null, ], ]); $this->logger->info('auth.login.success', [ 'app' => $appKey, 'user_id' => $idClaims['sub'], 'email' => $idClaims['email'] ?? null, 'preferred_username' => $idClaims['preferred_username'] ?? null, 'roles' => $roles, 'sid_hash' => $this->sidHash($kcSid), 'expires_at' => time() + (int) $tokens['expires_in'], ]); $separator = str_contains($returnUrl, '?') ? '&' : '?'; return new RedirectResponse($returnUrl . $separator . http_build_query(['sid' => $kcSid])); } /** * Guard endpoint apps call on page load: is this sid a live session, and * may it enter *this* app? Deliberately returns no profile data, so it * stays cheap enough to run on every page load. * * This exists because an app's own login state (a `sid` in client-side * storage) is trivially forgeable — checking it client-side proves * nothing. It is still UI gating, not an authorization boundary: the * backend authorizes off the access token the proxy injects. */ #[Route('/auth/verify', methods: ['GET'])] public function verify(Request $request): JsonResponse { $appKey = (string) $request->query->get('app', ''); $session = $this->resolveAppSession($request, $appKey); if ($session instanceof JsonResponse) { return $session; } return new JsonResponse([ 'valid' => true, 'app' => $appKey, 'permissions' => $this->apps->permissions($appKey, $session['roles'] ?? []), ]); } /** * Returns the logged-in user's profile (email, name, etc) plus the * permissions they hold for the calling app. Called by an app as a * normal API query with `Authorization: Bearer ` — same pattern as * /api/*, but served directly by the BFF since this data comes from the * tokens, not the backend. * * The `app` query parameter is required: one session is shared by every * app the user opens, so the BFF can only scope permissions if the * caller says which app is asking — and so it can check the caller may * enter that app at all (same gate as /auth/verify). * * Permissions here are for the app's own UI gating. They are not an * authorization boundary — anything that matters must be enforced by * the backend off the access token the proxy injects. */ #[Route('/auth/me', methods: ['GET'])] public function me(Request $request): JsonResponse { $appKey = (string) $request->query->get('app', ''); $session = $this->resolveAppSession($request, $appKey); if ($session instanceof JsonResponse) { return $session; } return new JsonResponse(($session['profile'] ?? []) + [ 'app' => $appKey, 'permissions' => $this->apps->permissions($appKey, $session['roles'] ?? []), ]); } /** * Global logout. Called by an app as a normal (server-side-executed) * API query with `Authorization: Bearer `. Returns a URL rather * than redirecting itself, since the app must perform the actual * browser navigation to kill Keycloak's own SSO cookie. */ #[Route('/auth/logout', methods: ['POST'])] public function logout(Request $request): JsonResponse { $kcSid = $this->extractBearer($request); if (!$kcSid) { $this->logger->info('auth.logout.no_session'); return new JsonResponse(['error' => 'missing session'], 401); } $data = $this->store->get($kcSid); $idToken = $data['id_token'] ?? null; // One delete kills the session for every app that shared it. $this->store->revoke($kcSid); $this->logger->info('auth.logout', [ 'sid_hash' => $this->sidHash($kcSid), 'user_id' => $data['user_id'] ?? null, 'email' => $data['profile']['email'] ?? null, // false when the sid was already dead (expired, or a second // logout from another tab) — the response is the same either way. 'was_live' => $data !== null, ]); $params = http_build_query(array_filter([ 'id_token_hint' => $idToken, 'post_logout_redirect_uri' => $this->postLogoutRedirect, ])); return new JsonResponse([ 'logout_url' => "{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/logout?{$params}", ]); } /** * Bearer sid -> a session that is live *and* authorized for $appKey. * * Returns the session array, or the JsonResponse the caller should * return instead of a payload. Liveness goes through TokenRefresher * rather than a bare store lookup, so a session killed on the Keycloak * side (admin logout, revoked refresh token) fails here immediately * instead of lingering until the cache TTL — and so the roles checked * below are the ones the refresh just re-read. * * @return array|JsonResponse */ private function resolveAppSession(Request $request, string $appKey): array|JsonResponse { $kcSid = $this->extractBearer($request); if (!$kcSid) { // Routine on first page load, before an app has a sid to send. $this->logger->info('auth.session.unauthenticated', [ 'app' => $appKey, 'endpoint' => $request->getPathInfo(), ]); return new JsonResponse(['error' => 'unauthenticated'], 401); } if (!$this->apps->isValidApp($appKey)) { $this->logger->warning('auth.session.unknown_app', [ 'app' => $appKey, 'endpoint' => $request->getPathInfo(), ]); return new JsonResponse(['error' => 'unknown or missing app key'], 400); } if ($this->refresher->ensureFresh($kcSid) === null) { $this->logger->info('auth.session.expired', [ 'app' => $appKey, 'endpoint' => $request->getPathInfo(), 'sid_hash' => $this->sidHash($kcSid), ]); return new JsonResponse(['error' => 'session expired'], 401); } $session = $this->store->get($kcSid); if ($session === null) { $this->logger->info('auth.session.expired', [ 'app' => $appKey, 'endpoint' => $request->getPathInfo(), 'sid_hash' => $this->sidHash($kcSid), ]); return new JsonResponse(['error' => 'session expired'], 401); } // Same gate as callback(), re-run per call: one session is shared by // every app the user opens, so a sid on its own says nothing about // which app its holder may enter. if (!in_array($this->apps->accessRole($appKey), $session['roles'] ?? [], true)) { $this->logger->warning('auth.session.access_denied', [ 'app' => $appKey, 'endpoint' => $request->getPathInfo(), 'required_role' => $this->apps->accessRole($appKey), 'roles' => $session['roles'] ?? [], 'user_id' => $session['user_id'] ?? null, 'email' => $session['profile']['email'] ?? null, 'sid_hash' => $this->sidHash($kcSid), ]); return new JsonResponse(['error' => 'access_denied'], 403); } return $session; } private function extractBearer(Request $request): ?string { $header = $request->headers->get('Authorization', ''); return str_starts_with($header, 'Bearer ') ? substr($header, 7) : null; } /** * A sid is a live bearer credential, so it never goes into a log file. * This short digest is enough to correlate records of one session * without being replayable if the logs leak. */ private function sidHash(string $kcSid): string { return substr(hash('sha256', $kcSid), 0, 12); } private function base64UrlEncode(string $bytes): string { return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); } }