feat(portal-bff): /auth/login route — pkce flow start + signed cookie (#105)
## 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.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #105
This commit was merged in pull request #105.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow();
|
||||
res.cookie(PRE_AUTH_COOKIE_NAME, JSON.stringify(preAuthPayload), preAuthCookieOptions());
|
||||
res.redirect(302, authUrl);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Promise<string>, [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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<AuthCodeFlowStart> {
|
||||
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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user