Files
apf_portal/apps
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
..