feat(portal-bff): obo strategy + encrypted downstream token cache #137

Merged
julien merged 1 commits from feat/portal-bff-downstream-obo-strategy into main 2026-05-14 18:13:31 +02:00
Owner

Summary

First half of the DownstreamApiClient + OBO chantier per ADR-0014. Ships the OBO auth strategy and its encrypted-at-rest token cache as testable primitives — explicitly not the full DownstreamApiClientFactory + cockatiel + audience-pre-check framework.

The scope is dictated by ADR-0014 §"Consequences":

"Bad, because the framework is forward-looking — there is no concrete v1 caller. Risk of drift between framework and real needs. Mitigated by 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."

The framework gets assembled when the first real downstream integration arrives, with that integration as the validation surface. The next PR in this chantier ships the symmetric signed-assertion strategy + the JWKS endpoint.

What lands

assertOboCacheEncryptionKey

Boot validator mirroring assertSessionEncryptionKey. AES-256-GCM, 32-byte requirement, placeholder rejection, fail-fast posture. Plus one extra defense in depth:

Refuses a value identical to SESSION_ENCRYPTION_KEY — ADR-0014 §"Token cache (for OBO)" mandates dedicated keys; catching the copy-paste regression at boot prevents a silent trust-boundary downgrade.

Wired in main.ts alongside the other assertX() validators.

DownstreamTokenCache

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).

Path Behaviour
Cache miss Returns null.
Tampered ciphertext Returns null + Pino warn downstream.obo_cache.decrypt_failed.
Wrong-key ciphertext Returns null (GCM auth-tag mismatch).
Decrypted but malformed shape Returns null + Pino warn.
Redis read failure Returns null + Pino warn downstream.obo_cache.read_failed.
Write of a token already inside the 60 s buffer Skipped (TTL would be useless).
Redis write failure Logged, non-fatal.

Reads never throw — every failure collapses to a miss, the strategy re-acquires from Entra.

OboStrategy

Wraps MSAL Node's acquireTokenOnBehalfOf with the cache.

acquire(input):
  cached = cache.get(...)
  if cached && cached.expiresAt - now > 60s → return cached
  result = msal.acquireTokenOnBehalfOf({ oboAssertion, scopes })
  if !result || !result.accessToken || !result.expiresOn → throw OboAcquireError(msal-no-result)
  cache.set(...)
  return result

OboAcquireError carries a typed reason discriminator (msal-refused / msal-no-result) the future framework will translate to a 502 + auth.token.validation.failed audit event per ADR-0014 — "the BFF does NOT silently fall back to the user's original token".

One scope nuance from ADR-0014

ADR-0014 §"OBO strategy" says "uses MSAL Node's acquireTokenOnBehalfOf with the user's current Entra access token (read from session via CLS)". v1 sessions don't persist the user's access token (ADR-0009 omits offline_access deliberately). For now the strategy takes the user access token as an input parameter — when the first concrete integration ships, the framework will fetch it from CLS / MSAL's token cache and forward here. That keeps the strategy a testable primitive without coupling to a session shape that doesn't exist yet.

DownstreamModule

Provides OBO_CACHE_KEY (via the validator at factory time), DownstreamTokenCache, OboStrategy. Imports AuthModule for the shared MSAL_CLIENT and RedisModule for the shared ioredis client. Wired into AppModule though no runtime consumer yet — the registration makes the strategy injectable for the future integration without that integration having to also touch the module graph.

Required env update (mandatory at boot)

OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url

Generate with node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))". Must differ from SESSION_ENCRYPTION_KEY — the boot validator refuses identical values.

Out of scope (deferred until the first concrete integration)

Per ADR-0014's "until then" clause:

  • DownstreamApiClientFactory + per-service typed config.
  • cockatiel resilience composition (timeout, retry, circuit breaker, bulkhead).
  • Audience pre-check at the call site (audienceConstraintauthz.deny audit event).
  • Error-translation tables per service.
  • OTel custom spans downstream.<service>.<verb>.<path>.
  • The auth.token.validation.failed audit event itself (the discriminator is on OboAcquireError, the audit-emission glue lives in the future framework).
  • The framework wiring that reads the user access token from 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 needs.

