docs: add ADR-0014 for downstream API access (OBO pattern + DownstreamApiClient framework)

Pin the framework for calls from the BFF to integrated downstream APIs.
The concrete list of downstream services is not yet known, but the
framework must exist so that the day a developer adds an integration the
answer is 'use the standard client', not 'invent something'.

A DownstreamApisModule exposes a DownstreamApiClientFactory that produces
typed clients from per-service DownstreamApiConfig blocks. Each config
declares the auth strategy, base URL, timeout, retry, circuit breaker,
bulkhead, and audienceConstraint.

Default auth strategy for Entra-protected downstreams is On-Behalf-Of
(MSAL Node acquireTokenOnBehalfOf). Downstream-scoped tokens are cached
in Redis under obo:{user_id_hash}:{resource}, encrypted with AES-256-GCM
using a dedicated key (OBO_CACHE_ENCRYPTION_KEY) distinct from the
session-encryption key so a cache compromise doesn't cascade into a
session compromise.

Fallback strategy for non-Entra downstreams is service credential +
signed user-assertion header (X-User-Assertion JWT signed by the BFF's
private key, verified by downstreams against the BFF JWKS at
/.well-known/jwks.json). Token relay is rejected as a default;
per-user credential mapping is rejected outright.

Resilience composes via cockatiel: timeout outermost, then retry (only
on idempotent verbs and retriable error classes), circuit breaker per
service, bulkhead per service. Each call opens a downstream.<service>
OpenTelemetry span; auth failures emit audit events. Downstream errors
are translated at the client boundary - never bubbled with raw payload.

Audience pre-check is enforced at the call site (not at controller
entry) - even a missing authorization guard upstream cannot bypass the
audience constraint.

The framework is forward-looking; concrete integrations land per-service
in code config (no per-integration ADR unless the integration deviates
non-trivially from the defaults). Strategy code is exercised by
mock-driven tests until the first real integration ships.

