feat(portal-bff): /auth/login route — pkce flow start + signed cookie #105

Merged
julien merged 1 commits from feat/portal-bff/auth-login-route into main 2026-05-12 11:20:04 +02:00
Owner

Summary

Third step of ADR-0009 wiring. Adds the first OIDC route, GET /api/auth/login: it 302s the browser to Entra's authorize endpoint with a freshly-generated state + PKCE challenge, and stashes the matching {state, codeVerifier} payload in a short-lived signed cookie so the next-PR callback can verify the round-trip.

What lands

  • Cookie infra: cookie-parser + @types/express deps; main.ts mounts the cookie middleware with the SESSION_SECRET signing key. Signed cookies are now available via req.signedCookies for the upcoming callback.
  • .env.example promotes SESSION_SECRET from a future-vars comment into an active section, with a one-liner showing how to generate 32 random bytes.
  • check-session-secret.ts — boot-time guard: refuses to start if SESSION_SECRET is unset, still the .env.example placeholder, or decodes below 32 bytes of entropy. Same family as check-database-url / check-entra-config.
  • auth.service.tsbeginAuthCodeFlow() uses MSAL's CryptoProvider for canonical PKCE verifier / challenge generation and a fresh GUID state per call, calls msal.getAuthCodeUrl() with the configured redirect URI + OIDC scopes (openid profile email — no offline_access in v1), and returns { authUrl, preAuthPayload }.
  • auth.cookie.tsportal_pre_auth name, 5-minute TTL, shared CookieOptions: signed, httpOnly, sameSite: 'lax' (lets Entra's cross-site top-level redirect back through), secure toggled by NODE_ENV.
  • auth.controller.ts@Controller('auth') @Get('login'): writes the cookie then 302s. Thin shell around the service.
  • AuthModule registers the new controller + service alongside the existing ENTRA_CONFIG and MSAL_CLIENT providers.

Decisions worth flagging

  • Scope deliberately stops before the callback. It's the next PR. Clicking /auth/login today round-trips through Entra and lands on a 404 — bounded mid-state, documented in the commit and here.
  • State + verifier in the cookie, not in Redis. Keeps /login stateless (no server-side store), which means the BFF stays horizontally scalable from day one without sticky-session config. The next-PR callback reads req.signedCookies to recover the payload.
  • portal_pre_auth, not __Host-portal_pre_auth. __Host- mandates Secure, and local dev is HTTP. The prefix + Secure: true lands together with the production TLS hardening ADR.
  • No offline_access scope. Sessions are short-lived (per ADR-0010); the user re-authenticates through Entra rather than the BFF refreshing tokens behind their back. Smaller token footprint, less code to write, easier to reason about.
  • 5-minute cookie TTL. Enough for the Entra round-trip (including a fresh MFA prompt), short enough that a stale cookie can't be replayed long after the user abandoned the flow.

Verification

  • nx run-many -t lint test build --projects=portal-bff — green.
  • 39 / 39 specs (was 30; +9 across check-session-secret, auth.service, auth.controller).
  • The service spec mocks getAuthCodeUrl, asserts the redirect URI / scopes / S256 method, the state-verifier identity between the cookie payload and what's sent to Entra, and fresh-per-call replay protection.
  • The controller spec asserts the cookie name + options + serialized payload and the 302 redirect.

Manual smoke test (next PR completes the loop)

  1. apps/portal-bff/.env has real ENTRA_* + SESSION_SECRET.
  2. nx serve portal-bff.
  3. curl -i http://localhost:3000/api/auth/login → 302 with Set-Cookie: portal_pre_auth=…; HttpOnly; SameSite=Lax; Path=/, Location: https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize?....
  4. Open the Location in a browser, authenticate, Entra redirects to http://localhost:3000/api/auth/callback?code=…&state=… → 404 today, will be the next PR.

Next PR on the auth track

GET /api/auth/callback — reads the signed cookie, verifies state matches, calls acquireTokenByCode with the stored verifier, validates the ID token (issuer, audience, exp, nonce, amr per ADR-0011), clears the pre-auth cookie, logs the resolved user identity, redirects to / (SPA). Still no session — that's the PR after.

## Summary Third step of ADR-0009 wiring. Adds the first OIDC route, `GET /api/auth/login`: it 302s the browser to Entra's authorize endpoint with a freshly-generated state + PKCE challenge, and stashes the matching `{state, codeVerifier}` payload in a short-lived signed cookie so the next-PR callback can verify the round-trip. ## What lands - **Cookie infra**: `cookie-parser` + `@types/express` deps; `main.ts` mounts the cookie middleware with the `SESSION_SECRET` signing key. Signed cookies are now available via `req.signedCookies` for the upcoming callback. - **[`.env.example`](apps/portal-bff/.env.example)** promotes `SESSION_SECRET` from a future-vars comment into an active section, with a one-liner showing how to generate 32 random bytes. - **[`check-session-secret.ts`](apps/portal-bff/src/config/check-session-secret.ts)** — boot-time guard: refuses to start if `SESSION_SECRET` is unset, still the .env.example placeholder, or decodes below 32 bytes of entropy. Same family as `check-database-url` / `check-entra-config`. - **[`auth.service.ts`](apps/portal-bff/src/auth/auth.service.ts)** — `beginAuthCodeFlow()` uses MSAL's `CryptoProvider` for canonical PKCE verifier / challenge generation and a fresh GUID state per call, calls `msal.getAuthCodeUrl()` with the configured redirect URI + OIDC scopes (`openid profile email` — no `offline_access` in v1), and returns `{ authUrl, preAuthPayload }`. - **[`auth.cookie.ts`](apps/portal-bff/src/auth/auth.cookie.ts)** — `portal_pre_auth` name, 5-minute TTL, shared `CookieOptions`: `signed`, `httpOnly`, `sameSite: 'lax'` (lets Entra's cross-site top-level redirect back through), `secure` toggled by `NODE_ENV`. - **[`auth.controller.ts`](apps/portal-bff/src/auth/auth.controller.ts)** — `@Controller('auth') @Get('login')`: writes the cookie then 302s. Thin shell around the service. - **AuthModule** registers the new controller + service alongside the existing `ENTRA_CONFIG` and `MSAL_CLIENT` providers. ## Decisions worth flagging - **Scope deliberately stops before the callback.** It's the next PR. Clicking `/auth/login` today round-trips through Entra and lands on a 404 — bounded mid-state, documented in the commit and here. - **State + verifier in the cookie, not in Redis.** Keeps `/login` stateless (no server-side store), which means the BFF stays horizontally scalable from day one without sticky-session config. The next-PR callback reads `req.signedCookies` to recover the payload. - **`portal_pre_auth`, not `__Host-portal_pre_auth`.** `__Host-` mandates `Secure`, and local dev is HTTP. The prefix + `Secure: true` lands together with the production TLS hardening ADR. - **No `offline_access` scope.** Sessions are short-lived (per ADR-0010); the user re-authenticates through Entra rather than the BFF refreshing tokens behind their back. Smaller token footprint, less code to write, easier to reason about. - **5-minute cookie TTL.** Enough for the Entra round-trip (including a fresh MFA prompt), short enough that a stale cookie can't be replayed long after the user abandoned the flow. ## Verification - `nx run-many -t lint test build --projects=portal-bff` — green. - **39 / 39 specs** (was 30; +9 across `check-session-secret`, `auth.service`, `auth.controller`). - The service spec mocks `getAuthCodeUrl`, asserts the redirect URI / scopes / S256 method, the state-verifier identity between the cookie payload and what's sent to Entra, and fresh-per-call replay protection. - The controller spec asserts the cookie name + options + serialized payload and the 302 redirect. ## Manual smoke test (next PR completes the loop) 1. `apps/portal-bff/.env` has real `ENTRA_*` + `SESSION_SECRET`. 2. `nx serve portal-bff`. 3. `curl -i http://localhost:3000/api/auth/login` → 302 with `Set-Cookie: portal_pre_auth=…; HttpOnly; SameSite=Lax; Path=/`, `Location: https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize?...`. 4. Open the `Location` in a browser, authenticate, Entra redirects to `http://localhost:3000/api/auth/callback?code=…&state=…` → 404 today, will be the next PR. ## Next PR on the auth track `GET /api/auth/callback` — reads the signed cookie, verifies `state` matches, calls `acquireTokenByCode` with the stored verifier, validates the ID token (issuer, audience, exp, nonce, `amr` per ADR-0011), clears the pre-auth cookie, logs the resolved user identity, redirects to `/` (SPA). Still no session — that's the PR after.
julien added 1 commit 2026-05-12 11:15:57 +02:00
feat(portal-bff): /auth/login route — pkce flow start + signed cookie
CI / scan (pull_request) Successful in 2m32s
CI / commits (pull_request) Successful in 2m46s
CI / check (pull_request) Successful in 4m22s
CI / a11y (pull_request) Successful in 1m52s
CI / perf (pull_request) Successful in 5m19s
77b2f73172
Third step of ADR-0009 wiring. Adds the first OIDC route:
`GET /api/auth/login` 302s to Entra's authorize endpoint with a
freshly-generated state + PKCE challenge, and stashes the matching
`{state, codeVerifier}` payload in a short-lived signed cookie so
the next-PR callback can verify the round-trip.

What lands:

- `cookie-parser` + `@types/express` added as deps. main.ts mounts
  the cookie parser middleware with the `SESSION_SECRET` signing
  key — signed cookies become available via `req.signedCookies` for
  the upcoming callback.
- `.env.example` promotes `SESSION_SECRET` from a future-vars
  comment into an active variable, with a one-liner showing how to
  generate a 32-byte base64url value.
- `apps/portal-bff/src/config/check-session-secret.ts` — same family
  of boot-time guard as `check-database-url.ts` /
  `check-entra-config.ts`: refuses to start if `SESSION_SECRET` is
  unset, still the .env.example placeholder, or decodes below 32
  bytes of entropy.
- `apps/portal-bff/src/auth/auth.service.ts` —
  `AuthService.beginAuthCodeFlow()` uses MSAL Node's `CryptoProvider`
  for canonical PKCE verifier / challenge generation and state
  nonce (fresh GUID per call), calls `msal.getAuthCodeUrl()` with
  the configured redirect URI + OIDC scopes
  (`openid profile email` — no `offline_access`; sessions are
  short-lived and re-auth via Entra rather than refresh-token
  shenanigans, per ADR-0010), and returns the URL + pre-auth
  payload to the controller.
- `apps/portal-bff/src/auth/auth.cookie.ts` — pre-auth cookie name
  (`portal_pre_auth`), 5-minute TTL, and shared `CookieOptions`:
  `signed: true`, `httpOnly: true`, `sameSite: 'lax'` (allows
  Entra's cross-site top-level redirect back), `secure` toggled by
  `NODE_ENV`. The `__Host-` prefix waits for the prod TLS hardening
  ADR.
- `apps/portal-bff/src/auth/auth.controller.ts` —
  `@Controller('auth') GET login`: writes the cookie via Express
  `res.cookie()` (using `@Res()`) then 302s to the auth URL. Thin
  shell around `AuthService`.
- `AuthModule` registers the controller + service alongside the
  existing `ENTRA_CONFIG` and `MSAL_CLIENT` providers.

Verification:

- `nx run-many -t lint test build --projects=portal-bff` — green.
- 39/39 specs (was 30; +9 across the new validator spec, service
  spec, and controller spec).
- The service spec mocks MSAL's `getAuthCodeUrl` and asserts the
  redirect URI / scopes / S256 method, the state-verifier identity
  between the cookie payload and what's sent to Entra, and the
  fresh-per-call replay protection.
- The controller spec asserts the cookie name + options + payload
  serialization and the 302 redirect.

What this PR explicitly does NOT do:

- The callback. `GET /auth/callback` reads the cookie, exchanges
  the code for tokens via `acquireTokenByCode`, validates the ID
  token, and (next PR after) creates a session. If you click
  `/auth/login` today, you'll round-trip through Entra and land on
  a 404 — by design until the callback ships.
- Session persistence (waits on ADR-0010 Redis wiring).
- Logout, CSRF protection, route guards.
julien merged commit 0eb404d111 into main 2026-05-12 11:20:04 +02:00
julien deleted branch feat/portal-bff/auth-login-route 2026-05-12 11:20:07 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#105