Test plan

  • pnpm nx test portal-bff334 specs pass (was 308; +26: env validator 8, token cache 9, OBO strategy 9).
  • pnpm exec nx affected -t format:check lint test build --base=origin/main — clean.
  • Env validator refuses placeholder, wrong length, non-base64url, AND identical-to-SESSION_ENCRYPTION_KEY. Boot-order tolerant: accepts the value when SESSION_ENCRYPTION_KEY is unset.
  • Token cache round-trip verified: written ciphertext starts with v1., never contains the plaintext sentinel.
  • Tamper rejection verified: flipping the last char of the GCM-encrypted blob fails decryption and collapses to a miss.
  • Wrong-key rejection verified: writing with one key, reading with another, returns null.
  • TTL math verified: PX TTL = expiresAt − now − 60 000. Write skipped when token already inside the buffer.
  • OBO strategy: cache-hit short-circuit, stale-cache re-acquire, cold-cache → MSAL → cache.set, MSAL refusal → typed error, MSAL null-result → typed error, empty access token → typed error, null expiresOn → typed error.

Notes for the reviewer

  • The strategy file uses override readonly cause on OboAcquireError because TS strict.exactOptionalPropertyTypes + noImplicitOverride flags shadowing the built-in Error.cause. The shadowing is intentional — we want the typed cause property visible in error consumers — so the override keyword is the canonical way.
  • DownstreamTokenCache.get's "never throws" posture is deliberate. A cache failure must not poison a downstream call: the strategy re-acquires from Entra. The trade-off is that a key-rotation gone wrong shows up as silent re-acquisitions (no errors, just extra MSAL load); the structured Pino warns are the ops signal.
  • The DownstreamModule is wired into AppModule even though nothing consumes the strategy at runtime. Without the wiring, the first integration PR would have to also touch the module graph; with it, the integration is just "inject OboStrategy and call .acquire()".
