feat(portal-bff): obo strategy + encrypted downstream token cache
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.<service>.<verb>.<path>`.
- 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.
This commit is contained in:
@@ -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'))"
|
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
||||||
SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_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
|
# Session timeouts (per ADR-0010). Both optional with sensible
|
||||||
# defaults; override only when staging / prod policy diverges.
|
# defaults; override only when staging / prod policy diverges.
|
||||||
# SESSION_IDLE_TIMEOUT_SECONDS — sliding window. Each request
|
# 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)
|
# AUDIT_RETENTION_DAYS (default 365)
|
||||||
#
|
#
|
||||||
# Downstream API access (ADR-0014):
|
# Downstream API access (ADR-0014):
|
||||||
# OBO_CACHE_ENCRYPTION_KEY (32-byte base64, distinct from SESSION_ENCRYPTION_KEY)
|
# OBO_CACHE_ENCRYPTION_KEY — wired (see above, line 113)
|
||||||
# BFF_JWKS_PRIVATE_KEY_PATH
|
# BFF_JWKS_PRIVATE_KEY_PATH (next PR: signed-assertion strategy)
|
||||||
# BFF_JWKS_KID
|
# BFF_JWKS_KID (next PR: signed-assertion strategy)
|
||||||
# <SERVICE>_API_BASE_URL (per integrated downstream)
|
# <SERVICE>_API_BASE_URL (per integrated downstream)
|
||||||
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000)
|
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { HealthModule } from '../health/health.module';
|
|||||||
import { AdminModule } from '../admin/admin.module';
|
import { AdminModule } from '../admin/admin.module';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { DownstreamModule } from '../downstream/downstream.module';
|
||||||
import { RedisModule } from '../redis/redis.module';
|
import { RedisModule } from '../redis/redis.module';
|
||||||
import { SecurityModule } from '../security/security.module';
|
import { SecurityModule } from '../security/security.module';
|
||||||
import { SessionModule } from '../session/session.module';
|
import { SessionModule } from '../session/session.module';
|
||||||
@@ -22,6 +23,7 @@ import { SessionModule } from '../session/session.module';
|
|||||||
SecurityModule,
|
SecurityModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
DownstreamModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { assertDatabaseUrl } from './config/check-database-url';
|
|||||||
import { assertEntraConfig } from './config/check-entra-config';
|
import { assertEntraConfig } from './config/check-entra-config';
|
||||||
import { assertRedisConfig } from './config/check-redis-config';
|
import { assertRedisConfig } from './config/check-redis-config';
|
||||||
import { assertLogUserIdSalt } from './config/check-log-user-id-salt';
|
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 { assertSessionEncryptionKey } from './config/check-session-encryption-key';
|
||||||
import { assertSessionSecret } from './config/check-session-secret';
|
import { assertSessionSecret } from './config/check-session-secret';
|
||||||
import { createRateLimitMiddleware, readRateLimitConfig } from './security/rate-limit.middleware';
|
import { createRateLimitMiddleware, readRateLimitConfig } from './security/rate-limit.middleware';
|
||||||
@@ -57,6 +58,12 @@ assertSessionEncryptionKey();
|
|||||||
// (ADR-0012). Mandatory at boot.
|
// (ADR-0012). Mandatory at boot.
|
||||||
assertLogUserIdSalt();
|
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() {
|
async function bootstrap() {
|
||||||
// `bufferLogs: true` holds early-bootstrap log lines until the
|
// `bufferLogs: true` holds early-bootstrap log lines until the
|
||||||
// Pino-based Logger is wired in below, so we don't lose anything
|
// Pino-based Logger is wired in below, so we don't lose anything
|
||||||
|
|||||||
Reference in New Issue
Block a user