--- 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": "", "aud": "", "audience": "workforce" | "customer", "claims": { /* a curated subset */ }, "exp": , "iat": , "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...` 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` | | `_API_BASE_URL` (per service) | service-specific base URL; required for any registered 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...` 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 100–300 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).