diff --git a/apps/portal-bff/.env.example b/apps/portal-bff/.env.example index 8be72c0..b51761b 100644 --- a/apps/portal-bff/.env.example +++ b/apps/portal-bff/.env.example @@ -114,6 +114,24 @@ SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url +# BFF JWKS signing material (per ADR-0014 §"Service strategy"). The +# BFF mints short-lived `X-User-Assertion` JWTs to propagate user +# identity to non-Entra downstreams; downstreams verify the signature +# against `/.well-known/jwks.json`. Both values are mandatory at boot. +# +# Generate an RSA private key: +# mkdir -p apps/portal-bff/.secrets && \ +# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \ +# -out apps/portal-bff/.secrets/jwks.pem +# +# RSA-2048 is the minimum the validator accepts; 3072 is the +# default recommendation. EC P-256 / P-384 also accepted. +BFF_JWKS_PRIVATE_KEY_PATH=apps/portal-bff/.secrets/jwks.pem +# Stable key id published in the JWKS + emitted in the JWT `kid` +# header. URL-safe charset only ([A-Za-z0-9_-], 4–128 chars). Bump +# this when rotating to a new key. +BFF_JWKS_KID=bff-2026-05 + # 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 @@ -183,8 +201,8 @@ CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4300 # AUDIT_RETENTION_DAYS (default 365) # # Downstream API access (ADR-0014): -# 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) +# OBO_CACHE_ENCRYPTION_KEY — wired +# BFF_JWKS_PRIVATE_KEY_PATH — wired +# BFF_JWKS_KID — wired +# _API_BASE_URL (per integrated downstream — lands with the first integration) +# _TIMEOUT_MS (optional, defaults to 5000 — lands with the first integration) diff --git a/apps/portal-bff/jest.config.cts b/apps/portal-bff/jest.config.cts index f7c2531..a810b06 100644 --- a/apps/portal-bff/jest.config.cts +++ b/apps/portal-bff/jest.config.cts @@ -6,5 +6,16 @@ module.exports = { '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], }, moduleFileExtensions: ['ts', 'js', 'html'], + // `jose` ships ESM-only; without an explicit transform exception + // ts-jest skips node_modules and Jest fails parsing the import + // statements. Listed by name rather than a broad pattern so a + // future ESM-only dep is a conscious addition. + // + // The pattern walks pnpm's deep layout: any path under + // `/node_modules/` is ignored UNLESS the path also contains + // `jose` somewhere — that catches both the hoisted symlink at + // `node_modules/jose/...` and the pnpm-internal real path at + // `node_modules/.pnpm/jose@/node_modules/jose/...`. + transformIgnorePatterns: ['/node_modules/(?!.*jose)'], coverageDirectory: '../../coverage/apps/portal-bff', }; diff --git a/apps/portal-bff/src/config/check-jwks-config.spec.ts b/apps/portal-bff/src/config/check-jwks-config.spec.ts new file mode 100644 index 0000000..5766ce2 --- /dev/null +++ b/apps/portal-bff/src/config/check-jwks-config.spec.ts @@ -0,0 +1,135 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { generateKeyPairSync } from 'node:crypto'; +import { assertJwksConfig } from './check-jwks-config'; + +const tmpDir = mkdtempSync(join(tmpdir(), 'apf-jwks-spec-')); + +function writeKey( + filename: string, + type: 'rsa-3072' | 'rsa-1024' | 'ec-p256' | 'ec-p384' | 'ed25519' | 'garbage', +): string { + const path = join(tmpDir, filename); + let pem: string; + switch (type) { + case 'rsa-3072': + pem = generateKeyPairSync('rsa', { modulusLength: 3072 }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; + break; + case 'rsa-1024': + pem = generateKeyPairSync('rsa', { modulusLength: 1024 }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; + break; + case 'ec-p256': + pem = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; + break; + case 'ec-p384': + pem = generateKeyPairSync('ec', { namedCurve: 'secp384r1' }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; + break; + case 'ed25519': + pem = generateKeyPairSync('ed25519').privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; + break; + case 'garbage': + pem = '-----BEGIN PRIVATE KEY-----\nbm90LWEta2V5\n-----END PRIVATE KEY-----\n'; + break; + } + writeFileSync(path, pem); + return path; +} + +describe('assertJwksConfig', () => { + const originalPath = process.env['BFF_JWKS_PRIVATE_KEY_PATH']; + const originalKid = process.env['BFF_JWKS_KID']; + + beforeEach(() => { + process.env['BFF_JWKS_KID'] = 'bff-2026-05'; + }); + + afterEach(() => { + restore('BFF_JWKS_PRIVATE_KEY_PATH', originalPath); + restore('BFF_JWKS_KID', originalKid); + }); + + it('returns the parsed key + kid + alg=RS256 for a well-formed RSA-3072 key', () => { + process.env['BFF_JWKS_PRIVATE_KEY_PATH'] = writeKey('rsa.pem', 'rsa-3072'); + const config = assertJwksConfig(); + expect(config.kid).toBe('bff-2026-05'); + expect(config.alg).toBe('RS256'); + expect(config.privateKey.asymmetricKeyType).toBe('rsa'); + }); + + it('returns alg=ES256 for an EC P-256 key', () => { + process.env['BFF_JWKS_PRIVATE_KEY_PATH'] = writeKey('ec256.pem', 'ec-p256'); + expect(assertJwksConfig().alg).toBe('ES256'); + }); + + it('returns alg=ES384 for an EC P-384 key', () => { + process.env['BFF_JWKS_PRIVATE_KEY_PATH'] = writeKey('ec384.pem', 'ec-p384'); + expect(assertJwksConfig().alg).toBe('ES384'); + }); + + it('throws when BFF_JWKS_PRIVATE_KEY_PATH is unset', () => { + delete process.env['BFF_JWKS_PRIVATE_KEY_PATH']; + expect(() => assertJwksConfig()).toThrow(/BFF_JWKS_PRIVATE_KEY_PATH is not set/); + }); + + it('throws when the file does not exist', () => { + process.env['BFF_JWKS_PRIVATE_KEY_PATH'] = join(tmpDir, 'missing.pem'); + expect(() => assertJwksConfig()).toThrow(/could not be read/); + }); + + it('throws when the file is not a valid PEM private key', () => { + process.env['BFF_JWKS_PRIVATE_KEY_PATH'] = writeKey('garbage.pem', 'garbage'); + expect(() => assertJwksConfig()).toThrow(/not a valid PEM private key/); + }); + + it('refuses RSA keys weaker than 2048 bits', () => { + process.env['BFF_JWKS_PRIVATE_KEY_PATH'] = writeKey('weak.pem', 'rsa-1024'); + expect(() => assertJwksConfig()).toThrow(/unsupported key type/); + }); + + it('refuses Ed25519 keys (v1 supports RSA + EC P-256/P-384 only)', () => { + process.env['BFF_JWKS_PRIVATE_KEY_PATH'] = writeKey('ed25519.pem', 'ed25519'); + expect(() => assertJwksConfig()).toThrow(/unsupported key type/); + }); + + it('throws when BFF_JWKS_KID is unset', () => { + process.env['BFF_JWKS_PRIVATE_KEY_PATH'] = writeKey('rsa.pem', 'rsa-3072'); + delete process.env['BFF_JWKS_KID']; + expect(() => assertJwksConfig()).toThrow(/BFF_JWKS_KID is not set/); + }); + + it('throws when BFF_JWKS_KID has illegal characters', () => { + process.env['BFF_JWKS_PRIVATE_KEY_PATH'] = writeKey('rsa.pem', 'rsa-3072'); + process.env['BFF_JWKS_KID'] = 'has spaces and !'; + expect(() => assertJwksConfig()).toThrow(/must match/); + }); + + it('throws when BFF_JWKS_KID is too short', () => { + process.env['BFF_JWKS_PRIVATE_KEY_PATH'] = writeKey('rsa.pem', 'rsa-3072'); + process.env['BFF_JWKS_KID'] = 'ab'; + expect(() => assertJwksConfig()).toThrow(/must match/); + }); +}); + +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-jwks-config.ts b/apps/portal-bff/src/config/check-jwks-config.ts new file mode 100644 index 0000000..781338b --- /dev/null +++ b/apps/portal-bff/src/config/check-jwks-config.ts @@ -0,0 +1,119 @@ +import { readFileSync } from 'node:fs'; +import { createPrivateKey, type KeyObject } from 'node:crypto'; + +/** + * Boot-time validators for the BFF's JWKS publishing material, per + * [ADR-0014](../../../../docs/decisions/0014-downstream-api-access-obo-pattern.md) + * §"Service strategy (non-Entra downstreams)": + * + * "The downstream verifies the signature against the BFF's + * published JWKS at `/.well-known/jwks.json`" + * + * Two env vars together describe the BFF's signing identity: + * + * - `BFF_JWKS_PRIVATE_KEY_PATH` — filesystem path to a PEM-encoded + * private key (RSA-2048 or stronger, or EC P-256 / P-384). The + * file is read once at boot; the BFF refuses to start if it's + * missing, unreadable, or the key is weak. + * - `BFF_JWKS_KID` — stable identifier published in the JWKS and + * emitted in the JWT header so a downstream can pick the right + * key when rotation introduces a second entry. URL-safe charset + * (`[A-Za-z0-9_-]+`) so it lives unescaped in JWT headers. + * + * Returns the parsed `KeyObject` (private) + kid so the application + * layer never re-reads the file at request time — a perf wart given + * how often `X-User-Assertion` JWTs get minted on a busy downstream + * call site. + */ + +const KID_RE = /^[A-Za-z0-9_-]{4,128}$/; + +export interface JwksConfig { + /** PEM-parsed private key, ready for `jose.SignJWT` consumption. */ + readonly privateKey: KeyObject; + /** Stable identifier published in the JWKS + the JWT `kid` header. */ + readonly kid: string; + /** + * JOSE algorithm derived from the key type. RSA → RS256; + * EC → ES256 / ES384 depending on curve. Kept on the config so + * the strategy doesn't have to re-derive it on every sign call. + */ + readonly alg: 'RS256' | 'ES256' | 'ES384'; +} + +export function assertJwksConfig(): JwksConfig { + const path = process.env['BFF_JWKS_PRIVATE_KEY_PATH']; + if (path === undefined || path === '') { + throw new Error( + `BFF_JWKS_PRIVATE_KEY_PATH is not set. Generate a key with ` + + `"openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out apps/portal-bff/.secrets/jwks.pem" ` + + `and set BFF_JWKS_PRIVATE_KEY_PATH to its path. Required for the ADR-0014 ` + + `signed-assertion strategy.`, + ); + } + + let pem: string; + try { + pem = readFileSync(path, 'utf8'); + } catch (err) { + throw new Error( + `BFF_JWKS_PRIVATE_KEY_PATH could not be read at "${path}": ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + + let privateKey: KeyObject; + try { + privateKey = createPrivateKey(pem); + } catch (err) { + throw new Error( + `BFF_JWKS_PRIVATE_KEY_PATH ("${path}") is not a valid PEM private key: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + + const alg = deriveAlg(privateKey); + if (alg === null) { + throw new Error( + `BFF_JWKS_PRIVATE_KEY_PATH ("${path}") uses an unsupported key type ` + + `("${privateKey.asymmetricKeyType ?? 'unknown'}"). Use RSA-2048+ or EC ` + + `(P-256, P-384). Symmetric / Ed25519 keys are not accepted in v1.`, + ); + } + + const rawKid = process.env['BFF_JWKS_KID']; + if (rawKid === undefined || rawKid === '') { + throw new Error( + `BFF_JWKS_KID is not set. Pick a stable identifier for the key (per ADR-0014 ` + + `§"Configuration") — e.g. "bff-2026-05" — and put it in apps/portal-bff/.env. ` + + `URL-safe charset only ([A-Za-z0-9_-]).`, + ); + } + if (!KID_RE.test(rawKid)) { + throw new Error( + `BFF_JWKS_KID must match ${KID_RE.source} (URL-safe, 4–128 chars). Got: ${rawKid}`, + ); + } + + return { privateKey, kid: rawKid, alg }; +} + +function deriveAlg(key: KeyObject): JwksConfig['alg'] | null { + if (key.asymmetricKeyType === 'rsa') { + // RSA modulus length is in bits. `asymmetricKeyDetails.modulusLength` is + // the canonical accessor on Node 16+. + const modulus = key.asymmetricKeyDetails?.modulusLength; + if (modulus === undefined || modulus < 2048) { + // Weak key — refuse rather than silently sign with it. + return null; + } + return 'RS256'; + } + if (key.asymmetricKeyType === 'ec') { + const curve = key.asymmetricKeyDetails?.namedCurve; + if (curve === 'prime256v1' || curve === 'P-256') return 'ES256'; + if (curve === 'secp384r1' || curve === 'P-384') return 'ES384'; + return null; + } + return null; +} diff --git a/apps/portal-bff/src/downstream/bff-signing-key.spec.ts b/apps/portal-bff/src/downstream/bff-signing-key.spec.ts new file mode 100644 index 0000000..af93473 --- /dev/null +++ b/apps/portal-bff/src/downstream/bff-signing-key.spec.ts @@ -0,0 +1,60 @@ +import { createPrivateKey, generateKeyPairSync } from 'node:crypto'; +import { buildBffSigningKey } from './bff-signing-key'; + +function rsaKey() { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + return createPrivateKey(privateKey.export({ type: 'pkcs8', format: 'pem' })); +} + +function ecKey(curve: 'prime256v1' | 'secp384r1') { + const { privateKey } = generateKeyPairSync('ec', { namedCurve: curve }); + return createPrivateKey(privateKey.export({ type: 'pkcs8', format: 'pem' })); +} + +describe('buildBffSigningKey', () => { + it('produces a public JWK with kid + alg + use=sig from an RSA private key', async () => { + const key = await buildBffSigningKey({ + privateKey: rsaKey(), + kid: 'bff-2026-05', + alg: 'RS256', + }); + expect(key.publicJwk.kty).toBe('RSA'); + expect(key.publicJwk.kid).toBe('bff-2026-05'); + expect(key.publicJwk.alg).toBe('RS256'); + expect(key.publicJwk.use).toBe('sig'); + // No private-material leak — the public JWK must not carry `d`, `p`, `q`, `dp`, `dq`, `qi`. + expect(key.publicJwk.d).toBeUndefined(); + expect(key.publicJwk.p).toBeUndefined(); + expect(key.publicJwk.q).toBeUndefined(); + }); + + it('produces a public JWK with kty=EC + crv=P-256 for an EC P-256 key', async () => { + const key = await buildBffSigningKey({ + privateKey: ecKey('prime256v1'), + kid: 'bff-2026-05', + alg: 'ES256', + }); + expect(key.publicJwk.kty).toBe('EC'); + expect(key.publicJwk.crv).toBe('P-256'); + expect(key.publicJwk.alg).toBe('ES256'); + expect(key.publicJwk.d).toBeUndefined(); + }); + + it('produces a public JWK with crv=P-384 for an EC P-384 key', async () => { + const key = await buildBffSigningKey({ + privateKey: ecKey('secp384r1'), + kid: 'bff-2026-05', + alg: 'ES384', + }); + expect(key.publicJwk.crv).toBe('P-384'); + expect(key.publicJwk.alg).toBe('ES384'); + }); + + it('exposes the full original config on the returned BffSigningKey', async () => { + const privateKey = rsaKey(); + const built = await buildBffSigningKey({ privateKey, kid: 'rotated-kid', alg: 'RS256' }); + expect(built.config.privateKey).toBe(privateKey); + expect(built.config.kid).toBe('rotated-kid'); + expect(built.config.alg).toBe('RS256'); + }); +}); diff --git a/apps/portal-bff/src/downstream/bff-signing-key.ts b/apps/portal-bff/src/downstream/bff-signing-key.ts new file mode 100644 index 0000000..397dfdc --- /dev/null +++ b/apps/portal-bff/src/downstream/bff-signing-key.ts @@ -0,0 +1,44 @@ +import type { JWK } from 'jose'; +import { exportJWK } from 'jose'; +import { createPublicKey } from 'node:crypto'; +import type { JwksConfig } from '../config/check-jwks-config'; + +/** + * Resolved BFF signing identity — the parsed private key, the + * derived public JWK (ready to ship in `/.well-known/jwks.json`), + * the `kid`, and the JOSE algorithm. Computed once at boot from + * `assertJwksConfig()` so neither the strategy nor the JWKS + * controller has to re-derive on the hot path. + * + * The DI token {@link BFF_SIGNING_KEY} wires this into Nest's + * provider graph. Both the `SignedAssertionStrategy` (signing) and + * the JWKS controller (publishing) read from this same instance — + * one source of truth for the BFF's signing material. + */ +export interface BffSigningKey { + readonly config: JwksConfig; + /** Public JWK shape, derived from the private key. Includes `kid`, `alg`, `use=sig`. */ + readonly publicJwk: JWK; +} + +export const BFF_SIGNING_KEY = 'BFF_SIGNING_KEY'; + +/** + * Builds the {@link BffSigningKey} from the validated env config. + * Async because `jose.exportJWK` is async on Node — it operates on + * Web Crypto's KeyObject under the hood. + */ +export async function buildBffSigningKey(config: JwksConfig): Promise { + // `exportJWK` accepts either a public KeyObject or a CryptoKey. + // We have the private one — derive the public first so the + // exported JWK has no private material. + const publicKey = createPublicKey(config.privateKey); + const baseJwk = await exportJWK(publicKey); + const publicJwk: JWK = { + ...baseJwk, + kid: config.kid, + alg: config.alg, + use: 'sig', + }; + return { config, publicJwk }; +} diff --git a/apps/portal-bff/src/downstream/downstream.module.ts b/apps/portal-bff/src/downstream/downstream.module.ts index 8536387..b0382f0 100644 --- a/apps/portal-bff/src/downstream/downstream.module.ts +++ b/apps/portal-bff/src/downstream/downstream.module.ts @@ -1,44 +1,57 @@ import { Module } from '@nestjs/common'; +import { assertJwksConfig } from '../config/check-jwks-config'; import { assertOboCacheEncryptionKey } from '../config/check-obo-cache-encryption-key'; import { AuthModule } from '../auth/auth.module'; import { RedisModule } from '../redis/redis.module'; +import { BFF_SIGNING_KEY, buildBffSigningKey } from './bff-signing-key'; import { DownstreamTokenCache } from './downstream-token-cache.service'; import { OBO_CACHE_KEY } from './downstream.token'; +import { JwksController } from './jwks.controller'; import { OboStrategy } from './strategies/obo.strategy'; +import { SignedAssertionStrategy } from './strategies/signed-assertion.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: + * **Scope (v1).** This module ships the auth-strategy primitives, + * the OBO token cache, and the BFF's JWKS publishing endpoint. The + * framework around them (`DownstreamApiClientFactory`, cockatiel + * resilience stack, audience pre-check, error translation, 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. + * What's exposed for the future integration to consume: * - * Imports `AuthModule` to consume `MSAL_CLIENT`, `RedisModule` for - * the shared `ioredis` client. + * - `OboStrategy` (Entra-protected downstreams). + * - `SignedAssertionStrategy` (non-Entra downstreams). + * - `DownstreamTokenCache` (used internally by OboStrategy; exposed + * in case a future consumer wants direct cache management). + * + * Imports `AuthModule` for the shared `MSAL_CLIENT`, `RedisModule` + * for the shared `ioredis` client. */ @Module({ imports: [AuthModule, RedisModule], + controllers: [JwksController], providers: [ { provide: OBO_CACHE_KEY, useFactory: () => assertOboCacheEncryptionKey(), }, + { + provide: BFF_SIGNING_KEY, + useFactory: () => buildBffSigningKey(assertJwksConfig()), + }, DownstreamTokenCache, OboStrategy, + SignedAssertionStrategy, ], - exports: [OboStrategy, DownstreamTokenCache], + exports: [OboStrategy, SignedAssertionStrategy, DownstreamTokenCache], }) export class DownstreamModule {} diff --git a/apps/portal-bff/src/downstream/jwks.controller.spec.ts b/apps/portal-bff/src/downstream/jwks.controller.spec.ts new file mode 100644 index 0000000..9776a9c --- /dev/null +++ b/apps/portal-bff/src/downstream/jwks.controller.spec.ts @@ -0,0 +1,42 @@ +import { createPrivateKey, generateKeyPairSync } from 'node:crypto'; +import { buildBffSigningKey } from './bff-signing-key'; +import { JwksController } from './jwks.controller'; + +async function makeController() { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = await buildBffSigningKey({ + privateKey: createPrivateKey(privateKey.export({ type: 'pkcs8', format: 'pem' })), + kid: 'bff-2026-05', + alg: 'RS256', + }); + return { controller: new JwksController(key), key }; +} + +describe('JwksController', () => { + it('returns a JWKS-shaped object with the single configured public key', async () => { + const { controller, key } = await makeController(); + const res = controller.jwks(); + expect(Array.isArray(res.keys)).toBe(true); + expect(res.keys).toHaveLength(1); + expect(res.keys[0]).toBe(key.publicJwk); + }); + + it('the served key carries the kid + alg + use=sig the publisher derived', async () => { + const { controller } = await makeController(); + const [jwk] = controller.jwks().keys; + expect(jwk?.kid).toBe('bff-2026-05'); + expect(jwk?.alg).toBe('RS256'); + expect(jwk?.use).toBe('sig'); + }); + + it('does NOT leak private RSA components (d/p/q/dp/dq/qi) over the wire', async () => { + const { controller } = await makeController(); + const [jwk] = controller.jwks().keys; + expect(jwk?.d).toBeUndefined(); + expect(jwk?.p).toBeUndefined(); + expect(jwk?.q).toBeUndefined(); + expect(jwk?.dp).toBeUndefined(); + expect(jwk?.dq).toBeUndefined(); + expect(jwk?.qi).toBeUndefined(); + }); +}); diff --git a/apps/portal-bff/src/downstream/jwks.controller.ts b/apps/portal-bff/src/downstream/jwks.controller.ts new file mode 100644 index 0000000..b74271e --- /dev/null +++ b/apps/portal-bff/src/downstream/jwks.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Inject } from '@nestjs/common'; +import type { JWK } from 'jose'; +import { BFF_SIGNING_KEY, type BffSigningKey } from './bff-signing-key'; + +/** + * `GET /.well-known/jwks.json` — publishes the BFF's public key + * material so downstream services can verify `X-User-Assertion` + * JWTs minted by `SignedAssertionStrategy` per + * [ADR-0014](../../../../docs/decisions/0014-downstream-api-access-obo-pattern.md) + * §"Service strategy". + * + * v1 publishes a single key. When the rotation chantier ships, + * `keys` will hold both the current and the previous public JWKs + * so a downstream that cached the previous one keeps verifying + * during the cut-over window. The shape is JWKS-canonical so + * existing JOSE clients on the downstream side just point at the + * URL and work. + * + * **Routing** — the controller's `@Controller('.well-known/jwks.json')` + * combined with `main.ts`'s `setGlobalPrefix('api', { exclude: + * [/^\.well-known/] })` lands the route at the bare-root path + * (`/.well-known/jwks.json`), which is where the well-known URI + * convention places it (RFC 8615). + * + * **No auth / no CSRF.** Public by design — the JWKS is the + * downstream's verification anchor; gating it would defeat the + * purpose. The double-submit CSRF middleware already exempts GET + * methods so the route comes out clean. + */ +@Controller('.well-known/jwks.json') +export class JwksController { + constructor(@Inject(BFF_SIGNING_KEY) private readonly key: BffSigningKey) {} + + @Get() + jwks(): { keys: readonly JWK[] } { + return { keys: [this.key.publicJwk] }; + } +} diff --git a/apps/portal-bff/src/downstream/strategies/signed-assertion.strategy.spec.ts b/apps/portal-bff/src/downstream/strategies/signed-assertion.strategy.spec.ts new file mode 100644 index 0000000..4d7f66f --- /dev/null +++ b/apps/portal-bff/src/downstream/strategies/signed-assertion.strategy.spec.ts @@ -0,0 +1,112 @@ +import { createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto'; +import { jwtVerify } from 'jose'; +import { buildBffSigningKey, type BffSigningKey } from '../bff-signing-key'; +import { SignedAssertionStrategy } from './signed-assertion.strategy'; + +async function makeStrategy(): Promise<{ strategy: SignedAssertionStrategy; key: BffSigningKey }> { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = await buildBffSigningKey({ + privateKey: createPrivateKey(privateKey.export({ type: 'pkcs8', format: 'pem' })), + kid: 'bff-2026-05', + alg: 'RS256', + }); + return { strategy: new SignedAssertionStrategy(key), key }; +} + +const INPUT = { + actorIdHash: 'hash(jane)', + audience: 'workforce' as const, + downstreamName: 'svc-cms', + claims: { tenant: 't', roles: ['editor'] }, + traceId: 'abc123def456', +}; + +describe('SignedAssertionStrategy.sign', () => { + it('mints a JWT verifiable against the BFF public key with the expected claim set', async () => { + const { strategy, key } = await makeStrategy(); + const jwt = await strategy.sign(INPUT); + + const { payload, protectedHeader } = await jwtVerify( + jwt, + createPublicKey(key.config.privateKey), + { issuer: 'portal-bff', audience: 'svc-cms' }, + ); + + expect(protectedHeader.alg).toBe('RS256'); + expect(protectedHeader.kid).toBe('bff-2026-05'); + expect(payload.iss).toBe('portal-bff'); + expect(payload.sub).toBe('hash(jane)'); + expect(payload.aud).toBe('svc-cms'); + // Non-standard claims per ADR-0014 §"Service strategy": + expect(payload['audience']).toBe('workforce'); + expect(payload['claims']).toEqual({ tenant: 't', roles: ['editor'] }); + expect(payload['trace_id']).toBe('abc123def456'); + }); + + it('sets exp = iat + 60 seconds (matches ADR-0014 §"Service strategy")', async () => { + const { strategy, key } = await makeStrategy(); + const jwt = await strategy.sign(INPUT); + const { payload } = await jwtVerify(jwt, createPublicKey(key.config.privateKey), { + issuer: 'portal-bff', + audience: 'svc-cms', + }); + expect(typeof payload.iat).toBe('number'); + expect(typeof payload.exp).toBe('number'); + expect((payload.exp as number) - (payload.iat as number)).toBe(60); + }); + + it('rejects with audience mismatch when the downstream verifies against the wrong aud', async () => { + const { strategy, key } = await makeStrategy(); + const jwt = await strategy.sign(INPUT); + await expect( + jwtVerify(jwt, createPublicKey(key.config.privateKey), { + issuer: 'portal-bff', + audience: 'svc-other', // wrong target + }), + ).rejects.toThrow(/"aud"/i); + }); + + it('rejects when verified against a different public key (signature mismatch)', async () => { + const { strategy } = await makeStrategy(); + const jwt = await strategy.sign(INPUT); + // Forge a different RSA keypair — its public half cannot + // verify the JWT signed under the original private key. + const { privateKey: otherPriv } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const otherPub = createPublicKey(otherPriv.export({ type: 'pkcs8', format: 'pem' })); + await expect( + jwtVerify(jwt, otherPub, { issuer: 'portal-bff', audience: 'svc-cms' }), + ).rejects.toThrow(/signature/i); + }); + + it('mints a different JWT for each call (no caching, fresh iat each time)', async () => { + const { strategy } = await makeStrategy(); + const a = await strategy.sign(INPUT); + // jose's setIssuedAt uses second-resolution; wait > 1s to make + // sure iat differs deterministically. Faking time keeps the + // test fast. + jest.useFakeTimers().setSystemTime(Date.now() + 2000); + try { + const b = await strategy.sign(INPUT); + expect(a).not.toBe(b); + } finally { + jest.useRealTimers(); + } + }); + + it('signs against the EC P-256 key with alg=ES256', async () => { + const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const key = await buildBffSigningKey({ + privateKey: createPrivateKey(privateKey.export({ type: 'pkcs8', format: 'pem' })), + kid: 'ec-kid', + alg: 'ES256', + }); + const strategy = new SignedAssertionStrategy(key); + const jwt = await strategy.sign(INPUT); + const { protectedHeader } = await jwtVerify(jwt, createPublicKey(key.config.privateKey), { + issuer: 'portal-bff', + audience: 'svc-cms', + }); + expect(protectedHeader.alg).toBe('ES256'); + expect(protectedHeader.kid).toBe('ec-kid'); + }); +}); diff --git a/apps/portal-bff/src/downstream/strategies/signed-assertion.strategy.ts b/apps/portal-bff/src/downstream/strategies/signed-assertion.strategy.ts new file mode 100644 index 0000000..9e2ad76 --- /dev/null +++ b/apps/portal-bff/src/downstream/strategies/signed-assertion.strategy.ts @@ -0,0 +1,92 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { SignJWT } from 'jose'; +import { BFF_SIGNING_KEY, type BffSigningKey } from '../bff-signing-key'; + +/** + * Caller-supplied inputs. The future framework integration fills + * these from the active request: + * + * - `actorIdHash` — salted user-id hash from the audit module. + * Goes into `sub`. Never the raw user id (per ADR-0013 §"Salted + * hash actor_id"); never a user-controlled value. + * - `audience` — workforce or customer per ADR-0008. Drives + * downstream audience-aware authorization without forcing the + * downstream to call back to the IdP. + * - `downstreamName` — stable downstream identifier; becomes the + * JWT `aud`. The downstream rejects assertions not targeted at + * itself, so a leaked assertion can't be replayed at a + * different service. + * - `claims` — curated subset of session claims the downstream + * needs to make its authorization decision. The framework is + * responsible for the curation (allow-list, never the full + * session); this strategy treats it as opaque structured data. + * - `traceId` — W3C trace id of the originating request. Lets the + * downstream emit its own logs/audit referencing the same trace + * so a correlation across BFF + downstream is one-step. + */ +export interface SignedAssertionInput { + readonly actorIdHash: string; + readonly audience: 'workforce' | 'customer'; + readonly downstreamName: string; + readonly claims: Readonly>; + readonly traceId: string; +} + +/** Issuer value baked into every assertion. Stable string, no env override. */ +const ISSUER = 'portal-bff'; + +/** Assertion lifetime — 60 s per ADR-0014 §"Service strategy". */ +const ASSERTION_TTL_SECONDS = 60; + +/** + * `SignedAssertionStrategy` per + * [ADR-0014](../../../../docs/decisions/0014-downstream-api-access-obo-pattern.md) + * §"Service strategy (non-Entra downstreams)". + * + * Mints a short-lived JWT that propagates user identity to a + * downstream without giving the downstream a token it could replay + * against Entra. The downstream verifies the signature against the + * BFF's `/.well-known/jwks.json` and makes its own authZ decision + * from the claims. + * + * Claim shape per the ADR: + * + * ``` + * { + * "iss": "portal-bff", + * "sub": "", + * "aud": "", + * "audience": "workforce" | "customer", + * "claims": { … }, + * "exp": , + * "iat": , + * "trace_id": "" + * } + * ``` + * + * Each call mints a fresh JWT — there's no caching. At ~60 s TTL + * the savings would be negligible and a cache would create a window + * where a replayed assertion lingers past its useful life. The + * signing operation itself is cheap (a few hundred microseconds for + * RS256 with a 3 KB key). + */ +@Injectable() +export class SignedAssertionStrategy { + constructor(@Inject(BFF_SIGNING_KEY) private readonly key: BffSigningKey) {} + + async sign(input: SignedAssertionInput): Promise { + const now = Math.floor(Date.now() / 1000); + return await new SignJWT({ + audience: input.audience, + claims: input.claims, + trace_id: input.traceId, + }) + .setProtectedHeader({ alg: this.key.config.alg, kid: this.key.config.kid }) + .setIssuer(ISSUER) + .setSubject(input.actorIdHash) + .setAudience(input.downstreamName) + .setIssuedAt(now) + .setExpirationTime(now + ASSERTION_TTL_SECONDS) + .sign(this.key.config.privateKey); + } +} diff --git a/apps/portal-bff/src/main.ts b/apps/portal-bff/src/main.ts index 5c1f723..8ffc075 100644 --- a/apps/portal-bff/src/main.ts +++ b/apps/portal-bff/src/main.ts @@ -3,7 +3,7 @@ // OpenTelemetry auto-instrumentations and is silently un-traced. import './observability/tracing'; -import { ValidationPipe } from '@nestjs/common'; +import { RequestMethod, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import cookieParser from 'cookie-parser'; import helmet from 'helmet'; @@ -12,6 +12,7 @@ import { AppModule } from './app/app.module'; import { readCorsAllowlist } from './config/check-cors-allowlist'; import { assertDatabaseUrl } from './config/check-database-url'; import { assertEntraConfig } from './config/check-entra-config'; +import { assertJwksConfig } from './config/check-jwks-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'; @@ -64,6 +65,13 @@ assertLogUserIdSalt(); // identical value as defense in depth against copy-paste accidents. assertOboCacheEncryptionKey(); +// BFF_JWKS_PRIVATE_KEY_PATH + BFF_JWKS_KID — signing material for +// the ADR-0014 signed-assertion strategy. Reads the PEM file once +// here so a missing / unreadable / weak key fails the boot rather +// than the first downstream call. The same parsed config is +// re-used by `DownstreamModule`'s factory at app construction. +assertJwksConfig(); + 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 @@ -183,7 +191,14 @@ async function bootstrap() { app.use(app.get(CSRF_MIDDLEWARE)); const globalPrefix = 'api'; - app.setGlobalPrefix(globalPrefix); + // `/.well-known/*` is reserved by RFC 8615 for bare-root metadata + // endpoints. The BFF's JWKS controller (ADR-0014 signed-assertion + // strategy) lives at `/.well-known/jwks.json` so downstream + // services pointing at the standard location find it. Excluding + // the prefix lets Nest's router resolve the route at the root. + app.setGlobalPrefix(globalPrefix, { + exclude: [{ path: '.well-known/jwks.json', method: RequestMethod.GET }], + }); const port = process.env['PORT'] ?? 3000; await app.listen(port); diff --git a/package.json b/package.json index d40b049..2ec2972 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "express-session": "^1.19.0", "helmet": "^8.1.0", "ioredis": "^5.10.1", + "jose": "^6.2.3", "lucide-angular": "^1.0.0", "nestjs-cls": "^6.2.0", "nestjs-pino": "^4.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31f4624..66c8d6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,9 @@ importers: ioredis: specifier: ^5.10.1 version: 5.10.1 + jose: + specifier: ^6.2.3 + version: 6.2.3 lucide-angular: specifier: ^1.0.0 version: 1.0.0(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))