Files
apf_portal/apps/portal-bff/.env.example
T
Julien Gautier 18f26dc2b9
CI / scan (pull_request) Successful in 2m55s
CI / commits (pull_request) Successful in 2m56s
CI / check (pull_request) Successful in 3m7s
CI / a11y (pull_request) Successful in 1m55s
CI / perf (pull_request) Successful in 5m47s
feat(portal-bff): obo strategy + encrypted downstream token cache
First half of the DownstreamApiClient + OBO chantier per ADR-0014.
Per the ADR's own §"Consequences" note — "writing the framework code
only in the same iteration as the first concrete integration; until
then, this ADR plus mock-driven unit tests on the strategies (OBO,
signed-assertion) keep the design honest" — this PR ships the OBO
strategy and its encrypted-at-rest token cache as testable primitives,
NOT the full DownstreamApiClientFactory + cockatiel + audience
pre-check framework. The framework gets assembled when the first real
downstream integration arrives.

What lands

- assertOboCacheEncryptionKey (config/check-obo-cache-encryption-key.ts):
  - 32-byte AES-256-GCM key validator, mirrors the SESSION key
    validator pattern.
  - Refuses placeholder, wrong length, non-base64url.
  - Refuses a value identical to SESSION_ENCRYPTION_KEY — defense
    in depth against the copy-paste regression that would silently
    downgrade the trust boundary ADR-0014 §"Token cache (for OBO)"
    explicitly draws.
  - Wired in main.ts alongside the other assertX() boot validators.

- DownstreamTokenCache (downstream/downstream-token-cache.service.ts):
  - Redis-backed cache, key shape `obo:{actorIdHash}:{resource}`.
  - Encrypts each entry via the shared AES-256-GCM helpers from
    `session-crypto` but under a DEDICATED key (OBO_CACHE_KEY) so
    a cache-key leak cannot decrypt sessions and vice versa.
  - TTL = upstream `expiresAt - 60s` (the safety buffer ADR-0014
    mandates). A token already inside the buffer is NOT written —
    the strategy re-acquires next call.
  - Never throws on read: Redis hiccups, tampered ciphertext, wrong
    key, malformed shape — all collapse to a miss with a structured
    Pino warn. The strategy then re-acquires from Entra.
  - Writes are best-effort: a Redis write failure is logged but
    doesn't fail the request — the call already has its token.

- OboStrategy (downstream/strategies/obo.strategy.ts):
  - Wraps MSAL Node's acquireTokenOnBehalfOf with the cache.
  - Flow: cache.get → if fresh, return verbatim; else MSAL call →
    cache.set → return.
  - Cache hit applies a second 60 s buffer beyond Redis's TTL —
    load-bearing under clock skew where a token survives Redis's
    expiry but is already inside its safety window.
  - MSAL refusal / null result throws OboAcquireError with a typed
    `reason` discriminator the future framework will translate to a
    502 + `auth.token.validation.failed` audit event per ADR-0014.
  - Takes the user's access token as a PARAMETER for now — ADR-0014
    says "read from session via CLS", but v1 sessions don't
    persist the user's access token (ADR-0009 omits offline_access
    deliberately). The framework integration that fetches it from
    CLS lands with the first real consumer, per the ADR's own
    "until then" clause.

- DownstreamModule (downstream/downstream.module.ts): provides
  OBO_CACHE_KEY (via assertOboCacheEncryptionKey at factory time),
  DownstreamTokenCache, OboStrategy. Imports AuthModule for the
  shared MSAL_CLIENT and RedisModule for the shared ioredis. Wired
  into AppModule though the strategy has no runtime consumer yet —
  the registration makes the strategy injectable for the future
  integration without that integration having to also touch the
  module graph.

Env

- New: OBO_CACHE_ENCRYPTION_KEY (mandatory at boot).
- .env.example placeholder + the stale "future" note collapsed to
  point at the now-wired key.

Tests: +26 specs (env validator 8, token cache 9, OBO strategy 9).

Out of scope (deferred per ADR-0014 "until then"):
- DownstreamApiClientFactory + per-service typed config.
- cockatiel resilience composition (timeout, retry, breaker, bulkhead).
- Audience pre-check at the call site.
- Error translation tables.
- OTel custom spans `downstream.<service>.<verb>.<path>`.
- audit events `auth.token.validation.failed` / `authz.deny`.
- The framework wiring that reads the user access token from
  session/CLS instead of accepting it as a parameter.

These land alongside the first concrete integration so the framework
shape is validated against a real consumer, not speculative.
2026-05-14 18:08:22 +02:00

191 lines
9.8 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.
# User portal — `/api/auth/callback` is the OIDC return URL; the
# post-logout URL is where Entra sends the browser after RP-initiated
# logout (typically the SPA landing page).
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
# Admin portal — distinct callback per ADR-0020 §"Sessions — distinct
# from `portal-shell`" so Entra routes the response to the matching
# session. Both `ENTRA_REDIRECT_URI` and `ENTRA_ADMIN_REDIRECT_URI`
# must be registered in the same Entra app registration's
# "Redirect URIs" list. Distinct post-logout URL routes admin
# sign-outs to the admin SPA's landing page (port 4300 in dev — see
# apps/portal-admin/project.json `serve.options.port`).
ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/
# 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
# OBO downstream-token cache encryption (per ADR-0014 §"Token cache
# (for OBO)"). AES-256-GCM key for encrypting Entra-issued downstream
# tokens cached in Redis under `obo:{actor_id_hash}:{resource}`.
# **Dedicated key** — must differ from SESSION_ENCRYPTION_KEY so a
# leak of one does not cascade into the other. The boot validator
# refuses an identical value. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
OBO_CACHE_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: portal-shell on :4200 and portal-admin on :4300 — both
# call the BFF with credentials. Source of truth for the ports:
# `apps/<app>/project.json` `serve.options.port`.
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4300
# Rate limiting (per ADR-0015 §"DoS mitigation"). Both optional
# with conservative defaults; override per environment when traffic
# patterns demand it. The BFF keys buckets by session id when the
# request is authenticated, by remote IP otherwise — rotating
# sessions doesn't dodge the limit.
# RATE_LIMIT_PER_MINUTE — general bucket. Default 120 (~ 2 r/s).
# RATE_LIMIT_AUTH_PER_MINUTE — stricter bucket on /auth/login and
# /auth/callback. Default 10/min, to
# slow brute-force / replay loops
# without inconveniencing legit users.
# RATE_LIMIT_PER_MINUTE=120
# RATE_LIMIT_AUTH_PER_MINUTE=10
# 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 — wired (see above, line 113)
# BFF_JWKS_PRIVATE_KEY_PATH (next PR: signed-assertion strategy)
# BFF_JWKS_KID (next PR: signed-assertion strategy)
# <SERVICE>_API_BASE_URL (per integrated downstream)
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000)