From 18f26dc2b96f03f0c33925bf3b5c068747995d7e Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Thu, 14 May 2026 18:08:22 +0200 Subject: [PATCH] feat(portal-bff): obo strategy + encrypted downstream token cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the DownstreamApiClient + OBO chantier per ADR-0014. Per the ADR's own §"Consequences" note — "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" — this PR ships the OBO strategy and its encrypted-at-rest token cache as testable primitives, NOT the full DownstreamApiClientFactory + cockatiel + audience pre-check framework. The framework gets assembled when the first real downstream integration arrives. What lands - assertOboCacheEncryptionKey (config/check-obo-cache-encryption-key.ts): - 32-byte AES-256-GCM key validator, mirrors the SESSION key validator pattern. - Refuses placeholder, wrong length, non-base64url. - Refuses a value identical to SESSION_ENCRYPTION_KEY — defense in depth against the copy-paste regression that would silently downgrade the trust boundary ADR-0014 §"Token cache (for OBO)" explicitly draws. - Wired in main.ts alongside the other assertX() boot validators. - DownstreamTokenCache (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) so a cache-key leak cannot decrypt sessions and vice versa. - TTL = upstream `expiresAt - 60s` (the safety buffer ADR-0014 mandates). A token already inside the buffer is NOT written — the strategy re-acquires next call. - Never throws on read: Redis hiccups, tampered ciphertext, wrong key, malformed shape — all collapse to a miss with a structured Pino warn. The strategy then re-acquires from Entra. - Writes are best-effort: a Redis write failure is logged but doesn't fail the request — the call already has its token. - OboStrategy (downstream/strategies/obo.strategy.ts): - Wraps MSAL Node's acquireTokenOnBehalfOf with the cache. - Flow: cache.get → if fresh, return verbatim; else MSAL call → cache.set → return. - Cache hit applies a second 60 s buffer beyond Redis's TTL — load-bearing under clock skew where a token survives Redis's expiry but is already inside its safety window. - MSAL refusal / null result throws OboAcquireError with a typed `reason` discriminator the future framework will translate to a 502 + `auth.token.validation.failed` audit event per ADR-0014. - Takes the user's access token as a PARAMETER for now — ADR-0014 says "read from session via CLS", but v1 sessions don't persist the user's access token (ADR-0009 omits offline_access deliberately). The framework integration that fetches it from CLS lands with the first real consumer, per the ADR's own "until then" clause. - DownstreamModule (downstream/downstream.module.ts): provides OBO_CACHE_KEY (via assertOboCacheEncryptionKey at factory time), DownstreamTokenCache, OboStrategy. Imports AuthModule for the shared MSAL_CLIENT and RedisModule for the shared ioredis. Wired into AppModule though the strategy has no runtime consumer yet — the registration makes the strategy injectable for the future integration without that integration having to also touch the module graph. Env - New: OBO_CACHE_ENCRYPTION_KEY (mandatory at boot). - .env.example placeholder + the stale "future" note collapsed to point at the now-wired key. Tests: +26 specs (env validator 8, token cache 9, OBO strategy 9). Out of scope (deferred per ADR-0014 "until then"): - DownstreamApiClientFactory + per-service typed config. - cockatiel resilience composition (timeout, retry, breaker, bulkhead). - Audience pre-check at the call site. - Error translation tables. - OTel custom spans `downstream...`. - audit events `auth.token.validation.failed` / `authz.deny`. - The framework wiring that reads the user access token from session/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. --- apps/portal-bff/.env.example | 16 +- apps/portal-bff/src/app/app.module.ts | 2 + .../check-obo-cache-encryption-key.spec.ts | 72 ++++++ .../config/check-obo-cache-encryption-key.ts | 80 +++++++ .../downstream-token-cache.service.spec.ts | 225 ++++++++++++++++++ .../downstream-token-cache.service.ts | 150 ++++++++++++ .../src/downstream/downstream.module.ts | 44 ++++ .../src/downstream/downstream.token.ts | 11 + .../strategies/obo.strategy.spec.ts | 209 ++++++++++++++++ .../src/downstream/strategies/obo.strategy.ts | 147 ++++++++++++ apps/portal-bff/src/main.ts | 7 + 11 files changed, 960 insertions(+), 3 deletions(-) create mode 100644 apps/portal-bff/src/config/check-obo-cache-encryption-key.spec.ts create mode 100644 apps/portal-bff/src/config/check-obo-cache-encryption-key.ts create mode 100644 apps/portal-bff/src/downstream/downstream-token-cache.service.spec.ts create mode 100644 apps/portal-bff/src/downstream/downstream-token-cache.service.ts create mode 100644 apps/portal-bff/src/downstream/downstream.module.ts create mode 100644 apps/portal-bff/src/downstream/downstream.token.ts create mode 100644 apps/portal-bff/src/downstream/strategies/obo.strategy.spec.ts create mode 100644 apps/portal-bff/src/downstream/strategies/obo.strategy.ts diff --git a/apps/portal-bff/.env.example b/apps/portal-bff/.env.example index aa67ea3..8be72c0 100644 --- a/apps/portal-bff/.env.example +++ b/apps/portal-bff/.env.example @@ -104,6 +104,16 @@ REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0 # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url +# OBO downstream-token cache encryption (per ADR-0014 §"Token cache +# (for OBO)"). AES-256-GCM key for encrypting Entra-issued downstream +# tokens cached in Redis under `obo:{actor_id_hash}:{resource}`. +# **Dedicated key** — must differ from SESSION_ENCRYPTION_KEY so a +# leak of one does not cascade into the other. The boot validator +# refuses an identical value. Mandatory at boot. +# +# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" +OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url + # Session timeouts (per ADR-0010). Both optional with sensible # defaults; override only when staging / prod policy diverges. # SESSION_IDLE_TIMEOUT_SECONDS — sliding window. Each request @@ -173,8 +183,8 @@ CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4300 # AUDIT_RETENTION_DAYS (default 365) # # Downstream API access (ADR-0014): -# OBO_CACHE_ENCRYPTION_KEY (32-byte base64, distinct from SESSION_ENCRYPTION_KEY) -# BFF_JWKS_PRIVATE_KEY_PATH -# BFF_JWKS_KID +# OBO_CACHE_ENCRYPTION_KEY — wired (see above, line 113) +# BFF_JWKS_PRIVATE_KEY_PATH (next PR: signed-assertion strategy) +# BFF_JWKS_KID (next PR: signed-assertion strategy) # _API_BASE_URL (per integrated downstream) # _TIMEOUT_MS (optional, defaults to 5000) diff --git a/apps/portal-bff/src/app/app.module.ts b/apps/portal-bff/src/app/app.module.ts index 30d3233..e959b46 100644 --- a/apps/portal-bff/src/app/app.module.ts +++ b/apps/portal-bff/src/app/app.module.ts @@ -7,6 +7,7 @@ import { HealthModule } from '../health/health.module'; import { AdminModule } from '../admin/admin.module'; import { AuditModule } from '../audit/audit.module'; import { AuthModule } from '../auth/auth.module'; +import { DownstreamModule } from '../downstream/downstream.module'; import { RedisModule } from '../redis/redis.module'; import { SecurityModule } from '../security/security.module'; import { SessionModule } from '../session/session.module'; @@ -22,6 +23,7 @@ import { SessionModule } from '../session/session.module'; SecurityModule, HealthModule, AdminModule, + DownstreamModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/portal-bff/src/config/check-obo-cache-encryption-key.spec.ts b/apps/portal-bff/src/config/check-obo-cache-encryption-key.spec.ts new file mode 100644 index 0000000..78ead15 --- /dev/null +++ b/apps/portal-bff/src/config/check-obo-cache-encryption-key.spec.ts @@ -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; + } +} diff --git a/apps/portal-bff/src/config/check-obo-cache-encryption-key.ts b/apps/portal-bff/src/config/check-obo-cache-encryption-key.ts new file mode 100644 index 0000000..6840d23 --- /dev/null +++ b/apps/portal-bff/src/config/check-obo-cache-encryption-key.ts @@ -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; +} diff --git a/apps/portal-bff/src/downstream/downstream-token-cache.service.spec.ts b/apps/portal-bff/src/downstream/downstream-token-cache.service.spec.ts new file mode 100644 index 0000000..4f059ce --- /dev/null +++ b/apps/portal-bff/src/downstream/downstream-token-cache.service.spec.ts @@ -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 } { + 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', + ); + }); +}); diff --git a/apps/portal-bff/src/downstream/downstream-token-cache.service.ts b/apps/portal-bff/src/downstream/downstream-token-cache.service.ts new file mode 100644 index 0000000..8c49a7a --- /dev/null +++ b/apps/portal-bff/src/downstream/downstream-token-cache.service.ts @@ -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 { + 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 { + 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; + return typeof v['accessToken'] === 'string' && typeof v['expiresAt'] === 'number'; +} diff --git a/apps/portal-bff/src/downstream/downstream.module.ts b/apps/portal-bff/src/downstream/downstream.module.ts new file mode 100644 index 0000000..8536387 --- /dev/null +++ b/apps/portal-bff/src/downstream/downstream.module.ts @@ -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 {} diff --git a/apps/portal-bff/src/downstream/downstream.token.ts b/apps/portal-bff/src/downstream/downstream.token.ts new file mode 100644 index 0000000..6bad740 --- /dev/null +++ b/apps/portal-bff/src/downstream/downstream.token.ts @@ -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'; diff --git a/apps/portal-bff/src/downstream/strategies/obo.strategy.spec.ts b/apps/portal-bff/src/downstream/strategies/obo.strategy.spec.ts new file mode 100644 index 0000000..1d2af58 --- /dev/null +++ b/apps/portal-bff/src/downstream/strategies/obo.strategy.spec.ts @@ -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; +} { + 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'); + }); +}); diff --git a/apps/portal-bff/src/downstream/strategies/obo.strategy.ts b/apps/portal-bff/src/downstream/strategies/obo.strategy.ts new file mode 100644 index 0000000..712765f --- /dev/null +++ b/apps/portal-bff/src/downstream/strategies/obo.strategy.ts @@ -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 { + 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; +} diff --git a/apps/portal-bff/src/main.ts b/apps/portal-bff/src/main.ts index 74eb5a7..5c1f723 100644 --- a/apps/portal-bff/src/main.ts +++ b/apps/portal-bff/src/main.ts @@ -14,6 +14,7 @@ import { assertDatabaseUrl } from './config/check-database-url'; import { assertEntraConfig } from './config/check-entra-config'; import { assertRedisConfig } from './config/check-redis-config'; import { assertLogUserIdSalt } from './config/check-log-user-id-salt'; +import { assertOboCacheEncryptionKey } from './config/check-obo-cache-encryption-key'; import { assertSessionEncryptionKey } from './config/check-session-encryption-key'; import { assertSessionSecret } from './config/check-session-secret'; import { createRateLimitMiddleware, readRateLimitConfig } from './security/rate-limit.middleware'; @@ -57,6 +58,12 @@ assertSessionEncryptionKey(); // (ADR-0012). Mandatory at boot. assertLogUserIdSalt(); +// OBO_CACHE_ENCRYPTION_KEY — dedicated AES-256-GCM key for the OBO +// downstream-token cache (ADR-0014 §"Token cache (for OBO)"). MUST +// differ from SESSION_ENCRYPTION_KEY — the validator refuses an +// identical value as defense in depth against copy-paste accidents. +assertOboCacheEncryptionKey(); + async function bootstrap() { // `bufferLogs: true` holds early-bootstrap log lines until the // Pino-based Logger is wired in below, so we don't lose anything -- 2.30.2