feat(portal-bff): /auth/login route — pkce flow start + signed cookie #105
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff/auth-login-route"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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-parser+@types/expressdeps;main.tsmounts the cookie middleware with theSESSION_SECRETsigning key. Signed cookies are now available viareq.signedCookiesfor the upcoming callback..env.examplepromotesSESSION_SECRETfrom 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 ifSESSION_SECRETis unset, still the .env.example placeholder, or decodes below 32 bytes of entropy. Same family ascheck-database-url/check-entra-config.auth.service.ts—beginAuthCodeFlow()uses MSAL'sCryptoProviderfor canonical PKCE verifier / challenge generation and a fresh GUID state per call, callsmsal.getAuthCodeUrl()with the configured redirect URI + OIDC scopes (openid profile email— nooffline_accessin v1), and returns{ authUrl, preAuthPayload }.auth.cookie.ts—portal_pre_authname, 5-minute TTL, sharedCookieOptions:signed,httpOnly,sameSite: 'lax'(lets Entra's cross-site top-level redirect back through),securetoggled byNODE_ENV.auth.controller.ts—@Controller('auth') @Get('login'): writes the cookie then 302s. Thin shell around the service.ENTRA_CONFIGandMSAL_CLIENTproviders.Decisions worth flagging
/auth/logintoday round-trips through Entra and lands on a 404 — bounded mid-state, documented in the commit and here./loginstateless (no server-side store), which means the BFF stays horizontally scalable from day one without sticky-session config. The next-PR callback readsreq.signedCookiesto recover the payload.portal_pre_auth, not__Host-portal_pre_auth.__Host-mandatesSecure, and local dev is HTTP. The prefix +Secure: truelands together with the production TLS hardening ADR.offline_accessscope. 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.Verification
nx run-many -t lint test build --projects=portal-bff— green.check-session-secret,auth.service,auth.controller).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.Manual smoke test (next PR completes the loop)
apps/portal-bff/.envhas realENTRA_*+SESSION_SECRET.nx serve portal-bff.curl -i http://localhost:3000/api/auth/login→ 302 withSet-Cookie: portal_pre_auth=…; HttpOnly; SameSite=Lax; Path=/,Location: https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize?....Locationin a browser, authenticate, Entra redirects tohttp://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, verifiesstatematches, callsacquireTokenByCodewith the stored verifier, validates the ID token (issuer, audience, exp, nonce,amrper ADR-0011), clears the pre-auth cookie, logs the resolved user identity, redirects to/(SPA). Still no session — that's the PR after.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.