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..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-13T14:57:20Z", + "generated_at": "2026-10-15T12:46:16Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -82,17 +82,17 @@ "hashed_secret": "c748440d5f1dd8ee8b1e5b3c96af779d3daa07a9", "is_secret": false, "is_verified": false, - "line_number": 95, + "line_number": 100, "type": "Secret Keyword", "verified_result": null }, { "hashed_secret": "c1230136cf11214817a89d4d3260a827fa8ebe32", - "is_secret": false, "is_verified": false, - "line_number": 115, + "line_number": 120, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "docker-compose.e2e.yml": [ @@ -113,6 +113,40 @@ "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": 271, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "4a0a2df96d4c9a13a282268cab33ac4b8cbb2c72", + "is_secret": false, + "is_verified": false, + "line_number": 312, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "5dbc716c4600097b85b9e51d6aeb77a4363b03ed", + "is_secret": false, + "is_verified": false, + "line_number": 398, + "type": "Secret Keyword", + "verified_result": null + } + ], "e2e/keycloak/realm-export.json": [ { "hashed_secret": "65724217e73023f759367bcb3375ea4c0a618418", 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..74033431 --- /dev/null +++ b/docs/SSO.md @@ -0,0 +1,473 @@ +# 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 (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 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. +- **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 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`. +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, 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). + +--- + +## 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..c0888cbf --- /dev/null +++ b/docs/sso-keycloak-setup.md @@ -0,0 +1,147 @@ +# 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 + +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 + +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`, 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 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. + +## 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 (`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 +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, 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 +# 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 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/keycloak +``` + +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 + +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 + 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 + 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). 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 () => {