Files
apf_portal/apps/portal-bff/.env.example
T
julien 5bbe2304ff
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m10s
CI / check (push) Successful in 3m16s
CI / a11y (push) Successful in 1m6s
CI / perf (push) Successful in 4m16s
feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF (#122)
## Summary

Phase-2 security baseline that the `main.ts` placeholder note has been advertising since the auth/session work began. Three independent middlewares + their SPA counterparts, all mounted in a single PR because they only become meaningful together.

### Helmet on the BFF

`helmet()` with three overrides matching our specific shape:

- **HSTS only in production** — dev runs on plain HTTP, HSTS is just noise.
- **`crossOriginResourcePolicy: 'cross-origin'`** — the SPA on its own origin reads JSON from the BFF; the default `same-origin` would block it.
- **CSP disabled in non-production** — the BFF doesn't render HTML, so CSP on JSON responses is mostly inert, but Helmet's default CSP triggers noisy `connect-src` violations in browser devtools that we don't need.

Everything else is Helmet defaults: `X-Frame-Options=SAMEORIGIN`, `X-Content-Type-Options=nosniff`, `Referrer-Policy=no-referrer`, `X-Powered-By` removed, etc.

### CORS allowlist, env-driven

`CORS_ALLOWED_ORIGINS` env (comma-separated) is now **mandatory** at boot. The BFF refuses to start without it via `readCorsAllowlist()` — same boot-time validator family as `assertSessionSecret` etc. The previous hardcoded `http://localhost:4200` fallback is gone; getting CORS wrong silently is the kind of "works in dev, breaks in prod" trap the validator is specifically designed to catch. `X-CSRF-Token` is now in the allowed headers.

### Double-submit CSRF

- BFF mints a 256-bit `csrfToken` at session creation (`/auth/callback`), stored on `req.session.csrfToken` and mirrored to a JS-readable cookie (`__Host-portal_csrf` prod / `portal_csrf` dev). The cookie is the SPA's read-only view; the server-side session is the source of truth.
- `createCsrfMiddleware` (mounted after the session middleware in `main.ts`) compares the `X-CSRF-Token` header with `req.session.csrfToken` using `crypto.timingSafeEqual`. Skips:
  - safe methods (`GET / HEAD / OPTIONS`),
  - anonymous requests (no `req.session.user`),
  - `/api/auth/login` and `/api/auth/callback` (those mint the token themselves).
- Mismatch → `403 {"error":"csrf"}` with a structured Pino warn.
- SPA's `csrfInterceptor` reads the cookie via `document.cookie` and copies its value into `X-CSRF-Token` on every mutating BFF request. The header is omitted on `GET / HEAD / OPTIONS` (BFF skips them anyway) and on non-BFF origins.
- Logout and the absolute-timeout middleware both clear the CSRF cookie alongside the session cookie.

## Notable choices

**Session-bound double-submit, not pure cookie-vs-header.** A naive "compare cookie with header" check is defeated when an attacker can plant a cookie (subdomain takeover, etc.). Comparing the header to the server-side session-stored token instead means the attacker would also need to be the authenticated user — which is what CSRF defense is supposed to prevent in the first place.

**No CSRF for anonymous mutating routes (v1).** None exist today; we don't have an unauthenticated POST endpoint anywhere. Generating a CSRF token for anonymous sessions would conflict with `saveUninitialized: false` on express-session and add complexity we don't need yet. Anonymous public-form CSRF defenses (site-key, captcha) land if and when those routes ship.

**`SameSite=Lax`, not `Strict`, on the CSRF cookie.** Matches the session cookie's policy so the two travel together on the SPA→BFF cross-origin same-site fetch (different ports = different origin, same registrable domain). The double-submit pattern is what gives the protection; `SameSite=Lax` is a belt-and-braces layer.

**`csrfInterceptor` runs after `bffCredentialsInterceptor` and before `bffUnauthorizedInterceptor` in the chain.** Order: credentials first (set `withCredentials`), then CSRF (set the header), then unauthorized handling (catch 401s). Forward order, no surprises.

**`CORS_ALLOWED_ORIGINS` has no localhost fallback.** I considered keeping the fallback for ergonomics but it makes the BFF silently misconfigured if someone forgets the env. The error message points straight at the file to edit.

## Out of scope (next PRs)

- Rate limiting + structured error filter (still in the phase-2 to-do).
- CSP fine-tuning when we have actual HTML pages (portal-shell + portal-admin static serving).
- CSRF token rotation on idle-extension (today the token lives the session's lifetime; refreshing on each request would invalidate in-flight mutations).

## Test plan

- [x] `pnpm nx run-many -t test --projects=portal-bff,feature-auth,portal-shell` clean env → **177 + 28 + 34 = 239/239 pass** (was 144 + 19 + 34 = 197 before; +42 specs across CSRF middleware, CSRF cookie helpers, CORS allowlist parser, csrfInterceptor, and extended auth.controller / absolute-timeout coverage).
- [x] `pnpm nx run-many -t lint build --projects=portal-bff,feature-auth,portal-shell` → clean.
- [x] **CI clean-env repro** (lesson from prior PRs): every env var unset (including new `CORS_ALLOWED_ORIGINS`) → tests still pass. The BFF refuses to boot without `CORS_ALLOWED_ORIGINS`, which is the intended behaviour.
- [x] Prettier-clean.
- [ ] Manual smoke against running BFF:
  - [ ] Sign in → `__Host-portal_csrf` (prod) / `portal_csrf` (dev) cookie set, value matches `audit.events.payload->>actorIdHash`-style traceability via `req.session.csrfToken` in Redis.
  - [ ] Hit a future POST route from the SPA → request carries `X-CSRF-Token`, BFF accepts.
  - [ ] Forge a POST without the header (curl) → 403 `{"error":"csrf"}`.
  - [ ] Sign out → both cookies cleared.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #122
2026-05-13 20:50:44 +02:00

157 lines
7.7 KiB
Bash

# BFF environment template
# Copy to .env (which is gitignored) and fill in actual values for local development.
# Production values are managed by the platform's secret manager (see future infrastructure ADR).
# Postgres connection (per ADR-0006)
# Local dev default: dockerised Postgres on port 5432, schema 'public'.
# Username / password / db must match infra/local/.env (POSTGRES_USER /
# POSTGRES_PASSWORD / POSTGRES_DB) — those are the source of truth,
# this is the BFF view of the same connection.
#
# IMPORTANT — URL encoding. The password is part of the URL userinfo
# segment, so any of these characters must be URL-encoded:
# @ → %40 # → %23 : → %3A / → %2F ? → %3F
# % → %25 & → %26 = → %3D + → %2B ; → %3B
# i.e. if your POSTGRES_PASSWORD is "p@ss#1", DATABASE_URL must read
# "postgresql://portal:p%40ss%231@localhost:5432/portal_dev?schema=public"
# The BFF aborts at boot with a clear error if it detects an unencoded
# special character (see apps/portal-bff/src/config/check-database-url.ts).
DATABASE_URL="postgresql://portal:portal_dev_change_me@localhost:5432/portal_dev?schema=public"
# Observability (per ADR-0012)
# All OTEL_* keys are honoured by the OpenTelemetry SDK directly — see
# apps/portal-bff/src/observability/tracing.ts for the bootstrap.
# Pino log level: 'info' in prod, 'debug' in dev (default if unset).
LOG_LEVEL=debug
OTEL_SERVICE_NAME=portal-bff
OTEL_SERVICE_VERSION=dev
# Default endpoint targets the Collector provisioned in
# infra/local/dev.compose.yml. The /v1/traces suffix is required by
# the HTTP/Protobuf transport.
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# v1 samples 100 % at the app; tail sampling is delegated to the
# Collector (per ADR-0012). Override only for spike investigations.
OTEL_TRACES_SAMPLER=always_on
# Identity / Entra ID app registration (per ADR-0008 / ADR-0009)
# Values come from the project's Entra application registration in the
# Azure Admin Center → App registrations → APF Portal. The four
# *_INSTANCE_URL / *_TENANT_ID / *_CLIENT_ID / *_CLIENT_SECRET keys
# are mandatory; the BFF refuses to boot without them (see
# apps/portal-bff/src/config/check-entra-config.ts).
#
# ENTRA_INSTANCE_URL is the Microsoft login endpoint — usually
# https://login.microsoftonline.com/. The authority used by MSAL is
# `${ENTRA_INSTANCE_URL}${ENTRA_TENANT_ID}` for single-tenant flows,
# or `${ENTRA_INSTANCE_URL}organizations` / `common` for multi-tenant
# (per ADR-0008's dual-audience design). v1 uses the tenant-scoped
# authority; the multi-tenant switch lands when External ID activation
# is needed.
#
# ENTRA_CLIENT_SECRET is the high-value secret of this set. Never
# commit a real value. Production manages it via the deploy platform's
# secret manager (future infrastructure ADR).
ENTRA_INSTANCE_URL=https://login.microsoftonline.com/
ENTRA_TENANT_ID=00000000-0000-0000-0000-000000000000
ENTRA_CLIENT_ID=00000000-0000-0000-0000-000000000000
ENTRA_CLIENT_SECRET=replace_with_real_value
# Redirect URIs registered in Entra alongside the same client id. Both
# `/auth/callback` and `/auth/logout` paths are mounted by the BFF
# once the OIDC routes land in a subsequent PR.
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback
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
# Redis connection (per ADR-0010). The BFF uses `ioredis` for session
# storage (today: just the connection; the express-session +
# connect-redis middleware lands in the next PR).
#
# REDIS_URL — full URL form including auth. Must match `infra/local/.env`
# (REDIS_PASSWORD + REDIS_PORT) when running against the local Compose
# stack. Production wiring uses Sentinel (REDIS_SENTINEL_HOSTS +
# REDIS_SENTINEL_NAME — future-vars block below) and TLS; the current
# variable supports the dev single-instance shape only.
REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0
# Session payload encryption (per ADR-0010 §"At-rest encryption").
# AES-256-GCM key for encrypting the session JSON that connect-redis
# writes to Redis, so a Redis dump never carries raw user identities
# / future tokens / claims in plaintext. **Distinct** from
# SESSION_SECRET, which only signs the cookie's session-id — never
# reuse one for the other. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# Session timeouts (per ADR-0010). Both optional with sensible
# defaults; override only when staging / prod policy diverges.
# SESSION_IDLE_TIMEOUT_SECONDS — sliding window. Each request
# extends the cookie's `expires` by this many seconds.
# Default 1800 (30 min).
# SESSION_ABSOLUTE_TIMEOUT_SECONDS — hard ceiling. Session is
# destroyed regardless of activity at this age.
# Default 43200 (12 h).
# SESSION_IDLE_TIMEOUT_SECONDS=1800
# SESSION_ABSOLUTE_TIMEOUT_SECONDS=43200
# Per-environment salt used to pseudonymise the user id before it
# lands in audit rows (per ADR-0013 §"Schema") and in Pino app log
# lines (per ADR-0012 §"User id hashing"). Same value must be used
# on both sides so audit and app logs join on `actor_id_hash`.
#
# Rotation invalidates the join key — old rows / log lines can no
# longer be correlated with the new hash. Treat as long-lived per
# environment. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
LOG_USER_ID_SALT=replace_with_32_random_bytes_base64url
# CORS allowlist (per ADR-0009 §"CORS"). Comma-separated list of
# origins (scheme://host[:port]) allowed to call the BFF with
# credentials. The BFF refuses to start without this — silently
# defaulting to localhost is the classic "works in dev, breaks in
# prod" trap.
#
# Local dev: the portal-shell dev server on :4200. Add :4201 once
# portal-admin grows its own dev server.
CORS_ALLOWED_ORIGINS=http://localhost:4200
# Future env vars introduced by upcoming phases / ADRs:
#
# Auth flow (ADR-0009) — additional keys wired as the routes land:
# ENTRA_CLIENT_CERT_PATH (alternative to ENTRA_CLIENT_SECRET)
# ENTRA_ACCEPTED_TENANT_IDS (CSV; restricts which tenants can sign in
# in the multi-tenant phase — empty means
# "only ENTRA_TENANT_ID is accepted")
#
# Sessions (ADR-0010) — additional keys wired as the layers land:
# REDIS_SENTINEL_HOSTS (CSV `host:port,host:port,…`; prod HA)
# REDIS_SENTINEL_NAME (master name in Sentinel; prod HA)
# REDIS_TLS ('true' in prod)
#
# MFA (ADR-0011):
# MFA_FRESHNESS_SECONDS (default 600)
#
# Audit trail (ADR-0013):
# AUDIT_DATABASE_URL (separate creds, role 'audit_writer')
# AUDIT_ARCHIVER_DATABASE_URL (role 'audit_archiver', for the retention purge job)
# AUDIT_RETENTION_DAYS (default 365)
#
# Downstream API access (ADR-0014):
# OBO_CACHE_ENCRYPTION_KEY (32-byte base64, distinct from SESSION_ENCRYPTION_KEY)
# BFF_JWKS_PRIVATE_KEY_PATH
# BFF_JWKS_KID
# <SERVICE>_API_BASE_URL (per integrated downstream)
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000)