From 77b2f73172d97c02419b10196d853af5581cf28e Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Tue, 12 May 2026 10:53:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(portal-bff):=20/auth/login=20route=20?= =?UTF-8?q?=E2=80=94=20pkce=20flow=20start=20+=20signed=20cookie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/portal-bff/.env.example | 12 ++- .../src/auth/auth.controller.spec.ts | 73 ++++++++++++++++++ apps/portal-bff/src/auth/auth.controller.ts | 34 +++++++++ apps/portal-bff/src/auth/auth.cookie.ts | 45 +++++++++++ apps/portal-bff/src/auth/auth.module.ts | 14 ++++ apps/portal-bff/src/auth/auth.service.spec.ts | 59 +++++++++++++++ apps/portal-bff/src/auth/auth.service.ts | 74 +++++++++++++++++++ .../src/config/check-session-secret.spec.ts | 36 +++++++++ .../src/config/check-session-secret.ts | 61 +++++++++++++++ apps/portal-bff/src/main.ts | 16 +++- package.json | 3 + pnpm-lock.yaml | 61 ++++++++++++++- 12 files changed, 484 insertions(+), 4 deletions(-) create mode 100644 apps/portal-bff/src/auth/auth.controller.spec.ts create mode 100644 apps/portal-bff/src/auth/auth.controller.ts create mode 100644 apps/portal-bff/src/auth/auth.cookie.ts create mode 100644 apps/portal-bff/src/auth/auth.service.spec.ts create mode 100644 apps/portal-bff/src/auth/auth.service.ts create mode 100644 apps/portal-bff/src/config/check-session-secret.spec.ts create mode 100644 apps/portal-bff/src/config/check-session-secret.ts diff --git a/apps/portal-bff/.env.example b/apps/portal-bff/.env.example index 844d586..39743fa 100644 --- a/apps/portal-bff/.env.example +++ b/apps/portal-bff/.env.example @@ -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) diff --git a/apps/portal-bff/src/auth/auth.controller.spec.ts b/apps/portal-bff/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..90d0cef --- /dev/null +++ b/apps/portal-bff/src/auth/auth.controller.spec.ts @@ -0,0 +1,73 @@ +import type { Response } from 'express'; +import { AuthController } from './auth.controller'; +import { PRE_AUTH_COOKIE_NAME, PRE_AUTH_COOKIE_TTL_MS } from './auth.cookie'; +import type { AuthService, PreAuthPayload } from './auth.service'; + +const PRE_AUTH: PreAuthPayload = { + state: 'state-nonce', + codeVerifier: 'verifier-secret', + createdAt: 1_000, +}; + +function makeResStub() { + return { + cookie: jest.fn().mockReturnThis(), + redirect: jest.fn().mockReturnThis(), + } as unknown as Response & { cookie: jest.Mock; redirect: jest.Mock }; +} + +describe('AuthController', () => { + let beginAuthCodeFlow: jest.Mock; + let service: AuthService; + let controller: AuthController; + + beforeEach(() => { + beginAuthCodeFlow = jest.fn().mockResolvedValue({ + authUrl: 'https://entra.example/authorize?state=state-nonce', + preAuthPayload: PRE_AUTH, + }); + service = { beginAuthCodeFlow } as unknown as AuthService; + controller = new AuthController(service); + }); + + it('writes the pre-auth cookie and 302s to the Entra auth URL', async () => { + const res = makeResStub(); + await controller.login(res); + + expect(res.cookie).toHaveBeenCalledTimes(1); + const call = res.cookie.mock.calls[0] ?? []; + const [name, value, options] = call; + expect(name).toBe(PRE_AUTH_COOKIE_NAME); + expect(JSON.parse(value as string)).toEqual(PRE_AUTH); + expect(options).toMatchObject({ + signed: true, + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: PRE_AUTH_COOKIE_TTL_MS, + }); + + expect(res.redirect).toHaveBeenCalledWith( + 302, + 'https://entra.example/authorize?state=state-nonce', + ); + }); + + it('toggles cookie `secure` based on NODE_ENV', async () => { + const originalNodeEnv = process.env['NODE_ENV']; + try { + process.env['NODE_ENV'] = 'production'; + const res = makeResStub(); + await controller.login(res); + const call = res.cookie.mock.calls[0] ?? []; + const options = call[2] as { secure: boolean }; + expect(options.secure).toBe(true); + } finally { + if (originalNodeEnv === undefined) { + delete process.env['NODE_ENV']; + } else { + process.env['NODE_ENV'] = originalNodeEnv; + } + } + }); +}); diff --git a/apps/portal-bff/src/auth/auth.controller.ts b/apps/portal-bff/src/auth/auth.controller.ts new file mode 100644 index 0000000..a3deeda --- /dev/null +++ b/apps/portal-bff/src/auth/auth.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, Res } from '@nestjs/common'; +import type { Response } from 'express'; +import { AuthService } from './auth.service'; +import { PRE_AUTH_COOKIE_NAME, preAuthCookieOptions } from './auth.cookie'; + +/** + * OIDC routes mounted under `/api/auth/` per ADR-0009. + * + * v1 ships one route: `GET /login`, the entry point of the + * Authorization Code + PKCE flow. The follow-up PR adds + * `/callback`, then `/me` and `/logout` once Redis-backed sessions + * land (ADR-0010). + */ +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + /** + * Starts the auth flow. Generates a fresh state + PKCE pair via + * `AuthService`, stashes them in a short-lived signed cookie so + * `/callback` can verify the round-trip, and 302s the browser to + * Entra's authorize endpoint. + * + * The cookie carries everything the callback needs — no + * server-side state. That keeps `/login` stateless and friendly + * to horizontal scaling once the BFF runs more than one instance. + */ + @Get('login') + async login(@Res() res: Response): Promise { + const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(); + res.cookie(PRE_AUTH_COOKIE_NAME, JSON.stringify(preAuthPayload), preAuthCookieOptions()); + res.redirect(302, authUrl); + } +} diff --git a/apps/portal-bff/src/auth/auth.cookie.ts b/apps/portal-bff/src/auth/auth.cookie.ts new file mode 100644 index 0000000..a9c8c18 --- /dev/null +++ b/apps/portal-bff/src/auth/auth.cookie.ts @@ -0,0 +1,45 @@ +import type { CookieOptions } from 'express'; + +/** + * Name of the transient cookie that carries the OIDC `state` and + * PKCE `codeVerifier` between `/auth/login` and `/auth/callback`. + * + * v1 uses an unprefixed name because the local dev server is HTTP + * and the `__Host-` prefix mandates `Secure`. Production hardening + * (the dedicated infrastructure ADR for phase 3b) swaps this to + * `__Host-portal_pre_auth` and turns on `Secure`. Until then the + * exposure is limited to the 5-minute auth-flow window and the + * cookie is `HttpOnly` + signed + `SameSite=Lax`. + */ +export const PRE_AUTH_COOKIE_NAME = 'portal_pre_auth'; + +/** + * 5 minutes — enough for the round-trip through Entra (including a + * fresh MFA prompt), short enough that a stale cookie can't be + * replayed long after the user abandoned the flow. + */ +export const PRE_AUTH_COOKIE_TTL_MS = 5 * 60 * 1000; + +/** + * Cookie options shared by the pre-auth cookie set (login) and the + * pre-auth cookie clear (callback / failure paths). + * + * `signed: true` engages the `cookie-parser` signature using + * `SESSION_SECRET`. `httpOnly: true` keeps the value out of any + * JavaScript — the cookie is BFF-only. `sameSite: 'lax'` allows the + * cookie to travel on Entra's cross-site top-level redirect back to + * `/auth/callback`; a stricter `strict` would drop it. `secure` is + * derived from `NODE_ENV` so prod gets the HTTPS-only flag without + * breaking the local HTTP dev server. + */ +export function preAuthCookieOptions(): CookieOptions { + const isProduction = process.env['NODE_ENV'] === 'production'; + return { + signed: true, + httpOnly: true, + sameSite: 'lax', + secure: isProduction, + path: '/', + maxAge: PRE_AUTH_COOKIE_TTL_MS, + }; +} diff --git a/apps/portal-bff/src/auth/auth.module.ts b/apps/portal-bff/src/auth/auth.module.ts index 93d24ee..dee1cfe 100644 --- a/apps/portal-bff/src/auth/auth.module.ts +++ b/apps/portal-bff/src/auth/auth.module.ts @@ -2,6 +2,8 @@ import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node'; import { Module } from '@nestjs/common'; import { Logger } from 'nestjs-pino'; import { assertEntraConfig } from '../config/check-entra-config'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token'; import { MSAL_CLIENT } from './msal-client.token'; @@ -20,13 +22,25 @@ import { MSAL_CLIENT } from './msal-client.token'; * diagnostics land alongside the rest of the BFF logs (per * ADR-0012). PII logging is disabled by default — MSAL won't * include tokens or user identifiers in the messages it emits. + * - `AuthService` — first-leg-of-the-flow logic: PKCE + state + * generation, MSAL auth-code URL building. The controller + * stays a thin shell around it. + * + * v1 routes (per ADR-0009): + * + * - `GET /api/auth/login` — 302 to Entra's authorize endpoint + * with the freshly-generated state + code challenge; sets a + * short-lived signed cookie carrying the state + PKCE + * verifier so the next-PR callback can verify the round-trip. * * The module stays non-global: modules state "I depend on auth" by * importing it. Re-exports both tokens so a single * `imports: [AuthModule]` is enough to consume either. */ @Module({ + controllers: [AuthController], providers: [ + AuthService, { provide: ENTRA_CONFIG, useFactory: () => assertEntraConfig(), diff --git a/apps/portal-bff/src/auth/auth.service.spec.ts b/apps/portal-bff/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..c4b414d --- /dev/null +++ b/apps/portal-bff/src/auth/auth.service.spec.ts @@ -0,0 +1,59 @@ +import type { ConfidentialClientApplication } from '@azure/msal-node'; +import { AuthService } from './auth.service'; +import type { EntraConfig } from './entra-config.token'; + +const ENTRA: EntraConfig = { + instanceUrl: 'https://login.microsoftonline.com/', + tenantId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + clientId: '11111111-2222-3333-4444-555555555555', + clientSecret: 's3cret', + redirectUri: 'http://localhost:3000/api/auth/callback', + postLogoutRedirectUri: 'http://localhost:4200/', + authority: 'https://login.microsoftonline.com/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', +}; + +describe('AuthService', () => { + let getAuthCodeUrl: jest.Mock, [unknown]>; + let service: AuthService; + + beforeEach(() => { + getAuthCodeUrl = jest.fn().mockResolvedValue('https://entra.example/authorize?…'); + const msalStub = { getAuthCodeUrl } as unknown as ConfidentialClientApplication; + service = new AuthService(msalStub, ENTRA); + }); + + it('builds the auth URL with the configured redirect, OIDC scopes, S256 challenge', async () => { + await service.beginAuthCodeFlow(); + expect(getAuthCodeUrl).toHaveBeenCalledTimes(1); + const arg = getAuthCodeUrl.mock.calls[0]?.[0] as Record; + expect(arg['redirectUri']).toBe(ENTRA.redirectUri); + expect(arg['scopes']).toEqual(['openid', 'profile', 'email']); + expect(arg['codeChallengeMethod']).toBe('S256'); + expect(typeof arg['codeChallenge']).toBe('string'); + expect((arg['codeChallenge'] as string).length).toBeGreaterThan(20); + expect(typeof arg['state']).toBe('string'); + expect((arg['state'] as string).length).toBeGreaterThan(8); + }); + + it('returns the pre-auth payload with state + codeVerifier matching what MSAL was called with', async () => { + const { authUrl, preAuthPayload } = await service.beginAuthCodeFlow(); + expect(authUrl).toBe('https://entra.example/authorize?…'); + + const arg = getAuthCodeUrl.mock.calls[0]?.[0] as Record; + expect(preAuthPayload.state).toBe(arg['state']); + // The verifier returned to the caller is the secret the callback + // will send back to Entra; the challenge sent to Entra is its + // SHA-256-of-verifier transform, so the two must differ. + expect(preAuthPayload.codeVerifier).not.toBe(arg['codeChallenge']); + expect(typeof preAuthPayload.codeVerifier).toBe('string'); + expect(preAuthPayload.codeVerifier.length).toBeGreaterThan(40); + expect(preAuthPayload.createdAt).toBeLessThanOrEqual(Date.now()); + }); + + it('generates a fresh state + verifier on every call (replay protection)', async () => { + const a = await service.beginAuthCodeFlow(); + const b = await service.beginAuthCodeFlow(); + expect(a.preAuthPayload.state).not.toBe(b.preAuthPayload.state); + expect(a.preAuthPayload.codeVerifier).not.toBe(b.preAuthPayload.codeVerifier); + }); +}); diff --git a/apps/portal-bff/src/auth/auth.service.ts b/apps/portal-bff/src/auth/auth.service.ts new file mode 100644 index 0000000..2e082b5 --- /dev/null +++ b/apps/portal-bff/src/auth/auth.service.ts @@ -0,0 +1,74 @@ +import { ConfidentialClientApplication, CryptoProvider } from '@azure/msal-node'; +import { Inject, Injectable } from '@nestjs/common'; +import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token'; +import { MSAL_CLIENT } from './msal-client.token'; + +/** + * Payload carried in the pre-auth cookie between `/auth/login` and + * `/auth/callback`: the OIDC `state` (anti-CSRF nonce) and the PKCE + * `codeVerifier` (secret the BFF sends back to Entra to prove it + * was the one that asked for the code). `createdAt` lets the + * callback reject cookies older than the flow's expected duration. + */ +export interface PreAuthPayload { + readonly state: string; + readonly codeVerifier: string; + readonly createdAt: number; +} + +export interface AuthCodeFlowStart { + readonly authUrl: string; + readonly preAuthPayload: PreAuthPayload; +} + +/** + * The minimum OIDC scopes — `openid` to get an ID token, `profile` + * for the user's display name / preferred_username, `email` for the + * email claim. Refresh tokens (`offline_access`) are deliberately + * omitted in v1: sessions are short-lived (per ADR-0010) and the + * user re-authenticates through Entra rather than the BFF refreshing + * tokens behind their back. + */ +const SCOPES: readonly string[] = ['openid', 'profile', 'email']; + +@Injectable() +export class AuthService { + private readonly crypto = new CryptoProvider(); + + constructor( + @Inject(MSAL_CLIENT) private readonly msal: ConfidentialClientApplication, + @Inject(ENTRA_CONFIG) private readonly config: EntraConfig, + ) {} + + /** + * First leg of the OIDC Authorization Code + PKCE flow. Builds + * the URL the user gets redirected to (Entra's authorize + * endpoint) and the pre-auth payload the controller stores in a + * signed cookie so the callback can verify the round-trip later. + * + * MSAL Node owns the PKCE code generation (`CryptoProvider`) so + * the verifier / challenge pair is canonical. The state nonce is + * a fresh GUID per flow. + */ + async beginAuthCodeFlow(): Promise { + const { verifier, challenge } = await this.crypto.generatePkceCodes(); + const state = this.crypto.createNewGuid(); + + const authUrl = await this.msal.getAuthCodeUrl({ + redirectUri: this.config.redirectUri, + scopes: [...SCOPES], + state, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + }); + + return { + authUrl, + preAuthPayload: { + state, + codeVerifier: verifier, + createdAt: Date.now(), + }, + }; + } +} diff --git a/apps/portal-bff/src/config/check-session-secret.spec.ts b/apps/portal-bff/src/config/check-session-secret.spec.ts new file mode 100644 index 0000000..efa6c98 --- /dev/null +++ b/apps/portal-bff/src/config/check-session-secret.spec.ts @@ -0,0 +1,36 @@ +import { randomBytes } from 'node:crypto'; +import { assertSessionSecret } from './check-session-secret'; + +const STRONG_SECRET = randomBytes(32).toString('base64url'); + +describe('assertSessionSecret', () => { + const original = process.env['SESSION_SECRET']; + + afterEach(() => { + if (original === undefined) { + delete process.env['SESSION_SECRET']; + } else { + process.env['SESSION_SECRET'] = original; + } + }); + + it('returns the value when SESSION_SECRET decodes to ≥ 32 bytes', () => { + process.env['SESSION_SECRET'] = STRONG_SECRET; + expect(assertSessionSecret()).toBe(STRONG_SECRET); + }); + + it('throws when SESSION_SECRET is unset', () => { + delete process.env['SESSION_SECRET']; + expect(() => assertSessionSecret()).toThrow(/SESSION_SECRET is not set/); + }); + + it('throws when SESSION_SECRET is the .env.example placeholder', () => { + process.env['SESSION_SECRET'] = 'replace_with_32_random_bytes_base64url'; + expect(() => assertSessionSecret()).toThrow(/placeholder/); + }); + + it('throws when SESSION_SECRET decodes below 32 bytes', () => { + process.env['SESSION_SECRET'] = randomBytes(16).toString('base64url'); + expect(() => assertSessionSecret()).toThrow(/decodes to 16 bytes/); + }); +}); diff --git a/apps/portal-bff/src/config/check-session-secret.ts b/apps/portal-bff/src/config/check-session-secret.ts new file mode 100644 index 0000000..db78395 --- /dev/null +++ b/apps/portal-bff/src/config/check-session-secret.ts @@ -0,0 +1,61 @@ +/** + * Sanity-check the `SESSION_SECRET` env var early in bootstrap so a + * missing or obviously weak value fails fast instead of producing + * weak cookie integrity at runtime. + * + * Wired in `main.ts` alongside `assertDatabaseUrl()` and + * `assertEntraConfig()` — same family of pre-flight check per + * ADR-0018 §"BFF env-var loading". + * + * `SESSION_SECRET` signs the transient pre-auth cookie (state + PKCE + * verifier carried between `/auth/login` and `/auth/callback`, per + * ADR-0009) and will later cover the session cookie's integrity + * layer (per ADR-0010). One secret for the cookie family is enough; + * payload encryption uses dedicated keys (`SESSION_ENCRYPTION_KEY`, + * `OBO_CACHE_ENCRYPTION_KEY`) when those features land. + */ + +const PLACEHOLDER = 'replace_with_32_random_bytes_base64url'; +const MIN_ENTROPY_BYTES = 32; + +export function assertSessionSecret(): string { + const raw = process.env['SESSION_SECRET']; + if (!raw || raw === '') { + throw new Error( + `SESSION_SECRET is not set. Generate one with ` + + `"node -e \\"console.log(require('crypto').randomBytes(32).toString('base64url'))\\"" ` + + `and put it in apps/portal-bff/.env.`, + ); + } + + if (raw === PLACEHOLDER) { + throw new Error( + `SESSION_SECRET is still set to the .env.example placeholder ` + + `("${PLACEHOLDER}"). Replace with a real random value.`, + ); + } + + // Decode base64url-ish input — accept base64 too (Node decodes + // both). Anything below 32 bytes of decoded entropy is weaker than + // the AES-256 key family the project uses elsewhere; reject early. + let decoded: Buffer; + try { + decoded = Buffer.from(raw, 'base64url'); + } catch { + throw new Error(`SESSION_SECRET must be a base64url-encoded string. Got: ${truncate(raw)}`); + } + + if (decoded.length < MIN_ENTROPY_BYTES) { + throw new Error( + `SESSION_SECRET decodes to ${decoded.length} bytes, ` + + `below the ${MIN_ENTROPY_BYTES}-byte minimum (≈ 256 bits of entropy). ` + + `Generate a longer value.`, + ); + } + + return raw; +} + +function truncate(s: string): string { + return s.length > 16 ? `${s.slice(0, 16)}…` : s; +} diff --git a/apps/portal-bff/src/main.ts b/apps/portal-bff/src/main.ts index bf505f7..82f90a3 100644 --- a/apps/portal-bff/src/main.ts +++ b/apps/portal-bff/src/main.ts @@ -5,10 +5,12 @@ import './observability/tracing'; import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import cookieParser from 'cookie-parser'; import { Logger } from 'nestjs-pino'; import { AppModule } from './app/app.module'; import { assertDatabaseUrl } from './config/check-database-url'; import { assertEntraConfig } from './config/check-entra-config'; +import { assertSessionSecret } from './config/check-session-secret'; // Fail fast on a malformed DATABASE_URL (most often a special char in // the password that needs URL-encoding) rather than letting Prisma @@ -20,6 +22,10 @@ assertDatabaseUrl(); // than deep inside the first auth request. assertEntraConfig(); +// SESSION_SECRET signs the auth-flow cookies (pre-auth state + +// PKCE verifier today, session cookie next). +const sessionSecret = assertSessionSecret(); + async function bootstrap() { // `bufferLogs: true` holds early-bootstrap log lines until the // Pino-based Logger is wired in below, so we don't lose anything @@ -53,9 +59,15 @@ async function bootstrap() { }), ); + // Cookie parsing for the auth flow (per ADR-0009). The same + // `SESSION_SECRET` will cover the post-login session cookie when + // ADR-0010 storage lands; signed cookies are read from + // `req.signedCookies`, unsigned from `req.cookies`. + app.use(cookieParser(sessionSecret)); + // Phase-2 security ADRs will harden the above: helmet, real CORS - // allowlist, cookie-session, CSRF protection, rate limiting, auth - // guards, structured error filter. + // allowlist, CSRF protection, rate limiting, auth guards, + // structured error filter. const globalPrefix = 'api'; app.setGlobalPrefix(globalPrefix); diff --git a/package.json b/package.json index afaed6e..f01eb70 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,8 @@ "@swc/core": "1.15.33", "@swc/helpers": "0.5.21", "@tailwindcss/postcss": "^4.2.4", + "@types/cookie-parser": "^1.4.10", + "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "24.12.4", "@typescript-eslint/utils": "^8.40.0", @@ -144,6 +146,7 @@ "axios": "^1.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "cookie-parser": "^1.4.7", "lucide-angular": "^1.0.0", "nestjs-cls": "^6.2.0", "nestjs-pino": "^4.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e24797..923563f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: class-validator: specifier: ^0.15.1 version: 0.15.1 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 lucide-angular: specifier: ^1.0.0 version: 1.0.0(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)) @@ -243,6 +246,12 @@ importers: '@tailwindcss/postcss': specifier: ^4.2.4 version: 4.3.0 + '@types/cookie-parser': + specifier: ^1.4.10 + version: 1.4.10(@types/express@5.0.6) + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -4270,6 +4279,11 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -4291,9 +4305,15 @@ packages: '@types/express-serve-static-core@4.19.8': resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + '@types/express@4.17.25': resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -4357,6 +4377,9 @@ packages: '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/sockjs@0.3.36': resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==} @@ -5422,6 +5445,13 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} @@ -14812,6 +14842,10 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/cookie-parser@1.4.10(@types/express@5.0.6)': + dependencies: + '@types/express': 5.0.6 + '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -14839,6 +14873,13 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 1.2.1 + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 24.12.4 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + '@types/express@4.17.25': dependencies: '@types/body-parser': 1.19.6 @@ -14846,6 +14887,12 @@ snapshots: '@types/qs': 6.15.0 '@types/serve-static': 1.15.10 + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + '@types/http-errors@2.0.5': {} '@types/http-proxy@1.17.17': @@ -14910,7 +14957,7 @@ snapshots: '@types/serve-index@1.9.4': dependencies: - '@types/express': 4.17.25 + '@types/express': 5.0.6 '@types/serve-static@1.15.10': dependencies: @@ -14918,6 +14965,11 @@ snapshots: '@types/node': 24.12.4 '@types/send': 0.17.6 + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.12.4 + '@types/sockjs@0.3.36': dependencies: '@types/node': 24.12.4 @@ -16084,6 +16136,13 @@ snapshots: convert-source-map@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + cookie-signature@1.0.7: {} cookie-signature@1.2.2: {} -- 2.30.2