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,225 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { Logger } from 'nestjs-pino';
|
||||
import { DownstreamTokenCache, type CachedToken } from './downstream-token-cache.service';
|
||||
|
||||
const KEY = randomBytes(32);
|
||||
const OTHER_KEY = randomBytes(32);
|
||||
|
||||
interface RedisStub {
|
||||
get: jest.Mock;
|
||||
set: jest.Mock;
|
||||
}
|
||||
|
||||
function makeRedis(opts?: { get?: jest.Mock; set?: jest.Mock }): RedisStub {
|
||||
return {
|
||||
get: opts?.get ?? jest.fn().mockResolvedValue(null),
|
||||
set: opts?.set ?? jest.fn().mockResolvedValue('OK'),
|
||||
};
|
||||
}
|
||||
|
||||
function makeLogger() {
|
||||
return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & {
|
||||
log: jest.Mock;
|
||||
warn: jest.Mock;
|
||||
error: jest.Mock;
|
||||
};
|
||||
}
|
||||
|
||||
function makeCache(
|
||||
redis: RedisStub,
|
||||
key: Buffer = KEY,
|
||||
): { cache: DownstreamTokenCache; logger: ReturnType<typeof makeLogger> } {
|
||||
const logger = makeLogger();
|
||||
// The service treats the injected redis client as the bare
|
||||
// ioredis surface; we only need `.get` and `.set` for these tests.
|
||||
const cache = new DownstreamTokenCache(redis as never, key, logger);
|
||||
return { cache, logger };
|
||||
}
|
||||
|
||||
const INPUT = { actorIdHash: 'hash(jane)', resource: 'api://downstream-svc' };
|
||||
|
||||
describe('DownstreamTokenCache.get', () => {
|
||||
it('returns null on a true cache miss (Redis returned null)', async () => {
|
||||
const redis = makeRedis({ get: jest.fn().mockResolvedValue(null) });
|
||||
const { cache } = makeCache(redis);
|
||||
expect(await cache.get(INPUT)).toBeNull();
|
||||
expect(redis.get).toHaveBeenCalledWith('obo:hash(jane):api://downstream-svc');
|
||||
});
|
||||
|
||||
it('decrypts a well-formed entry round-tripped through set()', async () => {
|
||||
// Use the cache itself to write, then read back — exercises the
|
||||
// encrypt/decrypt round-trip with the same key.
|
||||
let stored: string | null = null;
|
||||
const redis = makeRedis({
|
||||
get: jest.fn().mockImplementation(() => Promise.resolve(stored)),
|
||||
set: jest.fn().mockImplementation((_k: string, v: string) => {
|
||||
stored = v;
|
||||
return Promise.resolve('OK');
|
||||
}),
|
||||
});
|
||||
const { cache } = makeCache(redis);
|
||||
const token: CachedToken = { accessToken: 'down-token', expiresAt: Date.now() + 600_000 };
|
||||
await cache.set({ ...INPUT, token });
|
||||
const read = await cache.get(INPUT);
|
||||
expect(read).toEqual(token);
|
||||
});
|
||||
|
||||
it('returns null and logs when decryption fails (tampered ciphertext)', async () => {
|
||||
let stored: string | null = null;
|
||||
const redis = makeRedis({
|
||||
get: jest.fn().mockImplementation(() => Promise.resolve(stored)),
|
||||
set: jest.fn().mockImplementation((_k: string, v: string) => {
|
||||
stored = v;
|
||||
return Promise.resolve('OK');
|
||||
}),
|
||||
});
|
||||
const { cache, logger } = makeCache(redis);
|
||||
await cache.set({
|
||||
...INPUT,
|
||||
token: { accessToken: 'down-token', expiresAt: Date.now() + 600_000 },
|
||||
});
|
||||
// Flip the very last char of the ciphertext to break the GCM
|
||||
// auth tag — decrypt() rejects the tag verification.
|
||||
stored = stored ? `${stored.slice(0, -1)}${stored.endsWith('A') ? 'B' : 'A'}` : null;
|
||||
expect(await cache.get(INPUT)).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'downstream.obo_cache.decrypt_failed' }),
|
||||
'DownstreamTokenCache',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when the entry is decryptable but malformed JSON shape', async () => {
|
||||
// Round-trip a non-CachedToken value through encryption, then
|
||||
// ask the cache to read it — the shape guard collapses it to a
|
||||
// miss so the strategy re-acquires.
|
||||
let stored: string | null = null;
|
||||
const writerRedis = makeRedis({
|
||||
set: jest.fn().mockImplementation((_k: string, v: string) => {
|
||||
stored = v;
|
||||
return Promise.resolve('OK');
|
||||
}),
|
||||
});
|
||||
const { cache: writer } = makeCache(writerRedis);
|
||||
// Manually write a bogus shape with the same key.
|
||||
// We piggyback on the cache's encrypt path by calling set with
|
||||
// a wide token then handcraft the stored payload after.
|
||||
await writer.set({
|
||||
...INPUT,
|
||||
token: { accessToken: 'x', expiresAt: Date.now() + 600_000 },
|
||||
});
|
||||
// Now read with a fresh cache instance pointing at the same
|
||||
// stored payload but interpreted with a STRICT shape check —
|
||||
// mock the get to return an encrypted payload of a non-CachedToken.
|
||||
// Use a separately-encrypted bogus payload by routing through
|
||||
// session-crypto directly.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { encrypt } = require('../session/session-crypto') as {
|
||||
encrypt: (plaintext: string, key: Buffer) => string;
|
||||
};
|
||||
stored = encrypt(JSON.stringify({ accessToken: 42, expiresAt: 'soon' }), KEY);
|
||||
const reader = new DownstreamTokenCache(
|
||||
{ get: jest.fn().mockResolvedValue(stored), set: jest.fn() } as never,
|
||||
KEY,
|
||||
makeLogger(),
|
||||
);
|
||||
expect(await reader.get(INPUT)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the value was encrypted under a different key', async () => {
|
||||
// Write with one key, read with another — `decipher.final()`
|
||||
// throws on the GCM auth-tag mismatch and the cache treats it
|
||||
// as a miss.
|
||||
let stored: string | null = null;
|
||||
const writerRedis = makeRedis({
|
||||
set: jest.fn().mockImplementation((_k: string, v: string) => {
|
||||
stored = v;
|
||||
return Promise.resolve('OK');
|
||||
}),
|
||||
});
|
||||
const { cache: writer } = makeCache(writerRedis, KEY);
|
||||
await writer.set({
|
||||
...INPUT,
|
||||
token: { accessToken: 'down-token', expiresAt: Date.now() + 600_000 },
|
||||
});
|
||||
const reader = new DownstreamTokenCache(
|
||||
{ get: jest.fn().mockResolvedValue(stored), set: jest.fn() } as never,
|
||||
OTHER_KEY,
|
||||
makeLogger(),
|
||||
);
|
||||
expect(await reader.get(INPUT)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null and logs on a Redis read failure', async () => {
|
||||
const redis = makeRedis({
|
||||
get: jest.fn().mockRejectedValue(new Error('connection refused')),
|
||||
});
|
||||
const { cache, logger } = makeCache(redis);
|
||||
expect(await cache.get(INPUT)).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'downstream.obo_cache.read_failed' }),
|
||||
'DownstreamTokenCache',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DownstreamTokenCache.set', () => {
|
||||
it('writes the encrypted token to Redis with a PX TTL equal to expiry minus 60 s', async () => {
|
||||
const now = 10_000_000_000;
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(now);
|
||||
try {
|
||||
const redis = makeRedis();
|
||||
const { cache } = makeCache(redis);
|
||||
const distinctiveToken = 'PLAINTEXT_DOWNSTREAM_TOKEN_SENTINEL';
|
||||
const token: CachedToken = {
|
||||
accessToken: distinctiveToken,
|
||||
expiresAt: now + 600_000,
|
||||
};
|
||||
await cache.set({ ...INPUT, token });
|
||||
expect(redis.set).toHaveBeenCalledWith(
|
||||
'obo:hash(jane):api://downstream-svc',
|
||||
expect.any(String),
|
||||
'PX',
|
||||
540_000, // 600s expiry - 60s buffer
|
||||
);
|
||||
// The ciphertext stored is NOT the raw access token.
|
||||
const ciphertext = redis.set.mock.calls[0]?.[1] as string;
|
||||
expect(ciphertext).not.toContain(distinctiveToken);
|
||||
expect(ciphertext.startsWith('v1.')).toBe(true);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips the write when the token expires within the safety buffer', async () => {
|
||||
const now = 10_000_000_000;
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(now);
|
||||
try {
|
||||
const redis = makeRedis();
|
||||
const { cache } = makeCache(redis);
|
||||
const token: CachedToken = { accessToken: 'x', expiresAt: now + 30_000 }; // 30s < 60s buffer
|
||||
await cache.set({ ...INPUT, token });
|
||||
expect(redis.set).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs but does not throw when the Redis SET fails', async () => {
|
||||
const redis = makeRedis({
|
||||
set: jest.fn().mockRejectedValue(new Error('OOM')),
|
||||
});
|
||||
const { cache, logger } = makeCache(redis);
|
||||
await expect(
|
||||
cache.set({
|
||||
...INPUT,
|
||||
token: { accessToken: 'x', expiresAt: Date.now() + 600_000 },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'downstream.obo_cache.write_failed' }),
|
||||
'DownstreamTokenCache',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user