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,72 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { assertOboCacheEncryptionKey } from './check-obo-cache-encryption-key';
|
||||
|
||||
const STRONG_KEY = randomBytes(32).toString('base64url');
|
||||
const OTHER_KEY = randomBytes(32).toString('base64url');
|
||||
|
||||
describe('assertOboCacheEncryptionKey', () => {
|
||||
const originalObo = process.env['OBO_CACHE_ENCRYPTION_KEY'];
|
||||
const originalSession = process.env['SESSION_ENCRYPTION_KEY'];
|
||||
|
||||
afterEach(() => {
|
||||
restore('OBO_CACHE_ENCRYPTION_KEY', originalObo);
|
||||
restore('SESSION_ENCRYPTION_KEY', originalSession);
|
||||
});
|
||||
|
||||
it('returns the decoded 32-byte buffer for a well-formed key', () => {
|
||||
process.env['OBO_CACHE_ENCRYPTION_KEY'] = STRONG_KEY;
|
||||
const key = assertOboCacheEncryptionKey();
|
||||
expect(key).toBeInstanceOf(Buffer);
|
||||
expect(key.length).toBe(32);
|
||||
expect(key.equals(Buffer.from(STRONG_KEY, 'base64url'))).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when OBO_CACHE_ENCRYPTION_KEY is unset', () => {
|
||||
delete process.env['OBO_CACHE_ENCRYPTION_KEY'];
|
||||
expect(() => assertOboCacheEncryptionKey()).toThrow(/OBO_CACHE_ENCRYPTION_KEY is not set/);
|
||||
});
|
||||
|
||||
it('throws when OBO_CACHE_ENCRYPTION_KEY is the .env.example placeholder', () => {
|
||||
process.env['OBO_CACHE_ENCRYPTION_KEY'] = 'replace_with_32_random_bytes_base64url';
|
||||
expect(() => assertOboCacheEncryptionKey()).toThrow(/placeholder/);
|
||||
});
|
||||
|
||||
it('throws when OBO_CACHE_ENCRYPTION_KEY decodes to fewer than 32 bytes', () => {
|
||||
process.env['OBO_CACHE_ENCRYPTION_KEY'] = randomBytes(16).toString('base64url');
|
||||
expect(() => assertOboCacheEncryptionKey()).toThrow(/decodes to 16 bytes/);
|
||||
});
|
||||
|
||||
it('throws when OBO_CACHE_ENCRYPTION_KEY decodes to more than 32 bytes', () => {
|
||||
process.env['OBO_CACHE_ENCRYPTION_KEY'] = randomBytes(64).toString('base64url');
|
||||
expect(() => assertOboCacheEncryptionKey()).toThrow(/decodes to 64 bytes/);
|
||||
});
|
||||
|
||||
it('throws when OBO_CACHE_ENCRYPTION_KEY is identical to SESSION_ENCRYPTION_KEY', () => {
|
||||
// ADR-0014 mandates dedicated keys. Catching this at boot
|
||||
// prevents a copy-paste regression from silently downgrading
|
||||
// the trust boundary.
|
||||
process.env['SESSION_ENCRYPTION_KEY'] = STRONG_KEY;
|
||||
process.env['OBO_CACHE_ENCRYPTION_KEY'] = STRONG_KEY;
|
||||
expect(() => assertOboCacheEncryptionKey()).toThrow(/must differ from SESSION_ENCRYPTION_KEY/);
|
||||
});
|
||||
|
||||
it('accepts a value identical only when SESSION_ENCRYPTION_KEY is unset (boot order tolerant)', () => {
|
||||
delete process.env['SESSION_ENCRYPTION_KEY'];
|
||||
process.env['OBO_CACHE_ENCRYPTION_KEY'] = STRONG_KEY;
|
||||
expect(() => assertOboCacheEncryptionKey()).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts distinct keys', () => {
|
||||
process.env['SESSION_ENCRYPTION_KEY'] = STRONG_KEY;
|
||||
process.env['OBO_CACHE_ENCRYPTION_KEY'] = OTHER_KEY;
|
||||
expect(() => assertOboCacheEncryptionKey()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
function restore(name: string, original: string | undefined): void {
|
||||
if (original === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = original;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Sanity-check the `OBO_CACHE_ENCRYPTION_KEY` env var early in
|
||||
* bootstrap. Mirrors `assertSessionEncryptionKey` — same AES-256-GCM
|
||||
* 32-byte requirement, same placeholder rejection, same fail-fast
|
||||
* posture.
|
||||
*
|
||||
* `OBO_CACHE_ENCRYPTION_KEY` is the dedicated AES-256-GCM key for
|
||||
* the OBO downstream-token cache per
|
||||
* [ADR-0014](../../../../docs/decisions/0014-downstream-api-access-obo-pattern.md)
|
||||
* §"Token cache (for OBO)". Per the ADR, this key is intentionally
|
||||
* **distinct** from `SESSION_ENCRYPTION_KEY`:
|
||||
*
|
||||
* "The cached value is encrypted with AES-256-GCM using a
|
||||
* dedicated key (OBO_CACHE_ENCRYPTION_KEY), distinct from
|
||||
* SESSION_ENCRYPTION_KEY (ADR-0010) so a cache-key compromise
|
||||
* does not cascade into session compromise."
|
||||
*
|
||||
* Treat the two keys as separate trust boundaries: rotating one
|
||||
* must never require rotating the other, and a leak of either
|
||||
* must not expose data encrypted by the other.
|
||||
*
|
||||
* Returns the decoded 32-byte key as a `Buffer` so callers can pass
|
||||
* it straight to `crypto.createCipheriv('aes-256-gcm', key, iv)`
|
||||
* without re-decoding per request.
|
||||
*/
|
||||
|
||||
const PLACEHOLDER = 'replace_with_32_random_bytes_base64url';
|
||||
const REQUIRED_KEY_BYTES = 32;
|
||||
|
||||
export function assertOboCacheEncryptionKey(): Buffer {
|
||||
const raw = process.env['OBO_CACHE_ENCRYPTION_KEY'];
|
||||
if (!raw || raw === '') {
|
||||
throw new Error(
|
||||
`OBO_CACHE_ENCRYPTION_KEY is not set. Generate one with ` +
|
||||
`"node -e \\"console.log(require('crypto').randomBytes(32).toString('base64url'))\\"" ` +
|
||||
`and put it in apps/portal-bff/.env. It MUST differ from SESSION_ENCRYPTION_KEY.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (raw === PLACEHOLDER) {
|
||||
throw new Error(
|
||||
`OBO_CACHE_ENCRYPTION_KEY is still set to the .env.example placeholder ` +
|
||||
`("${PLACEHOLDER}"). Replace with a real random value.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Defense in depth: refuse the exact same value as the session
|
||||
// key. The ADR mandates distinct keys; catching it here means a
|
||||
// copy-paste accident in .env never makes it past boot.
|
||||
const sessionKey = process.env['SESSION_ENCRYPTION_KEY'];
|
||||
if (sessionKey !== undefined && sessionKey !== '' && sessionKey === raw) {
|
||||
throw new Error(
|
||||
`OBO_CACHE_ENCRYPTION_KEY must differ from SESSION_ENCRYPTION_KEY ` +
|
||||
`(ADR-0014 §"Token cache (for OBO)"). Generate a fresh value.`,
|
||||
);
|
||||
}
|
||||
|
||||
let decoded: Buffer;
|
||||
try {
|
||||
decoded = Buffer.from(raw, 'base64url');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`OBO_CACHE_ENCRYPTION_KEY must be a base64url-encoded string. Got: ${truncate(raw)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (decoded.length !== REQUIRED_KEY_BYTES) {
|
||||
throw new Error(
|
||||
`OBO_CACHE_ENCRYPTION_KEY decodes to ${decoded.length} bytes, ` +
|
||||
`but AES-256-GCM requires exactly ${REQUIRED_KEY_BYTES}. ` +
|
||||
`Generate a fresh value.`,
|
||||
);
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function truncate(s: string): string {
|
||||
return s.length > 16 ? `${s.slice(0, 16)}…` : s;
|
||||
}
|
||||
Reference in New Issue
Block a user