From 405220c3e8d9886e752ba0d69c5a9924107e57ef Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Mon, 12 Oct 2026 11:16:46 +0100 Subject: [PATCH 1/4] docs: add Keycloak SSO setup guide Document registering this BFF as its own Keycloak client (confidential, standard flow, PKCE S256), the redirect-URI exact-match gotcha, the SSO_* env vars, and the gateway-side trusted_for_api_auth/api_audience admin-API step -- which has no env var equivalent. Links to mcp-context-forge's own Keycloak tutorial for realm/role-mapper detail rather than duplicating it. Refs: IBM/mcp-context-forge#6929 Signed-off-by: Gabriel Costa --- .env.example | 5 + .env.prod.example | 2 + .secrets.baseline | 36 ++- docs/DOCKER.md | 8 + docs/SSO.md | 454 +++++++++++++++++++++++++++++++++++++ docs/sso-keycloak-setup.md | 124 ++++++++++ 6 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 docs/SSO.md create mode 100644 docs/sso-keycloak-setup.md diff --git a/.env.example b/.env.example index 248ef537..c75678bb 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,11 @@ LOG_LEVEL=info # both Keycloak clients ("mcp-gateway" for the gateway's own /admin UI, # "contextforge-web-ui" for this BFF) are seeded directly in that repo's # infra/keycloak/realm-export.json, no manual admin-console setup needed. +# +# Setting this up against a Keycloak that ISN'T mcp-context-forge's own +# compose-sso stack (e.g. a real deployment)? See docs/sso-keycloak-setup.md +# for client registration, the redirect-URI gotcha, and the gateway-side +# trusted_for_api_auth step it also requires. SSO_ENABLED=false # SSO_KEYCLOAK_BASE_URL=http://localhost:8180 # Only needed if the browser-facing Keycloak host differs from the one above diff --git a/.env.prod.example b/.env.prod.example index da07a7b6..79abde17 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -60,6 +60,8 @@ LOG_LEVEL=info # Keycloak SSO login. Leave SSO_ENABLED=false to skip. When enabled, # BASE_URL/REALM/CLIENT_ID/CLIENT_SECRET are required; PUBLIC_BASE_URL is optional, SCOPES/TTL default. +# Full setup (Keycloak client registration, redirect-URI gotcha, the +# gateway-side trusted_for_api_auth step): docs/sso-keycloak-setup.md SSO_ENABLED=false SSO_KEYCLOAK_BASE_URL= # Only needed if the browser-facing Keycloak host differs from the one above. diff --git a/.secrets.baseline b/.secrets.baseline index a1d73185..57a60267 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -82,7 +82,41 @@ "hashed_secret": "c748440d5f1dd8ee8b1e5b3c96af779d3daa07a9", "is_secret": false, "is_verified": false, - "line_number": 95, + "line_number": 100, + "type": "Secret Keyword", + "verified_result": null + } + ], + "docs/SSO.md": [ + { + "hashed_secret": "d3aa62df6a55e9df7783f4fbaec1aafd0c0cb770", + "is_secret": false, + "is_verified": false, + "line_number": 118, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "6eae3a5b062c6d0d79f070c26e6d62486b40cb46", + "is_secret": false, + "is_verified": false, + "line_number": 252, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "4a0a2df96d4c9a13a282268cab33ac4b8cbb2c72", + "is_secret": false, + "is_verified": false, + "line_number": 293, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "5dbc716c4600097b85b9e51d6aeb77a4363b03ed", + "is_secret": false, + "is_verified": false, + "line_number": 379, "type": "Secret Keyword", "verified_result": null }, diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 1bae9efe..2d9e19b5 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -101,6 +101,14 @@ the compose default). If you see the `memory-redis` warning in `docker compose logs app` and didn't ask for it, check `.env` isn't setting `REDIS_URL=memory://`. +## SSO (Keycloak) + +`SSO_ENABLED=false` by default — the container boots the same either way, +nothing extra to configure for a plain password-login deployment. Turning +it on needs a Keycloak client registered for this BFF (separate from the +gateway's own) and a one-time gateway-side admin-API step with no env var +equivalent — see [`sso-keycloak-setup.md`](./sso-keycloak-setup.md). + ## Local TLS (self-signed certs) `npm run certs` generates a self-signed cert/key pair for local HTTPS diff --git a/docs/SSO.md b/docs/SSO.md new file mode 100644 index 00000000..f5397f77 --- /dev/null +++ b/docs/SSO.md @@ -0,0 +1,454 @@ +# Keycloak SSO — End-to-End + +This document traces the full Keycloak single sign-on flow this BFF +implements: login, session bootstrap, token refresh, API proxying, and +logout (including the back-channel Keycloak logout). For _setting up_ SSO +against a real Keycloak (client registration, env vars, the gateway-side +admin step), see [`sso-keycloak-setup.md`](./sso-keycloak-setup.md) instead — +this doc is the architecture trace, not the operator guide. Same split as +[`OAUTH.md`](./OAUTH.md) draws for the MCP OAuth flow. + +--- + +## Table of Contents + +1. [Why a Separate OIDC Client](#1-why-a-separate-oidc-client) +2. [Login — `GET /auth/sso/login`](#2-login--get-authssologin) +3. [Callback — `GET /auth/sso/callback`](#3-callback--get-authssocallback) +4. [Session Bootstrap — What Gets Stored](#4-session-bootstrap--what-gets-stored) +5. [Every Authenticated Request — `sessionAuth`](#5-every-authenticated-request--sessionauth) +6. [Token Refresh](#6-token-refresh) +7. [API Proxying — The Gateway's Side](#7-api-proxying--the-gateways-side) +8. [Logout](#8-logout) +9. [Full Flow Diagram](#9-full-flow-diagram) +10. [Error Handling](#10-error-handling) +11. [Relevant Source Files](#11-relevant-source-files) + +--- + +## 1. Why a Separate OIDC Client + +mcpgateway has its own `/auth/sso/*` flow, but it isn't usable directly from +this SPA — the gateway and the BFF are typically on different origins, and +the gateway's flow isn't designed to hand a session back to a third party. +So this BFF runs its **own** Authorization Code + PKCE client against the +same Keycloak realm, independent of the gateway's. + +One consequence: the bearer token this BFF ends up holding for a logged-in +user is a **Keycloak-issued access token**, not a gateway-issued JWT. Section +7 covers what that means for proxied API calls. + +--- + +## 2. Login — `GET /auth/sso/login` + +**File:** `server/src/routes/auth/sso-login.ts` + +The SPA never calls this as `fetch()` — `Login.tsx`'s SSO button does a full +top-level navigation: + +```ts +window.location.href = `/auth/sso/login?next=${encodeURIComponent(returnTo)}`; +``` + +A full navigation, not a fetch, because the response is a `302` to Keycloak's +own login page — there's nothing for client-side JS to do with that. + +The route: + +1. **Validates `next`** (`safeReturnTo`) — must be an `/app`-rooted relative + path with no `scheme://`, no `//host`, and no `..`, mirroring the SPA + router's own `validateDestination`. Anything else falls back to `/app/`. + This is the login flow's only attacker-controlled input, so it's the only + thing checked before any state is minted. +2. Bails out early (redirecting back to `/app/login?error=sso_`) if SSO + is disabled or the request fails the login-CSRF cross-origin check — no + session exists yet to hijack at this point, but a hostile page could still + force a victim's browser through an unwanted login attempt. +3. Fetches Keycloak's **OIDC discovery document** (`oidc-discovery.ts`, + cached 1h) for the `authorization_endpoint`. +4. Computes its own `redirect_uri` — **not** `window.location.origin`, which + would break behind a reverse proxy — from `resolvePublicOrigin(request)`, + the same trusted-origin resolution `OAUTH.md` describes for the OAuth + flow's redirect URI. +5. **Mints one-time login state** (`sso-login-state.ts`): + - `state` (UUID) — the Redis key. + - `nonce` (UUID) — carried through to the ID token, checked at callback. + - PKCE `code_verifier`/`code_challenge` (S256) — RFC 7636. + - `binding` — a random value set as an **httpOnly cookie** + (`sso_login_binding`); only its hash is stored server-side, so a Redis + dump alone can't forge it. Ties the eventual callback to _this browser_, + not just to someone who captured the `state` value off the wire. + + The whole record (`codeVerifier`, `nonce`, `returnTo`, `redirectUri`, + `bindingHash`) is stored in Redis keyed by `state`, TTL + `SSO_LOGIN_STATE_TTL_SECONDS` (default 300s). + +6. Redirects to Keycloak's authorize endpoint with + `response_type=code&client_id=...&redirect_uri=...&scope=...&state=...&code_challenge=...&code_challenge_method=S256&nonce=...`. + +The browser now leaves this app entirely and lands on Keycloak's own login +form. + +--- + +## 3. Callback — `GET /auth/sso/callback` + +**File:** `server/src/routes/auth/sso-callback.ts` + +Keycloak redirects back here after the user authenticates, with +`?code=...&state=...` (or `?error=...&state=...` if they denied consent or +something went wrong on Keycloak's side). + +1. **Consumes the login state unconditionally**, before even checking for an + `error` param — a GETDEL against `state`, validated against the + `sso_login_binding` cookie's hash (`timingSafeEqual`, not `===`). This is + single-use: a replayed `state` (same value, second request) finds nothing + in Redis the second time. Consuming it even on an error path matters too + — otherwise a user who cancels at Keycloak leaves a live, bound login + state sitting in Redis for the rest of its TTL. +2. If Keycloak reported `?error=...`, redirects to the login page with that + code (`sso_`), preserving `returnTo` from the now-consumed + state. +3. Re-fetches the discovery document for the `token_endpoint`, then + **exchanges the code** (`sso-token-exchange.ts`): + ``` + POST + grant_type=authorization_code&code=...&redirect_uri=... + &client_id=...&client_secret=...&code_verifier=... + ``` + `redirect: "error"` on the fetch — a redirect here would resend + `client_secret` to wherever it points. The response carries + `access_token`, `refresh_token` (if Keycloak issues one for this client), + `id_token`, and `expires_in`. +4. **Verifies the ID token** (`sso-user-resolution.ts`'s `verifySsoIdToken`): + - Must be RS256 (Keycloak's default; other algorithms are rejected + outright — no algorithm-confusion surface). + - Signature checked against the realm's JWKS (`getJwks`, cached 1h), + matched by `kid` (or the sole key, if Keycloak only publishes one). + - `iss` must equal the discovery document's own issuer. + - `aud` must include this client's ID. + - `exp` must not have passed (60s clock-skew allowance). +5. **Checks the `nonce`** from the verified claims against the one minted at + login — this is _not_ part of `verifySsoIdToken` itself, because only the + callback route knows which login attempt this is. A mismatch means the ID + token wasn't issued for this specific authorization request. +6. **Resolves the user** (`resolveSsoUser`): + - Requires `email` — missing it is `email_missing`. + - Requires `email_verified: true` — an unverified, federated email could + collide with an existing local account; trusting it would let an + attacker take over that account just by claiming the same address at + the IdP. This is `email_unverified`, a distinct error code from + `email_missing` for the login page to render. + - `full_name` from `name`, falling back to `preferred_username`. + - `is_admin: false` always — RBAC stays entirely gateway-side; this flag + is cosmetic only (see `AuthContext.tsx`'s own `hasPermission` caveat). +7. **Establishes the session** — see Section 4. +8. Redirects to the original `returnTo` from the (consumed) login state. + +Every failure path in this route redirects to `/app/login?error=sso_` +rather than rendering raw JSON or throwing a 500 — the only caller is a +top-level browser navigation, so there's no client-side code waiting to +parse an error body. + +--- + +## 4. Session Bootstrap — What Gets Stored + +**File:** `server/src/lib/establish-session.ts` + +`establishSession()` is the single chokepoint both SSO and password login +funnel through (see its own doc comment). For SSO, the callback calls it +with: + +```ts +await establishSession( + fastify, + request, + reply, + { access_token: tokens.accessToken, expires_in: tokens.expiresIn, user }, + { refreshToken: tokens.refreshToken, idToken: tokens.idToken }, +); +``` + +Note **`access_token` here is Keycloak's own access token** — not anything +the gateway issued. This is the detail Section 7 depends on. + +What it does: + +- **TTL decision**: the Redis session (and the `bff_sid` cookie's `maxAge`) + get `config.sessionTtlSeconds` _only if_ `ssoTokens.refreshToken` is + present — otherwise they're capped at the access token's own `expires_in`. + Rationale: a session with no refresh token can never be extended once the + access token expires, so a long-lived cookie around a short-lived token + would just mean every proxied call 401s for the remainder of the cookie's + life. Password-login sessions (no `ssoTokens` at all) always get the full + TTL, since the upstream password-login JWT has its own independent + lifetime story. +- **`tokenExpiresAt`** is stamped as `now + expires_in` (0 if Keycloak didn't + send one) — the single source of truth `sessionAuth` later checks against, + not re-derived from the JWT's own `exp` claim. +- **CSRF rotation**: deletes any pre-existing `bff_csrf` cookie before + minting a fresh one. Without this, a secret planted before login (a + subdomain XSS, or a plaintext hop with `COOKIE_SECURE=false`) would survive + into the authenticated session. +- Stores the `SessionRecord` (`bearerToken`, `refreshToken`, `idToken`, + `tokenExpiresAt`, `user`) in Redis under a fresh `sessionId`, sets the + `bff_sid` cookie (httpOnly, `sameSite: lax`), and returns the CSRF token for + the SPA to echo back as `X-CSRF-Token` on future state-changing calls. + +The browser only ever sees the opaque `bff_sid` — the Keycloak access/refresh +tokens and the ID token never leave the BFF. + +--- + +## 5. Every Authenticated Request — `sessionAuth` + +**File:** `server/src/plugins/session.ts` + +Every proxied `/api/*` call and every BFF-owned auth route runs through the +`sessionAuth` preHandler first: + +1. No `bff_sid` cookie → `401 unauthenticated`. +2. No matching Redis record → `401 session_expired`. +3. **`needsRefresh(record)`**: true only if there's a `refreshToken`, a + `tokenExpiresAt`, the access token is within `SSO_TOKEN_REFRESH_LEEWAY_SECONDS` + of expiring (default 30s), and we're not inside a post-failure + `refreshRetryAfter` backoff window. Password-login sessions (no + `refreshToken`) never hit this branch at all. +4. If a refresh is needed, acquires a **per-session Redis lock** + (`refreshRecordWithLock`) so concurrent requests near expiry don't each + fire their own `refresh_token` grant — Keycloak's refresh tokens can + rotate, so a second concurrent refresh with an already-superseded token + would just fail. A request that loses the lock race polls briefly for the + winner to finish, then re-reads the (by-then-refreshed) record instead of + refreshing itself. +5. **`isActuallyExpired(record)`** — checked _after_ any refresh attempt, + unconditionally. A transient Keycloak outage can leave `refreshRetryAfter` + set without touching the real `tokenExpiresAt`; this check is what + guarantees a BFF never forwards a bearer token past its genuine deadline, + even mid-outage. +6. Either outcome failing calls **`endSession`** — deletes the Redis record, + clears the cookie, responds `401 session_expired`. This is the same + revoke-on-failure shape `catch-all.ts`'s own upstream-401 handling uses + (Section 7) — a session that can't produce a live bearer token stops + looking authenticated immediately, not just once its TTL naturally lapses. + +On success, `request.session = { sessionId, bearerToken, user }` — everything +downstream (the proxy, BFF-owned routes) reads from here, never from Redis +directly. + +--- + +## 6. Token Refresh + +**File:** `server/src/lib/sso-token-refresh.ts`, called from `plugins/session.ts` + +A refresh is a `POST` to the same Keycloak token endpoint as the initial +exchange, just with `grant_type=refresh_token`: + +``` +POST +grant_type=refresh_token&refresh_token=...&client_id=...&client_secret=... +``` + +Two failure shapes, deliberately distinguished: + +- **`"rejected"`** — Keycloak's own RFC 6749 error code was `invalid_grant`. + The refresh token is actually dead (revoked, expired, already used if + Keycloak rotates them). `sessionAuth` treats this as a hard failure → + `endSession`. +- **`"unreachable"`** — network error, timeout, a non-JSON body, or _any + other_ 4xx/5xx (rate-limited, momentarily unavailable, a desynced + `client_secret`). None of these say the refresh token itself is bad. + `sessionAuth` treats this as transient: it keeps the pre-refresh token + (still cryptographically valid until its real `tokenExpiresAt`), sets a + short `refreshRetryAfter` cooldown so every request during the outage + doesn't each pay a fresh discovery+refresh round trip, and only actually + logs the user out once `isActuallyExpired` finally catches up. + +On a successful refresh, the new `access_token`/`refresh_token`/`id_token` +overwrite the session in place (same `sessionId` — the cookie never +changes), and the cookie's `maxAge` is reset to the full session TTL. Without +that reset, the browser would drop the cookie on the _original_ login's +schedule even though Redis stayed fresh underneath it. + +--- + +## 7. API Proxying — The Gateway's Side + +**File:** `server/src/routes/proxy/catch-all.ts` + +Every `/api/*` call injects `request.session.bearerToken` as the upstream +`Authorization` header and forwards it to the gateway. Since (per Section 4) +that token is **Keycloak's own access token for an SSO session**, the gateway +has to recognize it as valid on its own terms — it isn't a token the gateway +minted. + +This requires two things configured on the gateway side (covered in full in +[`sso-keycloak-setup.md`](./sso-keycloak-setup.md)): + +- `SSO_API_TOKEN_AUTH_ENABLED=true`. +- The gateway's own SSOProvider row for this realm marked + `trusted_for_api_auth: true` with an `api_audience` matching an audience + claim the Keycloak client's access tokens actually carry. + +If the gateway doesn't recognize the token (not trusted, wrong audience, or a +gateway version that doesn't dispatch external-IdP tokens on this code path +at all), it responds `401`. `catch-all.ts`'s `onResponse` hook treats any +upstream `401` as "this bearer token is dead" — it deletes the BFF session +and clears the cookie right then, rather than letting the browser keep +retrying a token that will never become valid. The SPA's existing +redirect-to-login handling takes it from there. + +--- + +## 8. Logout + +**File:** `server/src/routes/auth/logout.ts` + +`POST /auth/logout` is CSRF-protected like any other state-changing BFF call. +With a valid `bff_sid`: + +1. Reads the session record, then **deletes the Redis session immediately** + — before anything else runs. The upstream revoke and back-channel logout + below are both best-effort; neither gets to leave a live BFF session + behind if it stalls. +2. Runs two revocations **in parallel**, both best-effort (logged on + failure, never thrown): + - **`revokeUpstreamToken`** (`revoke-upstream-token.ts`) — `POST +/auth/logout` with the session's bearer token, so the + Keycloak-issued access token also gets gateway-side blocklisted, not + just unreachable from the browser. Without this it stays + cryptographically valid until its own `expires_in` lapses. + - **`backChannelLogoutSso`** (`sso-back-channel-logout.ts`) — **only if + the session has an `idToken`** (i.e. it's an SSO session at all). + Fetches the discovery document's `end_session_endpoint` (RP-Initiated + Logout, OIDC) and calls it with `id_token_hint=`. This is what + actually ends Keycloak's own SSO session — without it, the BFF-side + logout is cosmetic: Keycloak would silently re-approve the _next_ + `/auth/sso/login` attempt without ever showing a login form again, + because the browser still has a live session cookie on Keycloak's own + origin. +3. Clears both the session cookie and the CSRF cookie, responds `{ ok: true +}`. + +The frontend (`AuthContext.tsx`'s `logout()`) `await`s this call before doing +anything else — the server-side logout (session delete + both revocations) +is fully complete before the browser navigates to `/app/login`. + +--- + +## 9. Full Flow Diagram + +> Rendered natively on GitHub, GitLab, Notion, Obsidian, and VS Code +> (Markdown Preview Mermaid Support extension). + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User (Browser) + participant BFF as BFF (Fastify) + participant GW as mcpgateway (FastAPI) + participant KC as Keycloak + + note over User,KC: ── Login ───────────────────────────────────────────── + + User->>BFF: window.location.href = /auth/sso/login?next=/app/servers + note right of BFF: Validate next (safeReturnTo)
Mint state/nonce/PKCE verifier/binding
Store in Redis, keyed by state + BFF-->>User: 302 Location: /auth?client_id=...
&redirect_uri=.../auth/sso/callback
&state=...&code_challenge=...&nonce=...
Set-Cookie: sso_login_binding + + User->>KC: GET /auth?... + note right of User: User authenticates on Keycloak's own form + KC-->>User: 302 .../auth/sso/callback?code=ABC&state=... + + note over User,KC: ── Callback ────────────────────────────────────────── + + User->>BFF: GET /auth/sso/callback?code=ABC&state=...
Cookie: sso_login_binding + note right of BFF: Consume state (Redis GETDEL)
Verify binding cookie hash + BFF->>KC: POST
grant_type=authorization_code&code=ABC
+code_verifier+client_id+client_secret + KC-->>BFF: { access_token, refresh_token, id_token, expires_in } + note right of BFF: Verify id_token: JWKS signature,
iss, aud, exp, then nonce + note right of BFF: resolveSsoUser: require email + email_verified + note right of BFF: establishSession: store SessionRecord in Redis,
Set-Cookie: bff_sid, rotate CSRF cookie + BFF-->>User: 302 Location: /app/servers
Set-Cookie: bff_sid + + note over User,KC: ── Authenticated API Call ─────────────────────────── + + User->>BFF: GET /api/v1/mcp-servers
Cookie: bff_sid + note right of BFF: sessionAuth: load session,
refresh if near expiry (Section 6) + BFF->>GW: GET /v1/mcp-servers
Authorization: Bearer + note right of GW: trusted_for_api_auth + api_audience
must be configured for this to succeed + GW-->>BFF: 200 [...] + BFF-->>User: 200 [...] + + note over User,KC: ── Logout ──────────────────────────────────────────── + + User->>BFF: POST /auth/logout
Cookie: bff_sid + X-CSRF-Token + note right of BFF: Delete Redis session FIRST + par + BFF->>GW: POST /auth/logout
Authorization: Bearer + GW-->>BFF: 200 (blocklisted) + and + BFF->>KC: GET ?id_token_hint= + KC-->>BFF: 200 (Keycloak SSO session ended) + end + BFF-->>User: 200 { ok: true }
Clear-Cookie: bff_sid, bff_csrf + note right of User: window.location.href = /app/login + + note over User,KC: A later /auth/sso/login now shows Keycloak's
real login form again -- its own session is really gone. +``` + +--- + +## 10. Error Handling + +Every failure in the login or callback route redirects to +`/app/login?error=sso_&next=` rather than returning JSON or a +5xx — there's no client-side code to hand a JSON error body to, since both +hops are top-level browser navigations. `Login.tsx` renders one generic, +user-facing message for _any_ `sso_*` code (`classifyLoginError` doesn't +distinguish them) and strips the `error` param from the URL after reading it, +so a page refresh doesn't re-show the error. The specific code still reaches +the server logs for debugging — see `sso-login-error-redirect.ts`. + +| Code | Where | Meaning | +| -------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `disabled` | login | `SSO_ENABLED=false` | +| `cross_site_forbidden` | login | Login-CSRF guard rejected the request | +| `discovery_failed` | login, callback | Keycloak's `.well-known/openid-configuration` fetch failed | +| `callback_invalid` | callback | Missing `code`/`state`, or a repeated `?error=` param | +| `state_invalid` | callback | `state` not found in Redis (expired, already consumed, or forged) or binding-cookie mismatch | +| `` | callback | Keycloak itself reported `?error=...` (e.g. `access_denied`) | +| `token_exchange_failed` | callback | The code→token POST failed or Keycloak rejected it | +| `id_token_missing` | callback | Token response had no `id_token` | +| `id_token_invalid` | callback | Signature/iss/aud/exp verification failed, or JWKS/discovery errored | +| `nonce_mismatch` | callback | ID token's `nonce` didn't match the one minted at login | +| `email_missing` | callback | ID token had no `email` claim | +| `email_unverified` | callback | `email_verified` wasn't `true` | +| `password_change_required` | callback | Can't actually happen for SSO (`resolveSsoUser` always sets it `false`) — `establishSession`'s shared backstop exists for every caller, SSO included | + +--- + +## 11. Relevant Source Files + +| File | Role | +| -------------------------------------------- | -------------------------------------------------------------------------------------- | +| `src/pages/Login.tsx` | SSO button, `sso_*` error rendering | +| `src/auth/AuthContext.tsx` | Session bootstrap probe, `logout()` | +| `server/src/routes/auth/sso-login.ts` | `GET /auth/sso/login` — mints state, redirects to Keycloak | +| `server/src/routes/auth/sso-callback.ts` | `GET /auth/sso/callback` — code exchange, ID token verification, session establishment | +| `server/src/routes/auth/session.ts` | `GET /auth/session` — SPA bootstrap probe, also carries `ssoEnabled`/`providerName` | +| `server/src/routes/auth/logout.ts` | `POST /auth/logout` — session delete + both revocations | +| `server/src/lib/sso-login-state.ts` | PKCE verifier/nonce/returnTo, one-time, Redis-backed | +| `server/src/lib/sso-login-error-redirect.ts` | Shared `sso_` redirect builder | +| `server/src/lib/oidc-discovery.ts` | Discovery document fetch/cache, internal/public endpoint rewriting | +| `server/src/lib/sso-token-exchange.ts` | Authorization-code → tokens | +| `server/src/lib/sso-token-refresh.ts` | `refresh_token` grant, `"rejected"` vs `"unreachable"` | +| `server/src/lib/sso-user-resolution.ts` | ID token JWKS verification, claim validation, user resolution | +| `server/src/lib/establish-session.ts` | Shared session-creation chokepoint (SSO + password login) | +| `server/src/lib/session-store.ts` | `SessionRecord` shape, Redis CRUD, cookie helpers | +| `server/src/plugins/session.ts` | `sessionAuth` preHandler — refresh-or-reject on every authenticated request | +| `server/src/lib/revoke-upstream-token.ts` | Gateway-side bearer-token blocklist call | +| `server/src/lib/sso-back-channel-logout.ts` | RP-Initiated Logout against Keycloak | +| `server/src/routes/proxy/catch-all.ts` | Bearer injection on every proxied call; revoke-on-401 | diff --git a/docs/sso-keycloak-setup.md b/docs/sso-keycloak-setup.md new file mode 100644 index 00000000..6874f610 --- /dev/null +++ b/docs/sso-keycloak-setup.md @@ -0,0 +1,124 @@ +# SSO (Keycloak) Setup + +How to turn on Keycloak single sign-on for this BFF, end to end: registering +the BFF as its own OIDC client, the BFF-side env vars, and the one gateway-side +step that has no env var. + +This is **not** a Keycloak tutorial — for realm creation, role mappers, and +general Keycloak concepts, see `mcp-context-forge`'s own +[`docs/docs/manage/sso-keycloak-tutorial.md`](https://github.com/IBM/mcp-context-forge/blob/main/docs/docs/manage/sso-keycloak-tutorial.md). +This doc only covers what's specific to deploying _this_ BFF in front of a +gateway that already has Keycloak SSO working. For how the login/callback/ +refresh/logout flow actually works once it's set up, see +[`SSO.md`](./SSO.md). + +## Why a second client + +The gateway's own `/auth/sso/*` flow isn't usable directly from this SPA — it's +not cross-origin-safe for a separately-deployed BFF. This BFF runs its own, +independent OIDC (Authorization Code + PKCE) client against the same Keycloak +realm. You end up with two Keycloak clients against one realm: the gateway's +own (already set up per the tutorial above) and this BFF's (set up below). + +## 1. Register the BFF as a Keycloak client + +In the Keycloak admin console, under the realm the gateway already uses, +create a new client: + +| Setting | Value | +| ---------------------------------- | -------------------------------------------------------------------------------------------- | +| Client ID | anything, e.g. `contextforge-web-ui` | +| Client authentication | **On** (confidential client) | +| Authentication flow | **Standard flow** only — leave Direct access grants, Implicit flow, and Service accounts off | +| Valid redirect URIs | `/auth/sso/callback` — see the gotcha below | +| Proof Key for Code Exchange (PKCE) | **S256** — the BFF always sends `code_challenge_method=S256`; it's required, not optional | + +Save, then copy the client's **Client ID** and **Client secret** (Credentials +tab) — you'll need both below. + +### The redirect-URI gotcha + +The BFF computes its own redirect URI as: + +``` +/auth/sso/callback +``` + +Keycloak requires an **exact** match against what you registered above — +scheme, host, port, and path all have to agree character-for-character. Two +common ways this breaks: + +- **Trailing slash / missing port**: `https://app.example.com/auth/sso/callback` + vs `https://app.example.com:443/auth/sso/callback` are different strings to + Keycloak even though they're the same URL to a browser. +- **`PUBLIC_ORIGIN` unset behind a reverse proxy**: the BFF derives its origin + from the request itself unless `PUBLIC_ORIGIN` is set (see `.env.example`), + which is wrong behind a TLS-terminating proxy. Set `PUBLIC_ORIGIN` explicitly + whenever `TRUST_PROXY=true` — same rule the OAuth redirect URI follows, see + [`OAUTH.md`](./OAUTH.md). + +If login redirects to Keycloak but comes back with a generic error before +ever showing the login form, this is the first thing to check. + +## 2. BFF environment variables + +Set these in `.env` (native/dev) or your deployment's env (prod) — see +`.env.example`/`.env.prod.example` for the inline reference: + +| Var | Required | Notes | +| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SSO_ENABLED` | — | `false` by default. Set `true` to turn this all on. | +| `SSO_KEYCLOAK_BASE_URL` | when enabled | Server-to-server base URL (discovery/token/JWKS calls never leave the BFF). May be plain `http://` on a private/Docker network. | +| `SSO_KEYCLOAK_PUBLIC_BASE_URL` | only if it differs from `BASE_URL` | Browser-facing base, if Keycloak's internal and public hostnames differ (e.g. a Docker service name vs. a public hostname). **Must be `https://`** if set — the BFF validates this at boot. | +| `SSO_KEYCLOAK_REALM` | when enabled | | +| `SSO_KEYCLOAK_CLIENT_ID` | when enabled | The client ID from step 1. | +| `SSO_KEYCLOAK_CLIENT_SECRET` | when enabled | The client secret from step 1. | +| `SSO_KEYCLOAK_SCOPES` | no | Defaults to `openid profile email`. | +| `SSO_LOGIN_STATE_TTL_SECONDS` | no | Defaults to `300`. How long a PKCE verifier/state/nonce survives before the login attempt expires. | +| `SSO_TOKEN_REFRESH_LEEWAY_SECONDS` | no | Defaults to `30`. Refreshes a session's Keycloak tokens this many seconds before they'd actually expire. | + +`SSO_ENABLED=true` with any of `BASE_URL`/`REALM`/`CLIENT_ID`/`CLIENT_SECRET` +missing fails at **boot**, not at the first login attempt — the error names +exactly which vars are missing. + +## 3. Trust Keycloak tokens for the gateway's REST API + +This BFF forwards the Keycloak-issued access token it receives (not a +gateway-issued one) as the bearer token on every proxied `/api/*` call. For +the gateway to accept that token, its matching SSO provider record must be +marked `trusted_for_api_auth` with a matching `api_audience`. **This has no +environment variable** — it's an admin-API-only setting, confirmed by reading +the gateway's own `sso_bootstrap.py`. Do this once, after the gateway has +booted and auto-created the provider row from its own `SSO_KEYCLOAK_*` env: + +```bash +# Find the provider id (usually the realm/client slug the gateway bootstrapped): +curl -s -H "Authorization: Bearer " \ + /auth/sso/admin/providers + +# Mark it trusted, with the audience your Keycloak client's access tokens carry +# in their `aud` claim (configure an Audience protocol mapper on the Keycloak +# client if it doesn't have one yet): +curl -s -X PUT -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"trusted_for_api_auth": true, "api_audience": ""}' \ + /auth/sso/admin/providers/ +``` + +Without this step, login succeeds (you land authenticated in the SPA), but +every proxied API call the dashboard makes 401s — the gateway rejects the +Keycloak token outright. If you only see this symptom, start here. + +## Verification checklist + +1. Visit the SPA's login page — a "Sign in with Keycloak" button appears. +2. Click it, log in against a real Keycloak user — you land back in the SPA, + authenticated. +3. A protected page loads real data (proves the proxied-call round trip from + step 3 above is actually working, not just that a BFF session cookie + exists). +4. `/app/login` is inaccessible while authenticated (redirects to the app). +5. Log out — the session cookie is cleared, a direct API call 401s, and + logging in again shows Keycloak's own login form rather than silently + re-authenticating (proves the back-channel logout actually ended + Keycloak's session too, not just the BFF's). From 3dedcc5c3c680020114be42e67c1cab2bafd4eb5 Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Tue, 13 Oct 2026 16:22:05 +0100 Subject: [PATCH 2/4] docs(sso): address review feedback on Keycloak setup guide - Add the missing SSO_API_TOKEN_AUTH_ENABLED=true gateway step (split section 3 into 3.1/3.2); without it the trusted_for_api_auth step has no effect and every proxied call 401s. - Fix provider-id guidance: the gateway bootstrap hardcodes id "keycloak", not a realm/client slug. - Correct the PUBLIC_ORIGIN/TRUST_PROXY redirect-URI guidance: the derived origin is already correct once TRUST_PROXY=true and the proxy sets forwarded headers; PUBLIC_ORIGIN is for when the proxy isn't trusted, not "whenever TRUST_PROXY=true". - Fix SSO.md's TTL-decision bullet: a missing/invalid expires_in falls back to the full sessionTtlSeconds, not a cap derived from the token; tokenExpiresAt ends the session on the next request instead. - Minor: add a trailing-slash example, note the loopback http: exception on SSO_KEYCLOAK_PUBLIC_BASE_URL, name the admin-token permissions needed, flag shared-audience reuse as weakening api_audience's confused-deputy protection, remove stale sso_bootstrap.py narration, and unify the "why a second client" wording between the two docs. Signed-off-by: Marek Dano --- .secrets.baseline | 8 ++-- docs/SSO.md | 6 ++- docs/sso-keycloak-setup.md | 95 +++++++++++++++++++++++--------------- 3 files changed, 68 insertions(+), 41 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 57a60267..4d341ee8 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)(package-lock\\.json$)|^\\.secrets\\.baseline$|src/i18n/locales/*|openapi.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-10-13T14:57:20Z", + "generated_at": "2026-10-13T15:21:50Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -100,7 +100,7 @@ "hashed_secret": "6eae3a5b062c6d0d79f070c26e6d62486b40cb46", "is_secret": false, "is_verified": false, - "line_number": 252, + "line_number": 256, "type": "Secret Keyword", "verified_result": null }, @@ -108,7 +108,7 @@ "hashed_secret": "4a0a2df96d4c9a13a282268cab33ac4b8cbb2c72", "is_secret": false, "is_verified": false, - "line_number": 293, + "line_number": 297, "type": "Secret Keyword", "verified_result": null }, @@ -116,7 +116,7 @@ "hashed_secret": "5dbc716c4600097b85b9e51d6aeb77a4363b03ed", "is_secret": false, "is_verified": false, - "line_number": 379, + "line_number": 383, "type": "Secret Keyword", "verified_result": null }, diff --git a/docs/SSO.md b/docs/SSO.md index f5397f77..fec987fb 100644 --- a/docs/SSO.md +++ b/docs/SSO.md @@ -178,7 +178,11 @@ What it does: - **TTL decision**: the Redis session (and the `bff_sid` cookie's `maxAge`) get `config.sessionTtlSeconds` _only if_ `ssoTokens.refreshToken` is - present — otherwise they're capped at the access token's own `expires_in`. + present — otherwise they're capped at the access token's own `expires_in`, + when Keycloak sent a usable one. If `expires_in` is missing or invalid, the + fallback is the full `config.sessionTtlSeconds` instead; it's + `tokenExpiresAt` (stamped at `now + 0` in that case, see below) that then + ends the session on the very next request, not the cookie's own TTL. Rationale: a session with no refresh token can never be extended once the access token expires, so a long-lived cookie around a short-lived token would just mean every proxied call 401s for the remainder of the cookie's diff --git a/docs/sso-keycloak-setup.md b/docs/sso-keycloak-setup.md index 6874f610..c0888cbf 100644 --- a/docs/sso-keycloak-setup.md +++ b/docs/sso-keycloak-setup.md @@ -14,11 +14,13 @@ refresh/logout flow actually works once it's set up, see ## Why a second client -The gateway's own `/auth/sso/*` flow isn't usable directly from this SPA — it's -not cross-origin-safe for a separately-deployed BFF. This BFF runs its own, -independent OIDC (Authorization Code + PKCE) client against the same Keycloak -realm. You end up with two Keycloak clients against one realm: the gateway's -own (already set up per the tutorial above) and this BFF's (set up below). +mcpgateway has its own `/auth/sso/*` flow, but it isn't usable directly from +this SPA — the gateway and the BFF are typically on different origins, and +the gateway's flow isn't designed to hand a session back to a third party. +This BFF runs its own, independent OIDC (Authorization Code + PKCE) client +against the same Keycloak realm. You end up with two Keycloak clients against +one realm: the gateway's own (already set up per the tutorial above) and this +BFF's (set up below). ## 1. Register the BFF as a Keycloak client @@ -49,13 +51,16 @@ scheme, host, port, and path all have to agree character-for-character. Two common ways this breaks: - **Trailing slash / missing port**: `https://app.example.com/auth/sso/callback` - vs `https://app.example.com:443/auth/sso/callback` are different strings to + vs `https://app.example.com:443/auth/sso/callback`, or a trailing slash like + `https://app.example.com/auth/sso/callback/`, are different strings to Keycloak even though they're the same URL to a browser. -- **`PUBLIC_ORIGIN` unset behind a reverse proxy**: the BFF derives its origin - from the request itself unless `PUBLIC_ORIGIN` is set (see `.env.example`), - which is wrong behind a TLS-terminating proxy. Set `PUBLIC_ORIGIN` explicitly - whenever `TRUST_PROXY=true` — same rule the OAuth redirect URI follows, see - [`OAUTH.md`](./OAUTH.md). +- **`PUBLIC_ORIGIN` unset behind a proxy you haven't declared trusted**: the + BFF derives its origin from `request.protocol`/`request.host` unless + `PUBLIC_ORIGIN` is set (see `.env.example`). With `TRUST_PROXY=true` and a + proxy that correctly sets `X-Forwarded-Proto`/`X-Forwarded-Host`, that + derived origin is already correct. Set `PUBLIC_ORIGIN` explicitly instead + when the proxy isn't trusted via `TRUST_PROXY=true` — same rule the OAuth + redirect URI follows, see [`OAUTH.md`](./OAUTH.md). If login redirects to Keycloak but comes back with a generic error before ever showing the login form, this is the first thing to check. @@ -65,17 +70,17 @@ ever showing the login form, this is the first thing to check. Set these in `.env` (native/dev) or your deployment's env (prod) — see `.env.example`/`.env.prod.example` for the inline reference: -| Var | Required | Notes | -| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SSO_ENABLED` | — | `false` by default. Set `true` to turn this all on. | -| `SSO_KEYCLOAK_BASE_URL` | when enabled | Server-to-server base URL (discovery/token/JWKS calls never leave the BFF). May be plain `http://` on a private/Docker network. | -| `SSO_KEYCLOAK_PUBLIC_BASE_URL` | only if it differs from `BASE_URL` | Browser-facing base, if Keycloak's internal and public hostnames differ (e.g. a Docker service name vs. a public hostname). **Must be `https://`** if set — the BFF validates this at boot. | -| `SSO_KEYCLOAK_REALM` | when enabled | | -| `SSO_KEYCLOAK_CLIENT_ID` | when enabled | The client ID from step 1. | -| `SSO_KEYCLOAK_CLIENT_SECRET` | when enabled | The client secret from step 1. | -| `SSO_KEYCLOAK_SCOPES` | no | Defaults to `openid profile email`. | -| `SSO_LOGIN_STATE_TTL_SECONDS` | no | Defaults to `300`. How long a PKCE verifier/state/nonce survives before the login attempt expires. | -| `SSO_TOKEN_REFRESH_LEEWAY_SECONDS` | no | Defaults to `30`. Refreshes a session's Keycloak tokens this many seconds before they'd actually expire. | +| Var | Required | Notes | +| ---------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SSO_ENABLED` | — | `false` by default. Set `true` to turn this all on. | +| `SSO_KEYCLOAK_BASE_URL` | when enabled | Server-to-server base URL (discovery/token/JWKS calls never leave the BFF). May be plain `http://` on a private/Docker network. | +| `SSO_KEYCLOAK_PUBLIC_BASE_URL` | only if it differs from `BASE_URL` | Browser-facing base, if Keycloak's internal and public hostnames differ (e.g. a Docker service name vs. a public hostname). **Must be `https://`** if set (`http:` only allowed for `localhost`/`127.0.0.1`) — the BFF validates this at boot. | +| `SSO_KEYCLOAK_REALM` | when enabled | | +| `SSO_KEYCLOAK_CLIENT_ID` | when enabled | The client ID from step 1. | +| `SSO_KEYCLOAK_CLIENT_SECRET` | when enabled | The client secret from step 1. | +| `SSO_KEYCLOAK_SCOPES` | no | Defaults to `openid profile email`. | +| `SSO_LOGIN_STATE_TTL_SECONDS` | no | Defaults to `300`. How long a PKCE verifier/state/nonce survives before the login attempt expires. | +| `SSO_TOKEN_REFRESH_LEEWAY_SECONDS` | no | Defaults to `30`. Refreshes a session's Keycloak tokens this many seconds before they'd actually expire. | `SSO_ENABLED=true` with any of `BASE_URL`/`REALM`/`CLIENT_ID`/`CLIENT_SECRET` missing fails at **boot**, not at the first login attempt — the error names @@ -85,29 +90,47 @@ exactly which vars are missing. This BFF forwards the Keycloak-issued access token it receives (not a gateway-issued one) as the bearer token on every proxied `/api/*` call. For -the gateway to accept that token, its matching SSO provider record must be -marked `trusted_for_api_auth` with a matching `api_audience`. **This has no -environment variable** — it's an admin-API-only setting, confirmed by reading -the gateway's own `sso_bootstrap.py`. Do this once, after the gateway has -booted and auto-created the provider row from its own `SSO_KEYCLOAK_*` env: +the gateway to accept that token, two things both have to be true on the +gateway side. + +### 3.1 Enable bearer-token auth on the gateway + +Set `SSO_API_TOKEN_AUTH_ENABLED=true` in the gateway's own env. It defaults +to `false` (internal-JWT-only behavior), and **this has no effect on its +own** — it only lets the gateway consider external-IdP tokens at all; which +tokens it actually accepts is still controlled per-provider by step 3.2 +below. + +### 3.2 Mark the provider trusted for API auth + +The gateway's SSO provider record for this realm must be marked +`trusted_for_api_auth` with a matching `api_audience`. **This has no +environment variable** — it's an admin-API-only setting. Do this once, after +the gateway has booted and auto-created the provider row from its own +`SSO_KEYCLOAK_*` env. The admin token used below needs the +`admin.sso_providers:read` and `admin.sso_providers:update` permissions. ```bash -# Find the provider id (usually the realm/client slug the gateway bootstrapped): +# Confirm the provider id -- the gateway's Keycloak bootstrap always uses the +# fixed id `keycloak`, not a realm/client slug: curl -s -H "Authorization: Bearer " \ /auth/sso/admin/providers -# Mark it trusted, with the audience your Keycloak client's access tokens carry -# in their `aud` claim (configure an Audience protocol mapper on the Keycloak -# client if it doesn't have one yet): +# Mark it trusted, with an audience specific to this BFF client (configure an +# Audience protocol mapper on the Keycloak client if it doesn't have one yet +# -- reusing a shared audience like `mcp-gateway` across clients weakens the +# confused-deputy protection `api_audience` is meant to provide): curl -s -X PUT -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"trusted_for_api_auth": true, "api_audience": ""}' \ - /auth/sso/admin/providers/ + /auth/sso/admin/providers/keycloak ``` -Without this step, login succeeds (you land authenticated in the SPA), but -every proxied API call the dashboard makes 401s — the gateway rejects the -Keycloak token outright. If you only see this symptom, start here. +Without **both** of these, login succeeds (you land authenticated in the +SPA), but every proxied API call the dashboard makes 401s — the gateway +rejects the Keycloak token outright. If you only see this symptom, check +`SSO_API_TOKEN_AUTH_ENABLED` first, then the provider's +`trusted_for_api_auth`/`api_audience`. ## Verification checklist @@ -115,7 +138,7 @@ Keycloak token outright. If you only see this symptom, start here. 2. Click it, log in against a real Keycloak user — you land back in the SPA, authenticated. 3. A protected page loads real data (proves the proxied-call round trip from - step 3 above is actually working, not just that a BFF session cookie + section 3 above is actually working, not just that a BFF session cookie exists). 4. `/app/login` is inaccessible while authenticated (redirects to the app). 5. Log out — the session cookie is cleared, a direct API call 401s, and From b124b6ffc7b44f48306953c9d4eb74acbb3d3534 Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Thu, 15 Oct 2026 13:47:41 +0100 Subject: [PATCH 3/4] docs(sso): clarify /auth/session and no-refresh-token TTL behavior - Describe GET /auth/session and POST /auth/logout separately: they don't run sessionAuth, so refresh/expiry guarantees apply only to routes that install it. /auth/session can report authenticated: true for an expired access token until a protected request removes the session. - Replace the unconditional "password-login sessions get the full TTL" claim: any session without a refresh token gets validExpiresIn ?? sessionTtlSeconds, per establishSession(). - Regenerate .secrets.baseline for the shifted SSO.md line numbers. Addresses review feedback Signed-off-by: Marek Dano --- .secrets.baseline | 50 +++++++++++++++++++++++------------------------ docs/SSO.md | 39 ++++++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 4d341ee8..a84c0177 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)(package-lock\\.json$)|^\\.secrets\\.baseline$|src/i18n/locales/*|openapi.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-10-13T15:21:50Z", + "generated_at": "2026-10-15T12:46:16Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -85,64 +85,64 @@ "line_number": 100, "type": "Secret Keyword", "verified_result": null - } - ], - "docs/SSO.md": [ + }, { - "hashed_secret": "d3aa62df6a55e9df7783f4fbaec1aafd0c0cb770", - "is_secret": false, + "hashed_secret": "c1230136cf11214817a89d4d3260a827fa8ebe32", "is_verified": false, - "line_number": 118, + "line_number": 120, "type": "Secret Keyword", - "verified_result": null - }, + "verified_result": null, + "is_secret": false + } + ], + "docker-compose.e2e.yml": [ { - "hashed_secret": "6eae3a5b062c6d0d79f070c26e6d62486b40cb46", + "hashed_secret": "79a9c8ac01ebcabbd767a7786dd47b7efef9e562", "is_secret": false, "is_verified": false, - "line_number": 256, + "line_number": 23, "type": "Secret Keyword", "verified_result": null }, { - "hashed_secret": "4a0a2df96d4c9a13a282268cab33ac4b8cbb2c72", + "hashed_secret": "65724217e73023f759367bcb3375ea4c0a618418", "is_secret": false, "is_verified": false, - "line_number": 297, + "line_number": 70, "type": "Secret Keyword", "verified_result": null - }, + } + ], + "docs/SSO.md": [ { - "hashed_secret": "5dbc716c4600097b85b9e51d6aeb77a4363b03ed", + "hashed_secret": "d3aa62df6a55e9df7783f4fbaec1aafd0c0cb770", "is_secret": false, "is_verified": false, - "line_number": 383, + "line_number": 118, "type": "Secret Keyword", "verified_result": null }, { - "hashed_secret": "c1230136cf11214817a89d4d3260a827fa8ebe32", + "hashed_secret": "6eae3a5b062c6d0d79f070c26e6d62486b40cb46", "is_secret": false, "is_verified": false, - "line_number": 115, + "line_number": 271, "type": "Secret Keyword", "verified_result": null - } - ], - "docker-compose.e2e.yml": [ + }, { - "hashed_secret": "79a9c8ac01ebcabbd767a7786dd47b7efef9e562", + "hashed_secret": "4a0a2df96d4c9a13a282268cab33ac4b8cbb2c72", "is_secret": false, "is_verified": false, - "line_number": 23, + "line_number": 312, "type": "Secret Keyword", "verified_result": null }, { - "hashed_secret": "65724217e73023f759367bcb3375ea4c0a618418", + "hashed_secret": "5dbc716c4600097b85b9e51d6aeb77a4363b03ed", "is_secret": false, "is_verified": false, - "line_number": 70, + "line_number": 398, "type": "Secret Keyword", "verified_result": null } diff --git a/docs/SSO.md b/docs/SSO.md index fec987fb..74033431 100644 --- a/docs/SSO.md +++ b/docs/SSO.md @@ -178,17 +178,18 @@ What it does: - **TTL decision**: the Redis session (and the `bff_sid` cookie's `maxAge`) get `config.sessionTtlSeconds` _only if_ `ssoTokens.refreshToken` is - present — otherwise they're capped at the access token's own `expires_in`, - when Keycloak sent a usable one. If `expires_in` is missing or invalid, the - fallback is the full `config.sessionTtlSeconds` instead; it's - `tokenExpiresAt` (stamped at `now + 0` in that case, see below) that then - ends the session on the very next request, not the cookie's own TTL. + present. Otherwise (an SSO response that omitted it, or a password login) + they get the upstream's usable `expires_in` when there is one, falling back + to the full `config.sessionTtlSeconds` when `expires_in` is missing or + invalid. For an SSO session that fallback case is then ended by + `tokenExpiresAt` (stamped at `now + 0`, see below) on the very next + request, not by the cookie's own TTL. Rationale: a session with no refresh token can never be extended once the access token expires, so a long-lived cookie around a short-lived token would just mean every proxied call 401s for the remainder of the cookie's - life. Password-login sessions (no `ssoTokens` at all) always get the full - TTL, since the upstream password-login JWT has its own independent - lifetime story. + life. Password-login sessions carry no `ssoTokens`, so no `tokenExpiresAt` + is stamped and `sessionAuth` never expiry-checks them; they rely on the + cookie/Redis TTL above. - **`tokenExpiresAt`** is stamped as `now + expires_in` (0 if Keycloak didn't send one) — the single source of truth `sessionAuth` later checks against, not re-derived from the JWT's own `exp` claim. @@ -210,8 +211,9 @@ tokens and the ID token never leave the BFF. **File:** `server/src/plugins/session.ts` -Every proxied `/api/*` call and every BFF-owned auth route runs through the -`sessionAuth` preHandler first: +Every proxied `/api/*` call (and the SSE and OAuth-popup proxy routes) runs +through the `sessionAuth` preHandler first. `GET /auth/session` and +`POST /auth/logout` do **not** — see the end of this section. 1. No `bff_sid` cookie → `401 unauthenticated`. 2. No matching Redis record → `401 session_expired`. @@ -239,8 +241,21 @@ Every proxied `/api/*` call and every BFF-owned auth route runs through the looking authenticated immediately, not just once its TTL naturally lapses. On success, `request.session = { sessionId, bearerToken, user }` — everything -downstream (the proxy, BFF-owned routes) reads from here, never from Redis -directly. +downstream (the proxy, SSE and OAuth routes) reads from here, never from +Redis directly. + +### Routes that do not run `sessionAuth` + +The refresh and expiry guarantees above apply only to routes that install +`sessionAuth`. Two BFF-owned routes deliberately don't: + +- **`GET /auth/session`** (`routes/auth/session.ts`) reads the Redis record + directly. It never checks `tokenExpiresAt` or attempts a refresh, so a + session whose SSO access token has expired can still return + `authenticated: true` until a protected request runs `sessionAuth` and + removes the record. +- **`POST /auth/logout`** is guarded by `csrfProtection` only, so it works + even when the session is already expired or missing (Section 8). --- From 013cd917efecc2e2409b4a542f9e22db865e0b9f Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Thu, 15 Oct 2026 13:53:50 +0100 Subject: [PATCH 4/4] fix: test forgot-password expect no aria-invalid on a neutral email field Signed-off-by: Marek Dano --- src/pages/ForgotPassword.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ForgotPassword.test.tsx b/src/pages/ForgotPassword.test.tsx index 3a36cd88..28246263 100644 --- a/src/pages/ForgotPassword.test.tsx +++ b/src/pages/ForgotPassword.test.tsx @@ -66,7 +66,7 @@ describe("ForgotPassword", () => { expect(await screen.findByRole("alert")).toHaveTextContent( "Too many requests. Please try again later.", ); - expect(input).toHaveAttribute("aria-invalid", "false"); + expect(input).not.toHaveAttribute("aria-invalid"); }); it("validates email before calling API", async () => {