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.