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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { assertOboCacheEncryptionKey } from '../config/check-obo-cache-encryption-key';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
import { DownstreamTokenCache } from './downstream-token-cache.service';
|
||||
import { OBO_CACHE_KEY } from './downstream.token';
|
||||
import { OboStrategy } from './strategies/obo.strategy';
|
||||
|
||||
/**
|
||||
* `DownstreamModule` — primitives for the downstream-API framework
|
||||
* per [ADR-0014](../../../../docs/decisions/0014-downstream-api-access-obo-pattern.md).
|
||||
*
|
||||
* **Scope (v1).** This module ships the auth-strategy primitives
|
||||
* and the OBO token cache only. The framework around them
|
||||
* (`DownstreamApiClientFactory`, cockatiel resilience stack,
|
||||
* audience pre-check, error translation table, OTel custom spans)
|
||||
* lands alongside the first concrete consumer per the ADR's own
|
||||
* guidance:
|
||||
*
|
||||
* "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."
|
||||
*
|
||||
* Until then `OboStrategy` is unused at runtime — exported here so
|
||||
* its unit tests can construct it through DI and the future
|
||||
* integration only has to import this module.
|
||||
*
|
||||
* Imports `AuthModule` to consume `MSAL_CLIENT`, `RedisModule` for
|
||||
* the shared `ioredis` client.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AuthModule, RedisModule],
|
||||
providers: [
|
||||
{
|
||||
provide: OBO_CACHE_KEY,
|
||||
useFactory: () => assertOboCacheEncryptionKey(),
|
||||
},
|
||||
DownstreamTokenCache,
|
||||
OboStrategy,
|
||||
],
|
||||
exports: [OboStrategy, DownstreamTokenCache],
|
||||
})
|
||||
export class DownstreamModule {}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* DI token for the dedicated AES-256-GCM key the OBO downstream
|
||||
* token cache uses to encrypt stored entries. Provided by the
|
||||
* `DownstreamModule` factory from `assertOboCacheEncryptionKey()`
|
||||
* (which decodes + validates the env var at boot).
|
||||
*
|
||||
* Distinct from `SESSION_ENCRYPTION_KEY` per ADR-0014 §"Token cache
|
||||
* (for OBO)": "distinct from SESSION_ENCRYPTION_KEY (ADR-0010) so a
|
||||
* cache-key compromise does not cascade into session compromise".
|
||||
*/
|
||||
export const OBO_CACHE_KEY = 'OBO_CACHE_KEY';
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { AuthenticationResult, ConfidentialClientApplication } from '@azure/msal-node';
|
||||
import type { Logger } from 'nestjs-pino';
|
||||
import type { CachedToken, DownstreamTokenCache } from '../downstream-token-cache.service';
|
||||
import { OboAcquireError, OboStrategy } from './obo.strategy';
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
interface CacheStub {
|
||||
get: jest.Mock;
|
||||
set: jest.Mock;
|
||||
}
|
||||
|
||||
function makeCache(getResult: CachedToken | null = null): CacheStub {
|
||||
return {
|
||||
get: jest.fn().mockResolvedValue(getResult),
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function makeMsal(opts?: { acquireTokenOnBehalfOf?: jest.Mock }): {
|
||||
msal: ConfidentialClientApplication;
|
||||
acquire: jest.Mock;
|
||||
} {
|
||||
const acquire =
|
||||
opts?.acquireTokenOnBehalfOf ??
|
||||
jest.fn().mockResolvedValue({
|
||||
accessToken: 'fresh-downstream-token',
|
||||
expiresOn: new Date(Date.now() + 3_600_000),
|
||||
} as AuthenticationResult);
|
||||
const msal = { acquireTokenOnBehalfOf: acquire } as unknown as ConfidentialClientApplication;
|
||||
return { msal, acquire };
|
||||
}
|
||||
|
||||
function makeStrategy(opts: { cache?: CacheStub; msal?: ConfidentialClientApplication }): {
|
||||
strategy: OboStrategy;
|
||||
cache: CacheStub;
|
||||
logger: ReturnType<typeof makeLogger>;
|
||||
} {
|
||||
const cache = opts.cache ?? makeCache();
|
||||
const logger = makeLogger();
|
||||
const msal = opts.msal ?? makeMsal().msal;
|
||||
const strategy = new OboStrategy(msal, cache as unknown as DownstreamTokenCache, logger);
|
||||
return { strategy, cache, logger };
|
||||
}
|
||||
|
||||
const INPUT = {
|
||||
userAccessToken: 'user-access-token',
|
||||
actorIdHash: 'hash(jane)',
|
||||
resource: 'api://downstream-svc',
|
||||
scopes: ['api://downstream-svc/.default'],
|
||||
};
|
||||
|
||||
describe('OboStrategy.acquire', () => {
|
||||
it('returns the cached token verbatim on a fresh cache hit (no MSAL round-trip)', async () => {
|
||||
const cached: CachedToken = {
|
||||
accessToken: 'cached-downstream-token',
|
||||
expiresAt: Date.now() + 600_000,
|
||||
};
|
||||
const cache = makeCache(cached);
|
||||
const { acquire } = makeMsal();
|
||||
const { strategy } = makeStrategy({
|
||||
cache,
|
||||
msal: { acquireTokenOnBehalfOf: acquire } as never,
|
||||
});
|
||||
|
||||
await expect(strategy.acquire(INPUT)).resolves.toEqual(cached);
|
||||
expect(cache.get).toHaveBeenCalledWith({
|
||||
actorIdHash: 'hash(jane)',
|
||||
resource: 'api://downstream-svc',
|
||||
});
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect(cache.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-acquires from MSAL when the cached token is inside the 60 s safety buffer', async () => {
|
||||
const cached: CachedToken = {
|
||||
accessToken: 'stale-cached-token',
|
||||
expiresAt: Date.now() + 30_000, // 30s < 60s buffer
|
||||
};
|
||||
const cache = makeCache(cached);
|
||||
const { msal, acquire } = makeMsal();
|
||||
const { strategy } = makeStrategy({ cache, msal });
|
||||
|
||||
const result = await strategy.acquire(INPUT);
|
||||
|
||||
expect(acquire).toHaveBeenCalledTimes(1);
|
||||
expect(result.accessToken).toBe('fresh-downstream-token');
|
||||
expect(cache.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ actorIdHash: 'hash(jane)', resource: 'api://downstream-svc' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls MSAL with the OBO assertion + scopes on a cold cache, then writes the result', async () => {
|
||||
const expiry = new Date(Date.now() + 3_600_000);
|
||||
const { msal, acquire } = makeMsal({
|
||||
acquireTokenOnBehalfOf: jest.fn().mockResolvedValue({
|
||||
accessToken: 'fresh-downstream-token',
|
||||
expiresOn: expiry,
|
||||
} as AuthenticationResult),
|
||||
});
|
||||
const cache = makeCache(null);
|
||||
const { strategy } = makeStrategy({ cache, msal });
|
||||
|
||||
const result = await strategy.acquire(INPUT);
|
||||
|
||||
expect(acquire).toHaveBeenCalledWith({
|
||||
oboAssertion: 'user-access-token',
|
||||
scopes: ['api://downstream-svc/.default'],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
accessToken: 'fresh-downstream-token',
|
||||
expiresAt: expiry.getTime(),
|
||||
});
|
||||
expect(cache.set).toHaveBeenCalledWith({
|
||||
actorIdHash: 'hash(jane)',
|
||||
resource: 'api://downstream-svc',
|
||||
token: { accessToken: 'fresh-downstream-token', expiresAt: expiry.getTime() },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws OboAcquireError(msal-refused) when MSAL throws', async () => {
|
||||
const error = new Error('AADSTS50013: oboAssertion is no longer valid');
|
||||
const { msal } = makeMsal({
|
||||
acquireTokenOnBehalfOf: jest.fn().mockRejectedValue(error),
|
||||
});
|
||||
const cache = makeCache(null);
|
||||
const { strategy, logger } = makeStrategy({ cache, msal });
|
||||
|
||||
await expect(strategy.acquire(INPUT)).rejects.toMatchObject({
|
||||
name: 'OboAcquireError',
|
||||
reason: 'msal-refused',
|
||||
cause: error,
|
||||
});
|
||||
expect(cache.set).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'downstream.obo.msal_refused' }),
|
||||
'OboStrategy',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws OboAcquireError(msal-no-result) when MSAL returns null', async () => {
|
||||
const { msal } = makeMsal({
|
||||
acquireTokenOnBehalfOf: jest.fn().mockResolvedValue(null),
|
||||
});
|
||||
const cache = makeCache(null);
|
||||
const { strategy } = makeStrategy({ cache, msal });
|
||||
|
||||
await expect(strategy.acquire(INPUT)).rejects.toMatchObject({
|
||||
name: 'OboAcquireError',
|
||||
reason: 'msal-no-result',
|
||||
});
|
||||
expect(cache.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws OboAcquireError(msal-no-result) when MSAL returns an empty accessToken', async () => {
|
||||
const { msal } = makeMsal({
|
||||
acquireTokenOnBehalfOf: jest.fn().mockResolvedValue({
|
||||
accessToken: '',
|
||||
expiresOn: new Date(Date.now() + 3_600_000),
|
||||
} as AuthenticationResult),
|
||||
});
|
||||
const { strategy } = makeStrategy({ msal });
|
||||
await expect(strategy.acquire(INPUT)).rejects.toMatchObject({ reason: 'msal-no-result' });
|
||||
});
|
||||
|
||||
it('throws OboAcquireError(msal-no-result) when MSAL returns a null expiresOn', async () => {
|
||||
const { msal } = makeMsal({
|
||||
acquireTokenOnBehalfOf: jest.fn().mockResolvedValue({
|
||||
accessToken: 'fresh',
|
||||
expiresOn: null,
|
||||
} as unknown as AuthenticationResult),
|
||||
});
|
||||
const { strategy } = makeStrategy({ msal });
|
||||
await expect(strategy.acquire(INPUT)).rejects.toMatchObject({ reason: 'msal-no-result' });
|
||||
});
|
||||
|
||||
it('treats the cache lookup as the *only* hit signal — empty cached resource string still bypasses MSAL when fresh', async () => {
|
||||
// Edge case: an empty `resource` parameter is technically legal
|
||||
// for the strategy's contract (the cache key is just the
|
||||
// concatenated string). The cache returns a fresh entry, and
|
||||
// MSAL must NOT be called. Pins the contract: the strategy
|
||||
// never second-guesses what the framework asks for.
|
||||
const fresh: CachedToken = {
|
||||
accessToken: 'cached',
|
||||
expiresAt: Date.now() + 600_000,
|
||||
};
|
||||
const cache = makeCache(fresh);
|
||||
const { msal, acquire } = makeMsal();
|
||||
const { strategy } = makeStrategy({ cache, msal });
|
||||
await strategy.acquire({ ...INPUT, resource: '' });
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OboAcquireError', () => {
|
||||
it('carries the reason discriminator + the original cause for the future audit emission', () => {
|
||||
const cause = new Error('underlying');
|
||||
const err = new OboAcquireError('msal-refused', cause);
|
||||
expect(err.reason).toBe('msal-refused');
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.name).toBe('OboAcquireError');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { ConfidentialClientApplication, type AuthenticationResult } from '@azure/msal-node';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { MSAL_CLIENT } from '../../auth/msal-client.token';
|
||||
import { DownstreamTokenCache, type CachedToken } from '../downstream-token-cache.service';
|
||||
|
||||
/**
|
||||
* Caller-supplied input. The user's Entra access token is passed in
|
||||
* by the future framework integration — when the first concrete
|
||||
* downstream consumer ships, the framework will fetch it from the
|
||||
* session (per ADR-0014 §"OBO strategy") and forward here. Keeping
|
||||
* the strategy parameter-driven for now means it stays a testable
|
||||
* primitive without coupling to CLS / session shape.
|
||||
*
|
||||
* `actorIdHash` is the salted user-id hash from the audit module —
|
||||
* used as the cache namespace so two different users acquiring the
|
||||
* same downstream-scoped token never collide.
|
||||
*
|
||||
* `resource` is the downstream's stable identifier (typically the
|
||||
* Entra app-id URI or a custom scope namespace); it is the cache
|
||||
* key's resource segment so a user acquiring tokens for multiple
|
||||
* downstreams has one cache entry per downstream.
|
||||
*
|
||||
* `scopes` is the scope array MSAL forwards to Entra's OBO endpoint.
|
||||
*/
|
||||
export interface OboAcquireInput {
|
||||
readonly userAccessToken: string;
|
||||
readonly actorIdHash: string;
|
||||
readonly resource: string;
|
||||
readonly scopes: readonly string[];
|
||||
}
|
||||
|
||||
export class OboAcquireError extends Error {
|
||||
/**
|
||||
* Discriminator forwarded to the framework's audit-event emission.
|
||||
* `'cache-miss'` is reserved for the path where MSAL itself
|
||||
* refused (network, refusal, expired user assertion) — a true
|
||||
* cache miss alone is not an error.
|
||||
*/
|
||||
readonly reason: 'msal-refused' | 'msal-no-result';
|
||||
override readonly cause: unknown;
|
||||
|
||||
constructor(reason: 'msal-refused' | 'msal-no-result', cause: unknown) {
|
||||
super(`OBO token acquisition failed (${reason})`);
|
||||
this.name = 'OboAcquireError';
|
||||
this.reason = reason;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On-Behalf-Of strategy per ADR-0014 §"OBO strategy (Entra-protected
|
||||
* downstreams)". Wraps MSAL Node's `acquireTokenOnBehalfOf` with
|
||||
* the encrypted-at-rest Redis cache from
|
||||
* {@link DownstreamTokenCache}.
|
||||
*
|
||||
* Flow on each `acquire()` call:
|
||||
*
|
||||
* 1. Cache lookup. If the entry exists and isn't past its safety
|
||||
* buffer, return it verbatim — saves an Entra round-trip.
|
||||
* 2. Cache miss → call MSAL `acquireTokenOnBehalfOf` with the
|
||||
* user's access token as the OBO assertion. MSAL handles the
|
||||
* Entra round-trip + token validation.
|
||||
* 3. Cache the result with TTL = expiry − 60 s (per ADR-0014).
|
||||
* 4. Return the token to the caller.
|
||||
*
|
||||
* Failures (MSAL refusal, MSAL null result) throw {@link OboAcquireError}.
|
||||
* The future framework integration will translate this to a 502 +
|
||||
* `auth.token.validation.failed` audit event (ADR-0013) at the call
|
||||
* site — per ADR-0014 the BFF does NOT silently fall back to the
|
||||
* user's original token.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OboStrategy {
|
||||
constructor(
|
||||
@Inject(MSAL_CLIENT) private readonly msal: ConfidentialClientApplication,
|
||||
private readonly cache: DownstreamTokenCache,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async acquire(input: OboAcquireInput): Promise<CachedToken> {
|
||||
const cached = await this.cache.get({
|
||||
actorIdHash: input.actorIdHash,
|
||||
resource: input.resource,
|
||||
});
|
||||
if (cached !== null && isFreshEnough(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let result: AuthenticationResult | null;
|
||||
try {
|
||||
result = await this.msal.acquireTokenOnBehalfOf({
|
||||
oboAssertion: input.userAccessToken,
|
||||
scopes: [...input.scopes],
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
{
|
||||
event: 'downstream.obo.msal_refused',
|
||||
resource: input.resource,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
'OboStrategy',
|
||||
);
|
||||
throw new OboAcquireError('msal-refused', err);
|
||||
}
|
||||
|
||||
if (result === null || result.accessToken === '' || result.expiresOn === null) {
|
||||
this.logger.warn(
|
||||
{
|
||||
event: 'downstream.obo.msal_no_result',
|
||||
resource: input.resource,
|
||||
hasResult: result !== null,
|
||||
hasExpiry: result?.expiresOn !== null,
|
||||
},
|
||||
'OboStrategy',
|
||||
);
|
||||
throw new OboAcquireError('msal-no-result', null);
|
||||
}
|
||||
|
||||
// MSAL's `expiresOn` is a `Date | null`. We just checked it
|
||||
// wasn't null above, so `.getTime()` is safe.
|
||||
const token: CachedToken = {
|
||||
accessToken: result.accessToken,
|
||||
expiresAt: result.expiresOn.getTime(),
|
||||
};
|
||||
|
||||
await this.cache.set({
|
||||
actorIdHash: input.actorIdHash,
|
||||
resource: input.resource,
|
||||
token,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-strategy freshness check that mirrors the cache's write-side
|
||||
* buffer (60 s). Two readers checking the same buffer means a token
|
||||
* that survives Redis's TTL but slipped inside the buffer between
|
||||
* write and read still gets re-acquired. Redundant under steady
|
||||
* state; load-bearing during clock skew or extreme contention.
|
||||
*/
|
||||
function isFreshEnough(token: CachedToken): boolean {
|
||||
return token.expiresAt - Date.now() > 60_000;
|
||||
}
|
||||
Reference in New Issue
Block a user