feat: fully backend-driven OIDC session flow (session cookie, not bearer token)
Replace the hybrid flow (frontend PKCE + POST /auth/session token exchange, access token in sessionStorage) with a classic backend-driven BFF: the browser only ever navigates to GET /api/v1/auth/login and is redirected straight to the IdP; PKCE verifier/state live server-side in Redis (SessionStoreService); GET /api/v1/auth/callback (now the registered IdP redirect URI, replacing the frontend's /auth/callback route, which is deleted) verifies the id_token, JIT-provisions the user, creates a Redis-backed session, and sets one httpOnly SameSite=Lax cookie before redirecting into the app. No token material of any kind ever reaches the browser. OidcAuthGuard (per-request bearer JWT verification) is replaced by SessionAuthGuard (cookie -> Redis session lookup) across every controller that used it. cookie-parser is now wired into main.ts. Frontend AuthService shrinks to login()/logout()/ensureSessionChecked(); pkce.ts, auth.interceptor.ts, and the callback component/route are all removed as dead code under this model. New required env var: APP_BASE_URL (source of truth for the OIDC redirect_uri and the post-login redirect target). Verified end-to-end against the real API, Redis, and a mocked IdP: login redirect shape, callback cookie + redirect, state-replay rejection, /users/me 401<->200 around the cookie, and logout.
This commit is contained in:
@@ -1340,3 +1340,25 @@ The plan's Task 10 assumed a public, PKCE-only SPA client and used `oidc-client-
|
||||
- A local dev proxy (`frontend/proxy.conf.json`, wired into `angular.json`'s `serve` target) forwards `/api/*` and `/health/*` from the Angular dev server to the API, avoiding a need for CORS configuration in local development (production already avoids CORS entirely since `edge` serves both origins).
|
||||
|
||||
This is a deployment-specific IdP constraint, not a general Travel Planner requirement — a future public-client IdP could skip the backend proxy — but the BFF pattern is what ships today since it is what the actual configured IdP requires.
|
||||
|
||||
---
|
||||
|
||||
## Addendum 2: Full Backend-Driven Session Flow, Not Just the Token Exchange (2026-08-17)
|
||||
|
||||
Shortly after Addendum 1 shipped, a review question ("why does OIDC discovery run in the frontend at all — shouldn't everything run in the backend?") led to a further, user-confirmed architecture change. Addendum 1 still had the frontend fetch IdP discovery itself, generate its own PKCE verifier/state, store the access token in `sessionStorage`, and POST the code+verifier to a `POST /api/v1/auth/session` endpoint. That is a workable "hybrid" BFF, but it (a) duplicated IdP knowledge between frontend and backend, and (b) still put the raw access token in JS-reachable browser storage, which is unnecessary exposure to XSS given the backend already brokers everything else.
|
||||
|
||||
The flow shipped instead is a classic, fully backend-driven BFF:
|
||||
|
||||
- `GET /api/v1/auth/login` (`AuthLoginController` → `AuthFlowService.buildAuthorizationRedirect`) generates the PKCE verifier/challenge and `state` **server-side**, persists `state → codeVerifier` in Redis (`SessionStoreService.createLoginAttempt`, 10-minute TTL), and issues an HTTP 302 straight to the IdP's `authorization_endpoint`. The frontend's `AuthService.login()` is now a one-line `window.location.href` navigation; it holds no PKCE state and never calls the IdP directly.
|
||||
- The registered redirect URI is now the **backend's** `${APP_BASE_URL}/api/v1/auth/callback`, not a frontend route. `frontend/src/app/auth/callback/` (the Angular callback component) was deleted entirely — there is nothing for the frontend to do on callback, since the backend redirects straight into `/trips` once the session is established. A new required env var, `APP_BASE_URL`, is the single source of truth for both the redirect URI sent to the IdP and the post-login redirect target.
|
||||
- `GET /api/v1/auth/callback` (`AuthFlowService.handleCallback`) consumes the one-time `state` (a replay returns 400), performs the token exchange (`TokenExchangeService`, unchanged from Addendum 1), verifies the response `id_token`'s signature/issuer/audience via `jose` against the IdP's JWKS, JIT-provisions the `User` from its claims, and creates a Redis-backed session (`SessionStoreService.createSession`, TTL = access-token lifetime) holding `{id, externalSubjectId, displayName, email}`. `TokenExchangeService.exchangeAuthorizationCode` now also returns `idToken` (previously deliberately omitted) since it's needed here — the guarantee that tokens never reach the browser is now structural (the callback response is a redirect with a Set-Cookie header, never a JSON body), not just a return-type convention.
|
||||
- The response sets exactly one cookie, `travel_planner_session` (httpOnly, `SameSite=Lax`, `Secure` when `APP_BASE_URL` is `https://`), containing only an opaque session id — never a JWT or any token material.
|
||||
- `OidcAuthGuard` (bearer-JWT verification per request) was deleted and replaced by `SessionAuthGuard`, which reads the cookie and looks up the session in Redis. Every controller that referenced `OidcAuthGuard`/`AuthenticatedUser` was updated to `SessionAuthGuard`/`SessionUser` (mechanical rename, same guard-composition pattern with `TripMembershipGuard`).
|
||||
- `POST /api/v1/auth/logout` deletes the Redis session and clears the cookie.
|
||||
- `main.ts` now registers Express's `cookie-parser` middleware globally (new dependency), since `SessionAuthGuard` reads `req.cookies`.
|
||||
- Frontend `pkce.ts`, `auth.interceptor.ts` (Bearer-header attachment — no longer needed since cookies are attached automatically by the browser for same-origin requests), and the `oidc-client-ts`-free-but-still-manual `POST /api/v1/auth/session` call are all gone. `AuthService` is now ~40 lines: `login()` navigates, `logout()` POSTs and clears local state, `ensureSessionChecked()` asks `GET /api/v1/users/me` and caches the in-flight promise so route guards don't trigger duplicate checks.
|
||||
- `provideHttpClient(withInterceptors([authInterceptor]))` reverted to plain `provideHttpClient()`.
|
||||
|
||||
Net effect: the browser holds zero token material at any point — not in `sessionStorage`, not in a JS-readable cookie, not in memory beyond the lifetime of the login redirect itself. This closes the XSS-exfiltration surface that Addendum 1's `sessionStorage`-held access token still had, at the cost of session state now living in Redis (already a hard dependency of this app) and one more required env var (`APP_BASE_URL`).
|
||||
|
||||
Verified end-to-end with the same style of mocked-IdP smoke test used in Task 13, extended to cover: login redirect shape (PKCE params present, targets the IdP), callback setting the httpOnly cookie and redirecting to `${APP_BASE_URL}/trips`, `state` replay rejection (400), `/users/me` 401→200 transition around the cookie, and logout returning `/users/me` to 401.
|
||||
|
||||
Reference in New Issue
Block a user