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_REDIRECT_URI=http://localhost:3000/api/auth/callback
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/ 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: # Future env vars introduced by upcoming phases / ADRs:
# #
# Auth flow (ADR-0009) — additional keys wired as the routes land: # 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 # ENTRA_ACCEPTED_TENANT_IDS (CSV; restricts which tenants can sign in
# in the multi-tenant phase — empty means # in the multi-tenant phase — empty means
# "only ENTRA_TENANT_ID is accepted") # "only ENTRA_TENANT_ID is accepted")
# SESSION_SECRET (cookie signing key, see Sessions below)
# #
# Sessions (ADR-0010): # Sessions (ADR-0010):
# REDIS_URL (or REDIS_SENTINEL_HOSTS + REDIS_SENTINEL_NAME) # REDIS_URL (or REDIS_SENTINEL_HOSTS + REDIS_SENTINEL_NAME)
@@ -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);
}
}
+45
View File
@@ -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,
};
}
+14
View File
@@ -2,6 +2,8 @@ import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import { assertEntraConfig } from '../config/check-entra-config'; 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 { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
import { MSAL_CLIENT } from './msal-client.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 * diagnostics land alongside the rest of the BFF logs (per
* ADR-0012). PII logging is disabled by default — MSAL won't * ADR-0012). PII logging is disabled by default — MSAL won't
* include tokens or user identifiers in the messages it emits. * 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 * The module stays non-global: modules state "I depend on auth" by
* importing it. Re-exports both tokens so a single * importing it. Re-exports both tokens so a single
* `imports: [AuthModule]` is enough to consume either. * `imports: [AuthModule]` is enough to consume either.
*/ */
@Module({ @Module({
controllers: [AuthController],
providers: [ providers: [
AuthService,
{ {
provide: ENTRA_CONFIG, provide: ENTRA_CONFIG,
useFactory: () => assertEntraConfig(), 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);
});
});
+74
View File
@@ -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;
}
+14 -2
View File
@@ -5,10 +5,12 @@ import './observability/tracing';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { assertDatabaseUrl } from './config/check-database-url'; import { assertDatabaseUrl } from './config/check-database-url';
import { assertEntraConfig } from './config/check-entra-config'; 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 // Fail fast on a malformed DATABASE_URL (most often a special char in
// the password that needs URL-encoding) rather than letting Prisma // the password that needs URL-encoding) rather than letting Prisma
@@ -20,6 +22,10 @@ assertDatabaseUrl();
// than deep inside the first auth request. // than deep inside the first auth request.
assertEntraConfig(); assertEntraConfig();
// SESSION_SECRET signs the auth-flow cookies (pre-auth state +
// PKCE verifier today, session cookie next).
const sessionSecret = assertSessionSecret();
async function bootstrap() { async function bootstrap() {
// `bufferLogs: true` holds early-bootstrap log lines until the // `bufferLogs: true` holds early-bootstrap log lines until the
// Pino-based Logger is wired in below, so we don't lose anything // 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 // Phase-2 security ADRs will harden the above: helmet, real CORS
// allowlist, cookie-session, CSRF protection, rate limiting, auth // allowlist, CSRF protection, rate limiting, auth guards,
// guards, structured error filter. // structured error filter.
const globalPrefix = 'api'; const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix); app.setGlobalPrefix(globalPrefix);
+3
View File
@@ -79,6 +79,8 @@
"@swc/core": "1.15.33", "@swc/core": "1.15.33",
"@swc/helpers": "0.5.21", "@swc/helpers": "0.5.21",
"@tailwindcss/postcss": "^4.2.4", "@tailwindcss/postcss": "^4.2.4",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "24.12.4", "@types/node": "24.12.4",
"@typescript-eslint/utils": "^8.40.0", "@typescript-eslint/utils": "^8.40.0",
@@ -144,6 +146,7 @@
"axios": "^1.6.0", "axios": "^1.6.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"lucide-angular": "^1.0.0", "lucide-angular": "^1.0.0",
"nestjs-cls": "^6.2.0", "nestjs-cls": "^6.2.0",
"nestjs-pino": "^4.6.1", "nestjs-pino": "^4.6.1",
+60 -1
View File
@@ -116,6 +116,9 @@ importers:
class-validator: class-validator:
specifier: ^0.15.1 specifier: ^0.15.1
version: 0.15.1 version: 0.15.1
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
lucide-angular: lucide-angular:
specifier: ^1.0.0 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)) 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': '@tailwindcss/postcss':
specifier: ^4.2.4 specifier: ^4.2.4
version: 4.3.0 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': '@types/jest':
specifier: ^30.0.0 specifier: ^30.0.0
version: 30.0.0 version: 30.0.0
@@ -4270,6 +4279,11 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} 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': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -4291,9 +4305,15 @@ packages:
'@types/express-serve-static-core@4.19.8': '@types/express-serve-static-core@4.19.8':
resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} 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': '@types/express@4.17.25':
resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} 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': '@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
@@ -4357,6 +4377,9 @@ packages:
'@types/serve-static@1.15.10': '@types/serve-static@1.15.10':
resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} 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': '@types/sockjs@0.3.36':
resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==} resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==}
@@ -5422,6 +5445,13 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} 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: cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
@@ -14812,6 +14842,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.12.4 '@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/deep-eql@4.0.2': {}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
@@ -14839,6 +14873,13 @@ snapshots:
'@types/range-parser': 1.2.7 '@types/range-parser': 1.2.7
'@types/send': 1.2.1 '@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': '@types/express@4.17.25':
dependencies: dependencies:
'@types/body-parser': 1.19.6 '@types/body-parser': 1.19.6
@@ -14846,6 +14887,12 @@ snapshots:
'@types/qs': 6.15.0 '@types/qs': 6.15.0
'@types/serve-static': 1.15.10 '@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-errors@2.0.5': {}
'@types/http-proxy@1.17.17': '@types/http-proxy@1.17.17':
@@ -14910,7 +14957,7 @@ snapshots:
'@types/serve-index@1.9.4': '@types/serve-index@1.9.4':
dependencies: dependencies:
'@types/express': 4.17.25 '@types/express': 5.0.6
'@types/serve-static@1.15.10': '@types/serve-static@1.15.10':
dependencies: dependencies:
@@ -14918,6 +14965,11 @@ snapshots:
'@types/node': 24.12.4 '@types/node': 24.12.4
'@types/send': 0.17.6 '@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': '@types/sockjs@0.3.36':
dependencies: dependencies:
'@types/node': 24.12.4 '@types/node': 24.12.4
@@ -16084,6 +16136,13 @@ snapshots:
convert-source-map@2.0.0: {} 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.0.7: {}
cookie-signature@1.2.2: {} cookie-signature@1.2.2: {}