282a972346
## Summary Second half of the **DownstreamApiClient + OBO** chantier per [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md). Ships the **signed-assertion strategy** (non-Entra downstreams) and the **JWKS publishing endpoint** as testable primitives, completing the strategy layer the OBO PR (#137) started. The framework around them (DownstreamApiClientFactory, cockatiel, audience pre-check, error translation) still waits for the first concrete integration per the ADR's own "until then" clause. After this PR the BFF has, ready to plug into a future integration: - `OboStrategy` — Entra-protected downstreams (PR #137) - `SignedAssertionStrategy` — non-Entra downstreams (this PR) - `DownstreamTokenCache` — encrypted-at-rest OBO token cache (PR #137) - `GET /.well-known/jwks.json` — public key publication (this PR) ## What lands ### [`assertJwksConfig`](apps/portal-bff/src/config/check-jwks-config.ts) Boot validator for `BFF_JWKS_PRIVATE_KEY_PATH` + `BFF_JWKS_KID`. Reads the PEM file once at startup, refuses missing / unreadable / weak material (RSA < 2048, Ed25519, unknown key type), derives the JOSE algorithm (`RS256` / `ES256` / `ES384`) from the key shape, and validates the kid against `[A-Za-z0-9_-]{4,128}` so the value lives unescaped in JWT headers + JWKS payloads. ### [`BffSigningKey`](apps/portal-bff/src/downstream/bff-signing-key.ts) Singleton holding `{ config: JwksConfig, publicJwk: JWK }`. The `publicJwk` is derived from the **public half** of the key (via `jose.exportJWK` on a `createPublicKey`-derived `KeyObject`) so no private material can leak through. Single DI source for both consumers (strategy + JWKS controller) so a key rotation only changes one provider. ### [`SignedAssertionStrategy`](apps/portal-bff/src/downstream/strategies/signed-assertion.strategy.ts) Wraps `jose.SignJWT` with the ADR-0014 claim shape: ```json { "iss": "portal-bff", "sub": "<actor_id_hash>", "aud": "<downstream-name>", "audience": "workforce" | "customer", "claims": { /* curated subset */ }, "exp": <now + 60s>, "iat": <now>, "trace_id": "<W3C trace id>" } ``` - **60 s TTL** hard-coded — the ADR mandates it. - **No JWT cache** — at 60 s lifetime the savings would be negligible and a cache would let replayed assertions linger past their useful life. The signing operation itself is cheap (~hundreds of µs for RS256 with a 3 KB key). - **kid in the protected header** matches the JWKS so a downstream picks the right key during rotation. - Supports **RS256 / ES256 / ES384** transparently — picks the alg the validator derived at boot. ### [`JwksController`](apps/portal-bff/src/downstream/jwks.controller.ts) `GET /.well-known/jwks.json` returns `{ keys: [<single jwk>] }`. v1 publishes one key; the rotation chantier will add a second entry + window-based eviction so a downstream that cached the previous JWK keeps verifying during cut-over. [`main.ts`](apps/portal-bff/src/main.ts) excludes `/.well-known/*` from the global `/api` prefix so the route lands at the bare root per RFC 8615. No auth gate — the JWKS is the verification anchor; gating it would defeat the purpose. The CSRF middleware already exempts GET methods, so the route comes out clean. ## Required env update (mandatory at boot) Generate the key: ```bash mkdir -p apps/portal-bff/.secrets openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \ -out apps/portal-bff/.secrets/jwks.pem ``` Set in `apps/portal-bff/.env`: ```env BFF_JWKS_PRIVATE_KEY_PATH=apps/portal-bff/.secrets/jwks.pem BFF_JWKS_KID=bff-2026-05 ``` The repo's existing `*.pem` / `*.key` gitignore patterns cover `.secrets/`. ## Dependency - **`jose@^6`** added as a direct dep (was transitive via MSAL). Pinned at the workspace root since the BFF is the only consumer today and the package isn't part of the Angular bundle graph. - `jest.config.cts`: `jose` ships ESM-only, so its `node_modules` path is removed from `transformIgnorePatterns`. The pattern walks pnpm's deep `.pnpm/` layout — anything under `/node_modules/` whose path also contains `jose` somewhere gets transformed by ts-jest. ## Out of scope (deferred until the first concrete integration) Per ADR-0014's "until then" clause: - `DownstreamApiClientFactory` + per-service typed `DownstreamApiConfig`. - `cockatiel` resilience composition (timeout, retry, circuit breaker, bulkhead). - Audience pre-check at the call site (`audienceConstraint` → `authz.deny` audit). - Error translation tables per service. - OTel custom spans `downstream.<service>.<verb>.<path>`. - The framework code that actually calls `SignedAssertionStrategy.sign()` and attaches `X-User-Assertion` + the `ServiceCredential` auth header to an outbound HTTP request. - Key rotation (the JWKS lists one key for now; the rotation chantier adds the second entry + eviction policy). These land alongside the first concrete integration so the framework shape is validated against a real consumer, not speculative needs. ## Test plan - [x] `pnpm nx test portal-bff` — **358 specs pass** (was 334; +24: env validators 11, signing key 4, strategy 6, controller 3). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Env validator: missing path, unreadable file, garbage PEM, RSA-1024 (weak), Ed25519 (unsupported), missing kid, illegal kid charset, kid too short. - [x] Signing key: RSA / EC P-256 / EC P-384 round-trip to public JWK with no private material (`d`, `p`, `q`, `dp`, `dq`, `qi` all absent from the published JWK). - [x] Strategy: claim shape matches ADR-0014, `exp - iat == 60`, audience mismatch rejected, signature mismatch rejected, EC P-256 signing path (ES256), per-call freshness. - [x] Controller: returns JWKS with the single public key, no private material leaks. - [ ] Manual smoke: generate a key locally + set the two env vars + `curl http://localhost:3000/.well-known/jwks.json` should return the JWKS shape with the chosen kid. ## Notes for the reviewer - The strategy uses `setProtectedHeader({ alg, kid })` — the kid in the protected header is the canonical way to tell a verifier "use the entry with this kid in the JWKS". Without it, a verifier holding two keys during rotation has to try both. - The `60 s` TTL is intentionally not env-overridable. ADR-0014 mandates it; making it tunable would create a tempting knob to widen the replay window for "performance". - `jose` was already in the tree transitively (likely via MSAL). Promoting it to a direct dep + pinning means a future hoist deduplication can't silently remove it without our review. ## What's next The chantier's strategy layer is complete. Open follow-ups on the roadmap: - **First concrete downstream integration** — when a real consumer arrives, the framework gets built around the two strategies (DownstreamApiClientFactory, cockatiel resilience, audience pre-check, error translation, OTel spans, audit events). Until then the strategies + cache + JWKS sit ready. - **Strategic security baseline ADR** — RSSI sign-off on ASVS / HDS / GDPR / NIS 2. Paused per [CLAUDE.md](CLAUDE.md) §"Repository status". - **portal-admin v1 modules** — CMS pages, menu management, user list. Each is its own self-contained chantier. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #138
211 lines
9.3 KiB
TypeScript
211 lines
9.3 KiB
TypeScript
// MUST be the very first import — see apps/portal-bff/src/observability/tracing.ts
|
|
// for the reasoning. Anything `import`ed above this line bypasses the
|
|
// OpenTelemetry auto-instrumentations and is silently un-traced.
|
|
import './observability/tracing';
|
|
|
|
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
|
import { NestFactory } from '@nestjs/core';
|
|
import cookieParser from 'cookie-parser';
|
|
import helmet from 'helmet';
|
|
import { Logger } from 'nestjs-pino';
|
|
import { AppModule } from './app/app.module';
|
|
import { readCorsAllowlist } from './config/check-cors-allowlist';
|
|
import { assertDatabaseUrl } from './config/check-database-url';
|
|
import { assertEntraConfig } from './config/check-entra-config';
|
|
import { assertJwksConfig } from './config/check-jwks-config';
|
|
import { assertRedisConfig } from './config/check-redis-config';
|
|
import { assertLogUserIdSalt } from './config/check-log-user-id-salt';
|
|
import { assertOboCacheEncryptionKey } from './config/check-obo-cache-encryption-key';
|
|
import { assertSessionEncryptionKey } from './config/check-session-encryption-key';
|
|
import { assertSessionSecret } from './config/check-session-secret';
|
|
import { createRateLimitMiddleware, readRateLimitConfig } from './security/rate-limit.middleware';
|
|
import { CSRF_MIDDLEWARE } from './security/security.token';
|
|
import { StructuredErrorFilter } from './security/structured-error.filter';
|
|
import type { NextFunction, Request, Response } from 'express';
|
|
import {
|
|
ADMIN_SESSION_MIDDLEWARE,
|
|
SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
|
|
SESSION_MIDDLEWARE,
|
|
type RequestHandler,
|
|
} from './session/session.token';
|
|
|
|
// Fail fast on a malformed DATABASE_URL (most often a special char in
|
|
// the password that needs URL-encoding) rather than letting Prisma
|
|
// surface a cryptic "invalid connection string" error mid-request.
|
|
assertDatabaseUrl();
|
|
|
|
// Same family of pre-flight check for the Entra app-registration env
|
|
// vars (per ADR-0009). Missing / placeholder values fail here rather
|
|
// 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();
|
|
|
|
// REDIS_URL is the shared session / cache backend (ADR-0010). Boot-
|
|
// time guard so a malformed URL fails before `ioredis` enters its
|
|
// reconnect loop.
|
|
assertRedisConfig();
|
|
|
|
// SESSION_ENCRYPTION_KEY is the AES-256-GCM key for session payload
|
|
// at-rest encryption (ADR-0010). Same fail-fast policy as the other
|
|
// pre-flight validators — a missing / weak key here would only
|
|
// surface on the first authenticated request otherwise.
|
|
assertSessionEncryptionKey();
|
|
|
|
// LOG_USER_ID_SALT — per-environment SHA-256 salt for the actor-id
|
|
// hash that joins audit rows (ADR-0013) and Pino log lines
|
|
// (ADR-0012). Mandatory at boot.
|
|
assertLogUserIdSalt();
|
|
|
|
// OBO_CACHE_ENCRYPTION_KEY — dedicated AES-256-GCM key for the OBO
|
|
// downstream-token cache (ADR-0014 §"Token cache (for OBO)"). MUST
|
|
// differ from SESSION_ENCRYPTION_KEY — the validator refuses an
|
|
// identical value as defense in depth against copy-paste accidents.
|
|
assertOboCacheEncryptionKey();
|
|
|
|
// BFF_JWKS_PRIVATE_KEY_PATH + BFF_JWKS_KID — signing material for
|
|
// the ADR-0014 signed-assertion strategy. Reads the PEM file once
|
|
// here so a missing / unreadable / weak key fails the boot rather
|
|
// than the first downstream call. The same parsed config is
|
|
// re-used by `DownstreamModule`'s factory at app construction.
|
|
assertJwksConfig();
|
|
|
|
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
|
|
// emitted before `app.useLogger()`.
|
|
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
|
app.useLogger(app.get(Logger));
|
|
|
|
// Global exception filter — normalises every 4xx/5xx response to
|
|
// `{ error: { code, message, traceId } }`. The Nest default
|
|
// serialises HttpException's getResponse() at the top level,
|
|
// which leaks the class name on 500s and produces an
|
|
// inconsistent shape across exception types. Registering early
|
|
// (before request middleware mounts) ensures even errors thrown
|
|
// during route setup are caught.
|
|
app.useGlobalFilters(new StructuredErrorFilter(app.get(Logger)));
|
|
|
|
// Security headers (phase-2). Defaults from `helmet()` are good
|
|
// for an API server returning JSON: X-Frame-Options=SAMEORIGIN,
|
|
// X-Content-Type-Options=nosniff, Referrer-Policy=no-referrer,
|
|
// X-Powered-By removed, etc. CSP defaults apply too but the BFF
|
|
// doesn't render HTML, so they're inert here.
|
|
//
|
|
// Three overrides for our specific shape:
|
|
// - HSTS only in production (dev runs on plain HTTP).
|
|
// - crossOriginResourcePolicy: 'cross-origin' so the SPA on its
|
|
// own origin can read JSON from the BFF without being blocked
|
|
// by Spectre-class CORP protections.
|
|
// - contentSecurityPolicy: false in dev — Helmet's default CSP
|
|
// blocks `connect-src` from anything but 'self', which is
|
|
// fine for HTML pages but irrelevant for JSON responses and
|
|
// noisy in browser devtools.
|
|
app.use(
|
|
helmet({
|
|
hsts: process.env['NODE_ENV'] === 'production',
|
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
contentSecurityPolicy: process.env['NODE_ENV'] === 'production',
|
|
}),
|
|
);
|
|
|
|
// CORS allowlist — env-driven via `CORS_ALLOWED_ORIGINS`, parsed
|
|
// and validated at boot. No hardcoded localhost fallback: getting
|
|
// CORS wrong silently is exactly the kind of "works in dev, breaks
|
|
// in prod" issue this validator is meant to catch.
|
|
app.enableCors({
|
|
origin: [...readCorsAllowlist()],
|
|
allowedHeaders: [
|
|
'Content-Type',
|
|
'Accept',
|
|
'Authorization',
|
|
'X-CSRF-Token',
|
|
'traceparent',
|
|
'tracestate',
|
|
],
|
|
credentials: true,
|
|
});
|
|
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
}),
|
|
);
|
|
|
|
// Cookie parsing for the auth flow (per ADR-0009). `SESSION_SECRET`
|
|
// signs the pre-auth cookie and the post-login session id cookie;
|
|
// signed cookies are read from `req.signedCookies`, unsigned from
|
|
// `req.cookies`.
|
|
app.use(cookieParser(sessionSecret));
|
|
|
|
// Session middlewares (per ADR-0010 + ADR-0020 §"Sessions — distinct
|
|
// from `portal-shell`"). Two parallel express-session instances:
|
|
//
|
|
// - `SESSION_MIDDLEWARE` carries `portal_session` / Redis prefix
|
|
// `session:` and binds to every path EXCEPT `/api/admin/*`.
|
|
// - `ADMIN_SESSION_MIDDLEWARE` carries `portal_admin_session` /
|
|
// Redis prefix `session:admin:` and binds to `/api/admin/*` only.
|
|
//
|
|
// The dispatch is a tiny wrapper that picks one or the other per
|
|
// request — running both would have the second overwrite `req.session`
|
|
// from the first, collapsing the two surfaces. Mounted after
|
|
// `cookieParser` so the session-id cookie is parsed by the time the
|
|
// selected middleware reads it.
|
|
const userSession = app.get<RequestHandler>(SESSION_MIDDLEWARE);
|
|
const adminSession = app.get<RequestHandler>(ADMIN_SESSION_MIDDLEWARE);
|
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
if (req.path.startsWith('/api/admin')) {
|
|
return adminSession(req, res, next);
|
|
}
|
|
return userSession(req, res, next);
|
|
});
|
|
|
|
// Absolute-timeout enforcement (ADR-0010 §"TTL policy"). Runs on
|
|
// every request that survives `express-session`; if the session
|
|
// is past its 12 h hard ceiling, destroy it + clear the cookie +
|
|
// drop the per-user index entry, then let the request continue
|
|
// anonymously (route-level guards turn it into a 401 where
|
|
// needed).
|
|
app.use(app.get<RequestHandler>(SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE));
|
|
|
|
// Rate limiting (ADR-0015 §"DoS mitigation" + phase-2 follow-up).
|
|
// Mounted after the session middleware so the bucket key falls
|
|
// back to the session id for authenticated requests (preventing
|
|
// a single attacker from rotating sessions to dodge the limit)
|
|
// and to the remote IP otherwise. Default 120/min general, 10/min
|
|
// on `/auth/login` and `/auth/callback` to slow brute-force /
|
|
// replay attempts. `/api/health` is skipped — orchestrator polls
|
|
// shouldn't burn the user quota.
|
|
app.use(createRateLimitMiddleware(readRateLimitConfig()));
|
|
|
|
// Double-submit CSRF (ADR-0009 §"CSRF defense"). Mounted after
|
|
// the session middleware so `req.session.csrfToken` is available
|
|
// for comparison with the `X-CSRF-Token` request header. Skips
|
|
// safe methods (GET / HEAD / OPTIONS), anonymous requests, and
|
|
// the auth entry routes that *mint* the token (`/auth/login`,
|
|
// `/auth/callback`).
|
|
app.use(app.get<RequestHandler>(CSRF_MIDDLEWARE));
|
|
|
|
const globalPrefix = 'api';
|
|
// `/.well-known/*` is reserved by RFC 8615 for bare-root metadata
|
|
// endpoints. The BFF's JWKS controller (ADR-0014 signed-assertion
|
|
// strategy) lives at `/.well-known/jwks.json` so downstream
|
|
// services pointing at the standard location find it. Excluding
|
|
// the prefix lets Nest's router resolve the route at the root.
|
|
app.setGlobalPrefix(globalPrefix, {
|
|
exclude: [{ path: '.well-known/jwks.json', method: RequestMethod.GET }],
|
|
});
|
|
const port = process.env['PORT'] ?? 3000;
|
|
await app.listen(port);
|
|
|
|
app
|
|
.get(Logger)
|
|
.log(`Application is running on: http://localhost:${port}/${globalPrefix}`, 'Bootstrap');
|
|
}
|
|
|
|
bootstrap();
|