decisions/README.md index updated. CLAUDE.md gains an explicit
'Downstream API access' line pointing to ADR-0014.
This commit is contained in:
Julien Gautier
2026-04-29 23:58:49 +02:00
parent b35bf2b3de
commit a20330a474
3 changed files with 250 additions and 0 deletions
@@ -0,0 +1,248 @@
---
status: accepted
date: 2026-04-29
decision-makers: R&D Lead
tags: [security, backend]
---
# Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization
## Context and Problem Statement
The portal will integrate access to existing applications. Some of those applications expose APIs the BFF will call on behalf of the authenticated user; some are slated to be re-developed as portal features and will expose their own APIs. The downstream auth landscape will be heterogeneous — some Entra-protected, some with legacy auth (API key, mTLS, custom OAuth client-credentials), some willing to trust a signed identity assertion from the BFF.
The concrete list of downstream services is not known yet (the project lead deferred those decisions). This ADR fixes the **framework** in which any future integration plugs in. Without a fixed framework, the day a developer needs to call a downstream the answer becomes a fresh debate or — worse — an ad-hoc `axios.post` in a controller.
## Decision Drivers
* No token ever leaves the BFF except through the legitimate downstream-call path.
* A single canonical place where downstream HTTP calls are made — never a `fetch`/`axios` directly in a controller or a service.
* Resilience: a slow or failing downstream cannot cascade into the BFF's general latency or saturate its event loop.
* Observability: every downstream call is part of the trace started by the user click ([ADR-0012](0012-observability-pino-opentelemetry.md)).
* Audience-aware: a downstream that is workforce-only must not be reachable by a customer-audience session, even by mistake. The dual-audience design ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) is honoured at the call site.
* Forward-compatible: a new downstream is added by configuration, not refactor.
* Trust-realistic: legacy downstreams that can't (yet) speak Entra must have a path that is not "store the user's password somewhere".
## Considered Options
### Authentication strategy
* **OBO (On-Behalf-Of) for Entra-protected downstreams.** (Chosen as default.)
* **Service credential + signed user-assertion header for non-Entra downstreams.** (Chosen as fallback.)
* Token relay (the BFF passes the user's access token unchanged).
* Per-user credential mapping (the BFF stores each user's credentials for each legacy downstream).
### HTTP client
* **`@nestjs/axios`.** (Chosen.)
* `got`, `undici`, native `fetch`.
### Resilience
* **`cockatiel`** — TypeScript-native composable policies (timeout, retry, circuit breaker, bulkhead). (Chosen.)
* `p-retry` / `opossum` / hand-rolled.
### Token cache (for OBO)
* **Redis with AES-256-GCM, dedicated key.** (Chosen.)
* In-memory only.
* No cache.
### Service registration
* **Module-level typed config.** (Chosen.)
* Decorator-based registration.
## Decision Outcome
A `DownstreamApisModule` provides a `DownstreamApiClientFactory` that produces a typed client per registered downstream. A service is declared via a `DownstreamApiConfig` block:
```ts
export type DownstreamApiConfig = {
name: string;
baseUrl: string; // env-driven
auth:
| { strategy: 'obo'; resource: string; scopes: string[] }
| { strategy: 'service'; credential: ServiceCredential; userAssertion: SignedAssertionConfig | null }
| { strategy: 'none' };
timeoutMs: number;
retry: { attempts: number; baseDelayMs: number; maxDelayMs: number; jitter: boolean };
circuitBreaker: { halfOpenAfterMs: number; threshold: { ratio: number; samples: number } };
bulkhead: { maxConcurrent: number };
audienceConstraint: ReadonlyArray<'workforce' | 'customer'>;
};
```
### OBO strategy (Entra-protected downstreams)
The factory composes an OBO authenticator that uses MSAL Node's `acquireTokenOnBehalfOf` with the user's current Entra access token (read from session via CLS) to obtain a downstream-scoped token. The downstream-scoped token is **cached in Redis** under `obo:{user_id_hash}:{resource}` with TTL equal to the token's expiry minus a safety buffer (60 s). The cached value is encrypted with **AES-256-GCM** using a **dedicated key** (`OBO_CACHE_ENCRYPTION_KEY`), distinct from `SESSION_ENCRYPTION_KEY` ([ADR-0010](0010-session-management-redis.md)) so a cache-key compromise does not cascade into session compromise.
On cache miss or imminent expiry, the BFF calls Entra's OBO endpoint and re-caches the result. Failures (Entra unreachable, OBO refused, the user's access token expired) emit an audit event (`auth.token.validation.failed` from [ADR-0013](0013-audit-trail-separated-postgres-append-only.md)) and surface as a 502 to the caller — the BFF does **not** silently fall back to the user's original token.
### Service strategy (non-Entra downstreams)
The BFF authenticates *as itself* with the configured `ServiceCredential` (API key, OAuth client-credentials grant, mTLS, …). The user identity is propagated via a **signed assertion header**`X-User-Assertion`, a short-lived JWT signed by the BFF's private key, carrying a minimal claim set:
```jsonc
{
"iss": "portal-bff",
"sub": "<actor_id_hash>",
"aud": "<downstream-name>",
"audience": "workforce" | "customer",
"claims": { /* a curated subset */ },
"exp": <now + 60s>,
"iat": <now>,
"trace_id": "<W3C trace id>"
}
```
The downstream verifies the signature against the BFF's published JWKS at `/.well-known/jwks.json` and then makes its own authorization decision based on the assertion. This requires the downstream to **trust the BFF as an identity authority** — that trust is established per integration, in a written agreement, before the integration ships. It is not a free lunch.
### Rejected — token relay
Token relay (passing the user's access token unchanged to a downstream) is rejected as a default because it requires the downstream to share the BFF's audience/resource — fragile and non-portable. It can be considered as a per-integration deviation when the downstream is, in fact, the same Entra-registered API as the BFF (rare, ADR per case).
### Rejected — per-user credential mapping
Per-user credential mapping is **rejected for v1**. If a downstream cannot be served by either OBO or service+assertion, the integration becomes its own ADR with its own justification. Storing user credentials for legacy systems is the kind of operational liability the project has explicitly excluded.
### HTTP client
`@nestjs/axios` (axios under the hood). One client instance per registered service; configured with the per-service `baseUrl`, the resolved auth header from the active strategy, and the per-call timeout. NestJS interceptors apply tracing, the audience pre-check, and error mapping uniformly.
### Resilience policies (composed via `cockatiel`, outermost first)
1. **Timeout** — every call has a hard deadline (default 5 s).
2. **Retry** — only on idempotent verbs (`GET`, `PUT`, `DELETE`) and retriable error classes (network errors, HTTP 5xx, HTTP 429 with `Retry-After` honoured). `POST` retries only when the caller explicitly opts in with an idempotency key. Exponential backoff with jitter.
3. **Circuit breaker** — per service, opens when the failure ratio exceeds the configured threshold over a sample window; half-open after `halfOpenAfterMs`.
4. **Bulkhead** — per service, caps concurrent in-flight calls so a slow service can't starve the BFF.
### Audience pre-check
Before any call leaves the BFF, the client reads the current request's `audience` from CLS (ADR-0012) and rejects with HTTP 403 + an `authz.deny` audit event (ADR-0013) if the audience is not in the service's `audienceConstraint`. In v1 every service is `audienceConstraint: ['workforce']`; the constraint is checked anyway so the dual-audience design is honoured from the first integration. The check is at the *call site*, not at controller entry — even a missed authorization guard upstream cannot bypass it.
### Observability
Each call opens a custom OpenTelemetry span `downstream.<service>.<verb>.<path>` with attributes: `service.name`, `attempt`, `http.status_code`, `retry.count`, `breaker.state`. The HTTP auto-instrumentation propagates `traceparent` to the downstream automatically (W3C — ADR-0012). Pino logs include `service`, `attempt`, `status`, `duration_ms`. Auth-related failures emit audit events.
### Error translation
Downstream errors are translated to BFF errors at the client boundary — **never** bubbled with the raw payload. The translation table lives with each service config (e.g. a downstream 404 may be a 502 in the BFF if the resource was supposed to exist, or a 404 if the BFF route exposes it). This keeps downstream internals out of the SPA and out of error logs accessible by SPA consumers.
### Per-integration ADRs
A downstream that requires a non-default auth strategy, a custom retry policy, a different audience model, or a special trust relationship gets its own ADR. Trivial integrations (Entra OBO, default policies, workforce-only) live in code config without a dedicated ADR. The threshold is *non-trivial deviation*, not *every integration*.
### Configuration (env-driven)
| Variable | Purpose |
| --- | --- |
| `OBO_CACHE_ENCRYPTION_KEY` | 32-byte base64 AES-GCM key for the OBO token cache; refused at startup if malformed |
| `BFF_JWKS_PRIVATE_KEY_PATH` | path to the BFF's private key for signing user-assertion JWTs |
| `BFF_JWKS_KID` | key id published in `/.well-known/jwks.json` |
| `<SERVICE>_API_BASE_URL` (per service) | service-specific base URL; required for any registered service |
| `<SERVICE>_TIMEOUT_MS` (per service, optional) | overrides the default 5 s timeout |
### Consequences
* Good, because every downstream call goes through the same plumbing — same observability, same resilience, same audit posture, same audience check. Drift requires leaving the framework, which review will not accept.
* Good, because OBO + cached tokens keeps Entra rate limits low and call latency predictable.
* Good, because the audience constraint at the call site enforces the dual-audience design at the *narrowest* point that actually emits a request.
* Good, because the signed-assertion strategy gives a clean answer for non-Entra downstreams without storing user credentials.
* Good, because resilience composition is explicit and testable — tail latency under partial failure is bounded.
* Good, because a per-service translation of error codes prevents downstream details from leaking into BFF error responses.
* Bad, because the framework is non-trivial — contributors must learn it before adding a downstream. Mitigated by a one-page "how to add a downstream" guide in `docs/`, written when the first integration ships.
* Bad, because the signed-assertion strategy requires the downstream to validate the BFF's JWT — operational coordination per integration, not a magic switch.
* Bad, because the OBO cache adds a second tier-1 secret to manage (`OBO_CACHE_ENCRYPTION_KEY`). Folded into the future ops/secret-rotation ADR.
* 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.
### Confirmation
* `apps/portal-bff/src/downstream/downstream.module.ts` exposes `DownstreamApiClientFactory`.
* `apps/portal-bff/src/downstream/strategies/obo.strategy.ts` and `service-with-assertion.strategy.ts` exist with unit tests against MSAL Node mocks (OBO) and JWT-verification mocks (assertion).
* `apps/portal-bff/src/downstream/token-cache.service.ts` reads/writes Redis with AES-256-GCM via the dedicated key; an integration test asserts the ciphertext is unrecoverable without the key and that tampering is rejected.
* `cockatiel` policies are composed in `apps/portal-bff/src/downstream/resilience.ts`. Tests cover: timeout firing, retry on 5xx, circuit-breaker open/half-open transitions, bulkhead saturation.
* The audience pre-check rejects with 403 and emits `authz.deny` (ADR-0013); a test exercises a customer-audience principal targeting a workforce-only service.
* Each call produces a `downstream.<service>.<verb>.<path>` span; an integration test asserts trace continuity from the BFF's incoming request to the downstream span.
* The BFF refuses to start if `OBO_CACHE_ENCRYPTION_KEY`, `BFF_JWKS_PRIVATE_KEY_PATH`, or `BFF_JWKS_KID` is missing or malformed.
* No production controller imports `axios` or `fetch` directly — enforced by an ESLint rule that flags those imports outside `apps/portal-bff/src/downstream/`. (Rule lands with the future CI ADR.)
## Pros and Cons of the Options
### Authentication strategy
#### OBO for Entra (chosen default)
* Good, because it is Microsoft's recommended pattern for confidential clients calling Entra-protected APIs on behalf of a user.
* Good, because preserves the user's audience, claims, and tenancy in the downstream token.
* Bad, because requires Entra-protected downstreams.
#### Service credential + signed user-assertion (chosen for non-Entra)
* Good, because retains user identity for downstream authorization without giving the downstream a token it could replay against the IdP.
* Good, because the BFF acts as a credentialed gateway — the standard pattern when downstreams cannot be brought into the IdP.
* Bad, because requires per-integration trust setup (publishing JWKS, having the downstream verify).
#### Token relay
* Good, because zero exchange.
* Bad, because only works if the downstream and the BFF share the exact same audience/resource. Non-portable.
* Bad, because a leaky downstream that logs Authorization headers leaks the user's BFF-scoped token.
#### Per-user credential mapping
* Bad, because operationally toxic — storing user passwords or tokens for legacy systems, violating user secrecy. Rejected for v1.
### HTTP client
#### `@nestjs/axios` (chosen)
* Good, because mainstream, integrates with NestJS DI, easy to wrap with interceptors and OTel.
#### `got` / `undici` / native `fetch`
* Good, because lighter or more modern.
* Bad, because no NestJS-blessed wrapper; we'd reinvent the surface for marginal gain.
### Resilience
#### `cockatiel` (chosen)
* Good, because TypeScript-native, composable policies, well-maintained.
* Good, because exposes timeout, retry, circuit breaker, bulkhead, fallback as first-class concepts.
#### `p-retry` / `opossum`
* Good, because simpler.
* Bad, because narrower (one concern each) — composing them into a coherent policy stack reproduces what `cockatiel` already gives.
### Token cache
#### Redis encrypted (chosen)
* Good, because shared across BFF replicas, survives restart, encrypted at rest.
* Bad, because adds a Redis round-trip per OBO acquisition. Negligible vs. an Entra round-trip.
#### In-memory only
* Bad, because per-replica caches duplicate work and break cache hit-rate under scale-out.
#### No cache
* Bad, because hits Entra rate limits and adds 100300 ms per downstream call.
### Service registration
#### Typed config (chosen)
* Good, because explicit, reviewable, IDE-discoverable.
* Bad, because every new service requires a config commit. Acceptable.
#### Decorator-based
* Good, because feels Nest-native.
* Bad, because hides registration in source — harder to grep, harder to audit. Defer.
## More Information
* Microsoft On-Behalf-Of flow: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow
* MSAL Node `acquireTokenOnBehalfOf`: https://learn.microsoft.com/javascript/api/@azure/msal-node/confidentialclientapplication
* `@nestjs/axios`: https://docs.nestjs.com/techniques/http-module
* `cockatiel`: https://github.com/connor4312/cockatiel
* RFC 7517 (JWKS): https://datatracker.ietf.org/doc/html/rfc7517
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md), [ADR-0010](0010-session-management-redis.md), [ADR-0012](0012-observability-pino-opentelemetry.md), [ADR-0013](0013-audit-trail-separated-postgres-append-only.md).