e43fa5ce24
Second half of the DownstreamApiClient + OBO chantier per ADR-0014.
Ships the signed-assertion strategy (non-Entra downstreams) and the
JWKS publishing endpoint as testable primitives. The framework
around them (DownstreamApiClientFactory, cockatiel, audience
pre-check, error translation) still waits for the first concrete
integration per the ADR's "until then" clause.
What lands
- assertJwksConfig (config/check-jwks-config.ts):
- Reads the PEM private key once at boot, refuses missing /
unreadable / weak material (RSA < 2048, Ed25519, unknown key
type). Derives the JOSE algorithm (RS256 / ES256 / ES384) from
the key shape so neither the strategy nor the JWKS controller
has to re-decide on the hot path.
- Validates BFF_JWKS_KID against [A-Za-z0-9_-]{4,128} so the
value lives unescaped in JWT headers + JWKS payloads.
- Wired in main.ts alongside the other assertX() validators.
- BffSigningKey (downstream/bff-signing-key.ts):
- Singleton holding { config: JwksConfig, publicJwk: JWK }.
publicJwk is derived from the private key via `jose.exportJWK`
on a public KeyObject — no private material leaks through.
- DI token BFF_SIGNING_KEY wires both consumers (strategy +
controller) to the same source of truth.
- SignedAssertionStrategy (downstream/strategies/signed-assertion.strategy.ts):
- Wraps `jose.SignJWT` with the ADR-0014 claim shape: iss,
sub, aud, audience (workforce|customer), claims (curated
subset), trace_id, iat, exp.
- 60 s TTL hard-coded — the ADR mandates it; cache disabled
because the savings on a 60 s JWT would be marginal and a
cache would let replayed assertions linger past their TTL.
- kid 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 (downstream/jwks.controller.ts):
- GET /.well-known/jwks.json returns { keys: [<single jwk>] }.
- 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). Read-only, so the CSRF middleware's
GET-exempt path already handles it.
Configuration
- Generate a key:
mkdir -p apps/portal-bff/.secrets && \
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
-out apps/portal-bff/.secrets/jwks.pem
- BFF_JWKS_PRIVATE_KEY_PATH (path to the PEM)
- BFF_JWKS_KID (URL-safe id, 4..128 chars)
- Both mandatory at boot.
- `apps/portal-bff/.secrets/` is matched by the repo's existing
*.pem / *.key gitignore patterns.
Deps
- jose@^6 added as a direct dep (was transitive). 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/ that
also contains `jose` somewhere in the path gets transformed.
Tests: +24 specs (env validators 11, signing key 4, strategy 6,
controller 3).
Out of scope (deferred per ADR-0014 "until then"):
- DownstreamApiClientFactory + per-service typed config.
- cockatiel resilience composition.
- Audience pre-check at the call site.
- Error translation tables.
- OTel custom spans `downstream.<service>.<verb>.<path>`.
- The framework wiring that calls SignedAssertionStrategy.sign()
+ attaches the `X-User-Assertion` + ServiceCredential auth
header to outbound HTTP requests.
- Key rotation (the JWKS lists one key for now; rotation chantier
adds a second entry + a window-based eviction policy).
These land alongside the first concrete integration so the
framework shape is validated against a real consumer.
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();
|