feat(portal-bff): obo strategy + encrypted downstream token cache #137
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff-downstream-obo-strategy"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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":
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
assertOboCacheEncryptionKeyBoot validator mirroring
assertSessionEncryptionKey. AES-256-GCM, 32-byte requirement, placeholder rejection, fail-fast posture. Plus one extra defense in depth:Wired in
main.tsalongside the otherassertX()validators.DownstreamTokenCacheRedis-backed cache, key shape
obo:{actorIdHash}:{resource}. Encrypts each entry via the shared AES-256-GCM helpers fromsession-cryptobut under a dedicated key (OBO_CACHE_KEY).null.null+ Pino warndownstream.obo_cache.decrypt_failed.null(GCM auth-tag mismatch).null+ Pino warn.null+ Pino warndownstream.obo_cache.read_failed.Reads never throw — every failure collapses to a miss, the strategy re-acquires from Entra.
OboStrategyWraps MSAL Node's
acquireTokenOnBehalfOfwith the cache.OboAcquireErrorcarries a typedreasondiscriminator (msal-refused/msal-no-result) the future framework will translate to a 502 +auth.token.validation.failedaudit 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
acquireTokenOnBehalfOfwith the user's current Entra access token (read from session via CLS)". v1 sessions don't persist the user's access token (ADR-0009 omitsoffline_accessdeliberately). 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.DownstreamModuleProvides
OBO_CACHE_KEY(via the validator at factory time),DownstreamTokenCache,OboStrategy. ImportsAuthModulefor the sharedMSAL_CLIENTandRedisModulefor the sharedioredisclient. Wired intoAppModulethough 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)
Generate with
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))". Must differ fromSESSION_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.cockatielresilience composition (timeout, retry, circuit breaker, bulkhead).audienceConstraint→authz.denyaudit event).downstream.<service>.<verb>.<path>.auth.token.validation.failedaudit event itself (the discriminator is onOboAcquireError, the audit-emission glue lives in the future framework).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-bff— 334 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.SESSION_ENCRYPTION_KEY. Boot-order tolerant: accepts the value whenSESSION_ENCRYPTION_KEYis unset.v1., never contains the plaintext sentinel.null.expiresAt − now − 60 000. Write skipped when token already inside the buffer.Notes for the reviewer
override readonly causeonOboAcquireErrorbecause TSstrict.exactOptionalPropertyTypes + noImplicitOverrideflags shadowing the built-inError.cause. The shadowing is intentional — we want the typed cause property visible in error consumers — so theoverridekeyword 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.DownstreamModuleis wired intoAppModuleeven 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 "injectOboStrategyand call.acquire()".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.