feat(portal-bff): obo strategy + encrypted downstream token cache (#137)
## Summary
First half of the **DownstreamApiClient + OBO** chantier per [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md). 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":
> *"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.**"*
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
### [`assertOboCacheEncryptionKey`](apps/portal-bff/src/config/check-obo-cache-encryption-key.ts)
Boot validator mirroring `assertSessionEncryptionKey`. AES-256-GCM, 32-byte requirement, placeholder rejection, fail-fast posture. Plus one extra defense in depth:
> *Refuses a value identical to `SESSION_ENCRYPTION_KEY`* — ADR-0014 §"Token cache (for OBO)" mandates dedicated keys; catching the copy-paste regression at boot prevents a silent trust-boundary downgrade.
Wired in [`main.ts`](apps/portal-bff/src/main.ts) alongside the other `assertX()` validators.
### [`DownstreamTokenCache`](apps/portal-bff/src/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`).
| Path | Behaviour |
| --- | --- |
| Cache miss | Returns `null`. |
| Tampered ciphertext | Returns `null` + Pino warn `downstream.obo_cache.decrypt_failed`. |
| Wrong-key ciphertext | Returns `null` (GCM auth-tag mismatch). |
| Decrypted but malformed shape | Returns `null` + Pino warn. |
| Redis read failure | Returns `null` + Pino warn `downstream.obo_cache.read_failed`. |
| Write of a token already inside the 60 s buffer | Skipped (TTL would be useless). |
| Redis write failure | Logged, non-fatal. |
Reads never throw — every failure collapses to a miss, the strategy re-acquires from Entra.
### [`OboStrategy`](apps/portal-bff/src/downstream/strategies/obo.strategy.ts)
Wraps MSAL Node's `acquireTokenOnBehalfOf` with the cache.
```
acquire(input):
cached = cache.get(...)
if cached && cached.expiresAt - now > 60s → return cached
result = msal.acquireTokenOnBehalfOf({ oboAssertion, scopes })
if !result || !result.accessToken || !result.expiresOn → throw OboAcquireError(msal-no-result)
cache.set(...)
return result
```
`OboAcquireError` carries a typed `reason` discriminator (`msal-refused` / `msal-no-result`) the future framework will translate to a **502 + `auth.token.validation.failed`** audit 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 `acquireTokenOnBehalfOf` with the user's current Entra access token (read from session via CLS)"*. v1 sessions don't persist the user's access token (ADR-0009 omits `offline_access` deliberately). 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.
### [`DownstreamModule`](apps/portal-bff/src/downstream/downstream.module.ts)
Provides `OBO_CACHE_KEY` (via the validator at factory time), `DownstreamTokenCache`, `OboStrategy`. Imports `AuthModule` for the shared `MSAL_CLIENT` and `RedisModule` for the shared `ioredis` client. Wired into `AppModule` though 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)
```env
OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
```
Generate with `node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"`. Must differ from `SESSION_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.
- `cockatiel` resilience composition (timeout, retry, circuit breaker, bulkhead).
- Audience pre-check at the call site (`audienceConstraint` → `authz.deny` audit event).
- Error-translation tables per service.
- OTel custom spans `downstream.<service>.<verb>.<path>`.
- The `auth.token.validation.failed` audit event itself (the discriminator is on `OboAcquireError`, the audit-emission glue lives in the future framework).
- The framework wiring that reads the user access token from 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 needs.
## Test plan
- [x] `pnpm nx test portal-bff` — **334 specs pass** (was 308; +26: env validator 8, token cache 9, OBO strategy 9).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [x] Env validator refuses placeholder, wrong length, non-base64url, AND identical-to-`SESSION_ENCRYPTION_KEY`. Boot-order tolerant: accepts the value when `SESSION_ENCRYPTION_KEY` is unset.
- [x] Token cache round-trip verified: written ciphertext starts with `v1.`, never contains the plaintext sentinel.
- [x] Tamper rejection verified: flipping the last char of the GCM-encrypted blob fails decryption and collapses to a miss.
- [x] Wrong-key rejection verified: writing with one key, reading with another, returns `null`.
- [x] TTL math verified: PX TTL = `expiresAt − now − 60 000`. Write skipped when token already inside the buffer.
- [x] OBO strategy: cache-hit short-circuit, stale-cache re-acquire, cold-cache → MSAL → cache.set, MSAL refusal → typed error, MSAL null-result → typed error, empty access token → typed error, null expiresOn → typed error.
## Notes for the reviewer
- The strategy file uses `override readonly cause` on `OboAcquireError` because TS `strict.exactOptionalPropertyTypes + noImplicitOverride` flags shadowing the built-in `Error.cause`. The shadowing is intentional — we want the typed cause property visible in error consumers — so the `override` keyword 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.
- The `DownstreamModule` is wired into `AppModule` even 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 "inject `OboStrategy` and call `.acquire()`".
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #137
This commit was merged in pull request #137.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
|
||||
import { decrypt, encrypt } from '../session/session-crypto';
|
||||
import { OBO_CACHE_KEY } from './downstream.token';
|
||||
|
||||
/**
|
||||
* Safety buffer subtracted from the upstream token's `expiresAt`
|
||||
* before computing the cache TTL. Per ADR-0014 §"Token cache (for
|
||||
* OBO)": "TTL equal to the token's expiry minus a safety buffer
|
||||
* (60 s)". Prevents the cache from returning a token that will
|
||||
* expire mid-call.
|
||||
*/
|
||||
const SAFETY_BUFFER_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Minimum positive TTL written to Redis. Anything shorter would
|
||||
* race against the BFF's own clock — better to skip the cache and
|
||||
* re-fetch from MSAL than persist a token that will already be
|
||||
* stale by the time the downstream sees it.
|
||||
*/
|
||||
const MIN_CACHE_TTL_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Decoded entry returned to the OBO strategy on a cache hit. The
|
||||
* `expiresAt` field is verbatim from the upstream `AuthenticationResult`
|
||||
* so the strategy can compute its own freshness check independently
|
||||
* of the cache layer (e.g. for logging "we served a cached token
|
||||
* with 47 s left").
|
||||
*/
|
||||
export interface CachedToken {
|
||||
readonly accessToken: string;
|
||||
/** Epoch ms when the upstream token expires. */
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypted-at-rest cache for OBO-acquired downstream tokens per
|
||||
* ADR-0014. Key shape: `obo:{actorIdHash}:{resource}`. Values are
|
||||
* encrypted via the shared AES-256-GCM helpers (same algorithm as
|
||||
* `session-crypto`) but under a **dedicated key**
|
||||
* ({@link OBO_CACHE_KEY}) so a cache-key compromise can't cascade
|
||||
* into session compromise.
|
||||
*
|
||||
* The cache is a strict short-circuit on the MSAL OBO round-trip:
|
||||
*
|
||||
* - Hit → return the cached token verbatim. Freshness check is
|
||||
* the caller's job (the strategy applies a buffer beyond what
|
||||
* the TTL enforces in Redis itself).
|
||||
* - Miss / tampered / wrong-key → return `null`. The strategy
|
||||
* re-acquires from Entra and writes the new value.
|
||||
*
|
||||
* The cache **never throws** on read — every failure is logged and
|
||||
* collapses to a miss. Per ADR-0014 §"OBO strategy", the rare
|
||||
* worst-case (MSAL unreachable AND cache useless) is the strategy's
|
||||
* concern, not the cache's.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DownstreamTokenCache {
|
||||
constructor(
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
@Inject(OBO_CACHE_KEY) private readonly key: Buffer,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async get(input: { actorIdHash: string; resource: string }): Promise<CachedToken | null> {
|
||||
const redisKey = cacheKey(input);
|
||||
let payload: string | null;
|
||||
try {
|
||||
payload = await this.redis.get(redisKey);
|
||||
} catch (err) {
|
||||
// Redis hiccup — log and pretend the cache is empty. The
|
||||
// strategy will pay an MSAL round-trip on this request and
|
||||
// try the cache again on the next one.
|
||||
this.logger.warn(
|
||||
{
|
||||
event: 'downstream.obo_cache.read_failed',
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
'DownstreamTokenCache',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (payload === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const decoded = JSON.parse(decrypt(payload, this.key));
|
||||
if (!isCachedToken(decoded)) {
|
||||
// Stored shape changed under us — refuse to serve. The
|
||||
// miss will trigger a re-acquisition; the bad value gets
|
||||
// overwritten on the next write.
|
||||
this.logger.warn({ event: 'downstream.obo_cache.shape_mismatch' }, 'DownstreamTokenCache');
|
||||
return null;
|
||||
}
|
||||
return decoded;
|
||||
} catch (err) {
|
||||
// Tampered ciphertext, wrong key, or unknown version — the
|
||||
// safe move is to treat it as a miss. Per ADR-0014's
|
||||
// confirmation: "tampering is rejected". Surface the rejection
|
||||
// in the log so ops can spot a key-rotation gone wrong.
|
||||
this.logger.warn(
|
||||
{
|
||||
event: 'downstream.obo_cache.decrypt_failed',
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
'DownstreamTokenCache',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(input: { actorIdHash: string; resource: string; token: CachedToken }): Promise<void> {
|
||||
const redisKey = cacheKey(input);
|
||||
const ttlMs = input.token.expiresAt - Date.now() - SAFETY_BUFFER_MS;
|
||||
if (ttlMs < MIN_CACHE_TTL_MS) {
|
||||
// The token is already inside the safety buffer — caching it
|
||||
// would only encourage a stale read. Skip the write entirely;
|
||||
// the strategy will re-acquire on the next call.
|
||||
return;
|
||||
}
|
||||
const plaintext = JSON.stringify(input.token);
|
||||
const ciphertext = encrypt(plaintext, this.key);
|
||||
try {
|
||||
await this.redis.set(redisKey, ciphertext, 'PX', ttlMs);
|
||||
} catch (err) {
|
||||
// Redis write failure is non-fatal: the call already has its
|
||||
// freshly-acquired token in hand. Worst case we'll pay an
|
||||
// extra MSAL round-trip on the next request — not a
|
||||
// correctness issue. Surface the failure for ops.
|
||||
this.logger.warn(
|
||||
{
|
||||
event: 'downstream.obo_cache.write_failed',
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
'DownstreamTokenCache',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKey(input: { actorIdHash: string; resource: string }): string {
|
||||
return `obo:${input.actorIdHash}:${input.resource}`;
|
||||
}
|
||||
|
||||
function isCachedToken(value: unknown): value is CachedToken {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return typeof v['accessToken'] === 'string' && typeof v['expiresAt'] === 'number';
|
||||
}
|
||||
Reference in New Issue
Block a user