Files
apf_portal/apps/portal-bff/src/downstream/downstream-token-cache.service.spec.ts
T
julien d665c66c4e
CI / check (push) Successful in 3m49s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m31s
CI / a11y (push) Successful in 1m45s
CI / perf (push) Successful in 3m25s
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
2026-05-14 18:13:30 +02:00

226 lines
8.1 KiB
TypeScript

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',
);
});
});