1513ad327c
## Summary Adds an OpenAPI 3 spec + a [Scalar API Reference](https://scalar.com/) UI to `portal-bff`, dev-only. The BFF previously had no way to *see* its HTTP surface short of grepping for `@Get` / `@Post`; this PR generates the spec from the existing Nest controllers via [`@nestjs/swagger`](https://docs.nestjs.com/openapi/introduction) and renders it through Scalar — a modern alternative to the classic Swagger UI (single-page, fast, dark-mode native, better typography). ## What lands ### Two new dev-only routes | Route | What it serves | | --- | --- | | `GET /api/openapi.json` | Raw OpenAPI 3 document. External tools (Bruno / Insomnia / Postman) import from here. | | `GET /api/docs` | Scalar API Reference HTML page. Loads the JSON spec at render time and renders the full endpoint catalogue with a "Try it" panel. | Both routes are gated behind `process.env.NODE_ENV !== 'production'` in [`setupOpenApi`](apps/portal-bff/src/openapi/openapi.ts) — production deployments don't need the docs surface, and publishing it would hand an attacker a curated map of every authenticated endpoint + every DTO shape. If a future ops use-case wants the spec in prod (internal gateway, contract testing), the gate is one line away from an opt-in `OPENAPI_PUBLISH=true` env knob. ### Core implementation — [`apps/portal-bff/src/openapi/openapi.ts`](apps/portal-bff/src/openapi/openapi.ts) Two exported helpers: - **`buildOpenApiDocument(app)`** — wraps Nest's `DocumentBuilder` + `SwaggerModule.createDocument`. Sets title, description (mentions the CSRF caveat — see below), version, and registers **two** cookie security schemes: - `portal_session` for the user-portal surface ([ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md)). - `portal_admin_session` for the admin-portal surface ([ADR-0020](docs/decisions/0020-portal-admin-app.md)). No `@ApiBearerAuth` is declared — the BFF never exposes a bearer-auth surface (SPA never holds tokens per ADR-0009; downstream OBO tokens are server-side only per ADR-0014). - **`setupOpenApi(app, globalPrefix)`** — short-circuits in production, otherwise binds the two routes via the Express adapter directly (`app.getHttpAdapter().get(...)` and `app.use(...)`). The OpenAPI JSON is a static asset and Scalar is a vanilla Express middleware — wrapping either in a Nest controller would add zero value and an extra layer of indirection. Wired into bootstrap at [`apps/portal-bff/src/main.ts:220`](apps/portal-bff/src/main.ts#L220), immediately after the JWKS endpoint mount and before `app.listen()`. ### Controllers decorated with `@ApiTags` / `@ApiOperation` / `@ApiCookieAuth` Annotations are cosmetic but make the spec actually browsable. Tag taxonomy: | Controller | Tag | Security | | --- | --- | --- | | [`AppController`](apps/portal-bff/src/app/app.controller.ts) | `app (scaffolding)` | — | | [`HealthController`](apps/portal-bff/src/health/health.controller.ts) | `health` | — | | [`AuthController`](apps/portal-bff/src/auth/auth.controller.ts) | `auth (user portal)` | `portal_session` on `/me` + `/logout` | | [`AdminAuthController`](apps/portal-bff/src/admin/admin-auth.controller.ts) | `auth (admin portal)` | `portal_admin_session` on `/me` + `/logout` | | [`AdminController`](apps/portal-bff/src/admin/admin.controller.ts) | `admin (self-test)` | class-level `portal_admin_session` | | [`AdminAuditController`](apps/portal-bff/src/admin/admin-audit.controller.ts) | `admin (audit log)` | class-level `portal_admin_session` | | [`AdminUsersController`](apps/portal-bff/src/admin/admin-users.controller.ts) | `admin (user directory)` | class-level `portal_admin_session` | `@ApiOperation({ summary: … })` added on every route — populates the one-line description Scalar shows in its left-rail TOC. ### Deps + Jest - `@nestjs/swagger ^11` (matches the Nest 11 major already pinned) and `@scalar/nestjs-api-reference` added to the workspace root. - [`jest.config.cts`](apps/portal-bff/jest.config.cts) — widened `transformIgnorePatterns` from `/node_modules/(?!.*jose)/` to `/node_modules/(?!.*(jose|@scalar/))/`. `@scalar/client-side-rendering` (a transitive dep) ships ESM-only; without this widening the spec suite fails to load the module under ts-jest. ## Notes for the reviewer - **Why two cookie schemes rather than one?** Scalar renders a per-endpoint lock icon driven by the security scheme name. Splitting `portal_session` / `portal_admin_session` keeps the indicator semantically truthful — `/api/auth/me` and `/api/admin/auth/me` look identical otherwise. - **CSRF caveat.** Mutating routes (`POST` / `PUT` / `PATCH` / `DELETE`) require `X-CSRF-Token` per [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md). The header must be set manually in Scalar's "Try it" panel to the value of the `portal_csrf` cookie when exercising those routes. The spec description mentions it; auto-injecting the header from the cookie is a future polish. - **No ADR for this.** `@nestjs/swagger` is the framework's own first-party tooling; Scalar is a thin UI on top of a standard OpenAPI 3 document. Both replaceable without touching the controllers (the `@Api*` annotations are spec-standard). Dev-only, no prod surface — doesn't cross any of the bars that warrant an ADR per [CLAUDE.md](CLAUDE.md). - **Express-layer routing.** Same pattern as the JWKS endpoint (#139): the OpenAPI JSON is a static asset and Scalar a vanilla Express handler, so wiring through Nest's router adds no value. ## Test plan - [x] **5 new specs** in [`apps/portal-bff/src/openapi/openapi.spec.ts`](apps/portal-bff/src/openapi/openapi.spec.ts) — document shape (openapi version, title, version), both cookie schemes declared, smoke controller route captured in `paths`, production short-circuit (no routes mounted, no `app.use` called), dev mount (JSON at `/api/openapi.json` via the HTTP adapter, Scalar UI at `/api/docs` via `app.use`). - [x] `pnpm nx test portal-bff` — **396 specs pass** (was 391). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Manual dev smoke: `pnpm nx serve portal-bff`, `curl /api/openapi.json | jq .info` returns title + version, open `/api/docs` in a browser, every controller's routes visible under their tag, lock icons match the cookie scheme on guarded routes. ## What's next — light follow-ups Not blocking this PR; mentioned so they're not lost: - Auto-inject the `X-CSRF-Token` header in Scalar from the `portal_csrf` cookie (custom Scalar config preset). - Promote `@ApiOperation` summaries with multi-line `description`s on the more involved routes (`/api/admin/audit`, `/api/admin/users`). - Annotate DTOs with `@ApiProperty` once the first contract-test consumer arrives — Nest can also pick them up automatically with the `@nestjs/swagger` ts-plugin if we wire it into the Nx build target. Deferred until the spec is consumed by tooling that benefits from the precision. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #143
231 lines
10 KiB
TypeScript
231 lines
10 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 { 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 { JwksPublisher } from './downstream/jwks.publisher';
|
|
import { setupOpenApi } from './openapi/openapi';
|
|
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';
|
|
app.setGlobalPrefix(globalPrefix);
|
|
|
|
// JWKS endpoint at the RFC 8615 bare-root path. Wired at the
|
|
// Express layer rather than as a Nest `@Controller` because
|
|
// path-to-regexp v8 (Nest 11's router) does not cleanly route a
|
|
// leading-dot segment like `.well-known/jwks.json` to a bare-root
|
|
// URL — the previous Nest-side attempt with `setGlobalPrefix`
|
|
// `exclude` landed the route at neither `/api/.well-known/jwks.json`
|
|
// nor `/.well-known/jwks.json` (both 404'd). Express's own
|
|
// routing accepts the leading dot verbatim, and the Nest DI
|
|
// container still owns the underlying `JwksPublisher` service.
|
|
//
|
|
// Public by design (no session, no CSRF) — the JWKS is the
|
|
// downstream's verification anchor; gating it defeats the
|
|
// purpose. Mounted before `app.listen()` so the route is live
|
|
// by the time the BFF reports ready.
|
|
const jwksPublisher = app.get(JwksPublisher);
|
|
app.getHttpAdapter().get('/.well-known/jwks.json', (_req: Request, res: Response) => {
|
|
res.json(jwksPublisher.jwks());
|
|
});
|
|
|
|
// OpenAPI spec + Scalar API Reference UI, dev-only. Mounted at
|
|
// `/${globalPrefix}/openapi.json` and `/${globalPrefix}/docs`
|
|
// respectively — the function short-circuits in production.
|
|
setupOpenApi(app, globalPrefix);
|
|
|
|
const port = process.env['PORT'] ?? 3000;
|
|
await app.listen(port);
|
|
|
|
app
|
|
.get(Logger)
|
|
.log(`Application is running on: http://localhost:${port}/${globalPrefix}`, 'Bootstrap');
|
|
}
|
|
|
|
bootstrap();
|