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