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