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

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.
This commit is contained in:
Julien Gautier
2026-05-12 10:53:32 +02:00
parent b7093d61de
commit 77b2f73172
12 changed files with 484 additions and 4 deletions
+11 -1
View File
@@ -62,6 +62,17 @@ ENTRA_CLIENT_SECRET=replace_with_real_value
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
# transient pre-auth cookie that carries the OIDC `state` + PKCE
# verifier between the /auth/login redirect and the /auth/callback
# round-trip, and (once ADR-0010 ships) the session cookie's
# integrity layer. Mandatory at boot — the BFF aborts if missing or
# obviously weak (less than 32 base64-decoded bytes ≈ 256 bits of
# entropy). Generate a fresh value per environment:
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
SESSION_SECRET=replace_with_32_random_bytes_base64url
# Future env vars introduced by upcoming phases / ADRs:
#
# Auth flow (ADR-0009) — additional keys wired as the routes land:
@@ -69,7 +80,6 @@ ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
# ENTRA_ACCEPTED_TENANT_IDS (CSV; restricts which tenants can sign in
# in the multi-tenant phase — empty means
# "only ENTRA_TENANT_ID is accepted")
# SESSION_SECRET (cookie signing key, see Sessions below)
#
# Sessions (ADR-0010):
# REDIS_URL (or REDIS_SENTINEL_HOSTS + REDIS_SENTINEL_NAME)