## Summary First half of the **DownstreamApiClient + OBO** chantier per [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md). Ships the OBO auth strategy and its encrypted-at-rest token cache as testable primitives — explicitly **not** the full `DownstreamApiClientFactory` + cockatiel + audience-pre-check framework. The scope is dictated by ADR-0014 §"Consequences": > *"Bad, because the framework is forward-looking — there is no concrete v1 caller. Risk of drift between framework and real needs. **Mitigated by 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.**"* The framework gets assembled when the first real downstream integration arrives, with that integration as the validation surface. The next PR in this chantier ships the symmetric signed-assertion strategy + the JWKS endpoint. ## What lands ### [`assertOboCacheEncryptionKey`](apps/portal-bff/src/config/check-obo-cache-encryption-key.ts) Boot validator mirroring `assertSessionEncryptionKey`. AES-256-GCM, 32-byte requirement, placeholder rejection, fail-fast posture. Plus one extra defense in depth: > *Refuses a value identical to `SESSION_ENCRYPTION_KEY`* — ADR-0014 §"Token cache (for OBO)" mandates dedicated keys; catching the copy-paste regression at boot prevents a silent trust-boundary downgrade. Wired in [`main.ts`](apps/portal-bff/src/main.ts) alongside the other `assertX()` validators. ### [`DownstreamTokenCache`](apps/portal-bff/src/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`). | Path | Behaviour | | --- | --- | | Cache miss | Returns `null`. | | Tampered ciphertext | Returns `null` + Pino warn `downstream.obo_cache.decrypt_failed`. | | Wrong-key ciphertext | Returns `null` (GCM auth-tag mismatch). | | Decrypted but malformed shape | Returns `null` + Pino warn. | | Redis read failure | Returns `null` + Pino warn `downstream.obo_cache.read_failed`. | | Write of a token already inside the 60 s buffer | Skipped (TTL would be useless). | | Redis write failure | Logged, non-fatal. | Reads never throw — every failure collapses to a miss, the strategy re-acquires from Entra. ### [`OboStrategy`](apps/portal-bff/src/downstream/strategies/obo.strategy.ts) Wraps MSAL Node's `acquireTokenOnBehalfOf` with the cache. ``` acquire(input): cached = cache.get(...) if cached && cached.expiresAt - now > 60s → return cached result = msal.acquireTokenOnBehalfOf({ oboAssertion, scopes }) if !result || !result.accessToken || !result.expiresOn → throw OboAcquireError(msal-no-result) cache.set(...) return result ``` `OboAcquireError` carries a typed `reason` discriminator (`msal-refused` / `msal-no-result`) the future framework will translate to a **502 + `auth.token.validation.failed`** audit event per ADR-0014 — "the BFF does NOT silently fall back to the user's original token". ### One scope nuance from ADR-0014 ADR-0014 §"OBO strategy" says *"uses MSAL Node's `acquireTokenOnBehalfOf` with the user's current Entra access token (read from session via CLS)"*. v1 sessions don't persist the user's access token (ADR-0009 omits `offline_access` deliberately). For now the strategy takes the user access token as an **input parameter** — when the first concrete integration ships, the framework will fetch it from CLS / MSAL's token cache and forward here. That keeps the strategy a testable primitive without coupling to a session shape that doesn't exist yet. ### [`DownstreamModule`](apps/portal-bff/src/downstream/downstream.module.ts) Provides `OBO_CACHE_KEY` (via the validator at factory time), `DownstreamTokenCache`, `OboStrategy`. Imports `AuthModule` for the shared `MSAL_CLIENT` and `RedisModule` for the shared `ioredis` client. Wired into `AppModule` though no runtime consumer yet — the registration makes the strategy injectable for the future integration without that integration having to also touch the module graph. ## Required env update (mandatory at boot) ```env OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url ``` Generate with `node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"`. Must differ from `SESSION_ENCRYPTION_KEY` — the boot validator refuses identical values. ## Out of scope (deferred until the first concrete integration) Per ADR-0014's "until then" clause: - `DownstreamApiClientFactory` + per-service typed config. - `cockatiel` resilience composition (timeout, retry, circuit breaker, bulkhead). - Audience pre-check at the call site (`audienceConstraint` → `authz.deny` audit event). - Error-translation tables per service. - OTel custom spans `downstream.<service>.<verb>.<path>`. - The `auth.token.validation.failed` audit event itself (the discriminator is on `OboAcquireError`, the audit-emission glue lives in the future framework). - The framework wiring that reads the user access token from 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 needs. ## Test plan - [x] `pnpm nx test portal-bff` — **334 specs pass** (was 308; +26: env validator 8, token cache 9, OBO strategy 9). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Env validator refuses placeholder, wrong length, non-base64url, AND identical-to-`SESSION_ENCRYPTION_KEY`. Boot-order tolerant: accepts the value when `SESSION_ENCRYPTION_KEY` is unset. - [x] Token cache round-trip verified: written ciphertext starts with `v1.`, never contains the plaintext sentinel. - [x] Tamper rejection verified: flipping the last char of the GCM-encrypted blob fails decryption and collapses to a miss. - [x] Wrong-key rejection verified: writing with one key, reading with another, returns `null`. - [x] TTL math verified: PX TTL = `expiresAt − now − 60 000`. Write skipped when token already inside the buffer. - [x] OBO strategy: cache-hit short-circuit, stale-cache re-acquire, cold-cache → MSAL → cache.set, MSAL refusal → typed error, MSAL null-result → typed error, empty access token → typed error, null expiresOn → typed error. ## Notes for the reviewer - The strategy file uses `override readonly cause` on `OboAcquireError` because TS `strict.exactOptionalPropertyTypes + noImplicitOverride` flags shadowing the built-in `Error.cause`. The shadowing is intentional — we want the typed cause property visible in error consumers — so the `override` keyword is the canonical way. - `DownstreamTokenCache.get`'s "never throws" posture is deliberate. A cache failure must not poison a downstream call: the strategy re-acquires from Entra. The trade-off is that a key-rotation gone wrong shows up as silent re-acquisitions (no errors, just extra MSAL load); the structured Pino warns are the ops signal. - The `DownstreamModule` is wired into `AppModule` even though nothing consumes the strategy at runtime. Without the wiring, the first integration PR would have to also touch the module graph; with it, the integration is just "inject `OboStrategy` and call `.acquire()`".
julien added 1 commit 2026-05-14 18:10:02 +02:00
feat(portal-bff): obo strategy + encrypted downstream token cache
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
18f26dc2b9
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.
julien merged commit d665c66c4e into main 2026-05-14 18:13:31 +02:00
julien deleted branch feat/portal-bff-downstream-obo-strategy 2026-05-14 18:13:33 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#137