query->get('app', ''); if (!$this->apps->isValidApp($appKey)) { 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', ]); 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)) { return new Response('Invalid or missing state', 401); } $appKey = (string) $session->get('oauth_app'); $returnUrl = $this->apps->resolveReturnUrl($appKey); $requiredRole = $this->apps->requiredRole($appKey); $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'); } // 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. $accessClaims = $this->idTokenDecoder->decode($tokens['access_token']); if (!$this->hasRole($accessClaims, $requiredRole)) { $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'], '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, ], ]); $separator = str_contains($returnUrl, '?') ? '&' : '?'; return new RedirectResponse($returnUrl . $separator . http_build_query(['sid' => $kcSid])); } /** @param array $accessTokenClaims */ private function hasRole(array $accessTokenClaims, string $role): bool { $roles = $accessTokenClaims['resource_access'][$this->kcClientId]['roles'] ?? []; return in_array($role, $roles, true); } /** * Returns the logged-in user's profile (email, name, etc). 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 ID token, not the backend. */ #[Route('/auth/me', methods: ['GET'])] public function me(Request $request): JsonResponse { $kcSid = $this->extractBearer($request); if (!$kcSid) { return new JsonResponse(['error' => 'unauthenticated'], 401); } $session = $this->store->get($kcSid); if ($session === null) { return new JsonResponse(['error' => 'session expired'], 401); } return new JsonResponse($session['profile'] ?? []); } /** * 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) { 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); $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}", ]); } private function extractBearer(Request $request): ?string { $header = $request->headers->get('Authorization', ''); return str_starts_with($header, 'Bearer ') ? substr($header, 7) : null; } private function base64UrlEncode(string $bytes): string { return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); } }