feat(portal-bff): signed-assertion strategy + /.well-known/jwks.json (#138)
## Summary Second half of the **DownstreamApiClient + OBO** chantier per [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md). Ships the **signed-assertion strategy** (non-Entra downstreams) and the **JWKS publishing endpoint** as testable primitives, completing the strategy layer the OBO PR (#137) started. The framework around them (DownstreamApiClientFactory, cockatiel, audience pre-check, error translation) still waits for the first concrete integration per the ADR's own "until then" clause. After this PR the BFF has, ready to plug into a future integration: - `OboStrategy` — Entra-protected downstreams (PR #137) - `SignedAssertionStrategy` — non-Entra downstreams (this PR) - `DownstreamTokenCache` — encrypted-at-rest OBO token cache (PR #137) - `GET /.well-known/jwks.json` — public key publication (this PR) ## What lands ### [`assertJwksConfig`](apps/portal-bff/src/config/check-jwks-config.ts) Boot validator for `BFF_JWKS_PRIVATE_KEY_PATH` + `BFF_JWKS_KID`. Reads the PEM file once at startup, refuses missing / unreadable / weak material (RSA < 2048, Ed25519, unknown key type), derives the JOSE algorithm (`RS256` / `ES256` / `ES384`) from the key shape, and validates the kid against `[A-Za-z0-9_-]{4,128}` so the value lives unescaped in JWT headers + JWKS payloads. ### [`BffSigningKey`](apps/portal-bff/src/downstream/bff-signing-key.ts) Singleton holding `{ config: JwksConfig, publicJwk: JWK }`. The `publicJwk` is derived from the **public half** of the key (via `jose.exportJWK` on a `createPublicKey`-derived `KeyObject`) so no private material can leak through. Single DI source for both consumers (strategy + JWKS controller) so a key rotation only changes one provider. ### [`SignedAssertionStrategy`](apps/portal-bff/src/downstream/strategies/signed-assertion.strategy.ts) Wraps `jose.SignJWT` with the ADR-0014 claim shape: ```json { "iss": "portal-bff", "sub": "<actor_id_hash>", "aud": "<downstream-name>", "audience": "workforce" | "customer", "claims": { /* curated subset */ }, "exp": <now + 60s>, "iat": <now>, "trace_id": "<W3C trace id>" } ``` - **60 s TTL** hard-coded — the ADR mandates it. - **No JWT cache** — at 60 s lifetime the savings would be negligible and a cache would let replayed assertions linger past their useful life. The signing operation itself is cheap (~hundreds of µs for RS256 with a 3 KB key). - **kid in the protected header** matches the JWKS so a downstream picks the right key during rotation. - Supports **RS256 / ES256 / ES384** transparently — picks the alg the validator derived at boot. ### [`JwksController`](apps/portal-bff/src/downstream/jwks.controller.ts) `GET /.well-known/jwks.json` returns `{ keys: [<single jwk>] }`. v1 publishes one key; the rotation chantier will add a second entry + window-based eviction so a downstream that cached the previous JWK keeps verifying during cut-over. [`main.ts`](apps/portal-bff/src/main.ts) excludes `/.well-known/*` from the global `/api` prefix so the route lands at the bare root per RFC 8615. No auth gate — the JWKS is the verification anchor; gating it would defeat the purpose. The CSRF middleware already exempts GET methods, so the route comes out clean. ## Required env update (mandatory at boot) Generate the key: ```bash mkdir -p apps/portal-bff/.secrets openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \ -out apps/portal-bff/.secrets/jwks.pem ``` Set in `apps/portal-bff/.env`: ```env BFF_JWKS_PRIVATE_KEY_PATH=apps/portal-bff/.secrets/jwks.pem BFF_JWKS_KID=bff-2026-05 ``` The repo's existing `*.pem` / `*.key` gitignore patterns cover `.secrets/`. ## Dependency - **`jose@^6`** added as a direct dep (was transitive via MSAL). Pinned at the workspace root since the BFF is the only consumer today and the package isn't part of the Angular bundle graph. - `jest.config.cts`: `jose` ships ESM-only, so its `node_modules` path is removed from `transformIgnorePatterns`. The pattern walks pnpm's deep `.pnpm/` layout — anything under `/node_modules/` whose path also contains `jose` somewhere gets transformed by ts-jest. ## Out of scope (deferred until the first concrete integration) Per ADR-0014's "until then" clause: - `DownstreamApiClientFactory` + per-service typed `DownstreamApiConfig`. - `cockatiel` resilience composition (timeout, retry, circuit breaker, bulkhead). - Audience pre-check at the call site (`audienceConstraint` → `authz.deny` audit). - Error translation tables per service. - OTel custom spans `downstream.<service>.<verb>.<path>`. - The framework code that actually calls `SignedAssertionStrategy.sign()` and attaches `X-User-Assertion` + the `ServiceCredential` auth header to an outbound HTTP request. - Key rotation (the JWKS lists one key for now; the rotation chantier adds the second entry + eviction policy). These land alongside the first concrete integration so the framework shape is validated against a real consumer, not speculative needs. ## Test plan - [x] `pnpm nx test portal-bff` — **358 specs pass** (was 334; +24: env validators 11, signing key 4, strategy 6, controller 3). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Env validator: missing path, unreadable file, garbage PEM, RSA-1024 (weak), Ed25519 (unsupported), missing kid, illegal kid charset, kid too short. - [x] Signing key: RSA / EC P-256 / EC P-384 round-trip to public JWK with no private material (`d`, `p`, `q`, `dp`, `dq`, `qi` all absent from the published JWK). - [x] Strategy: claim shape matches ADR-0014, `exp - iat == 60`, audience mismatch rejected, signature mismatch rejected, EC P-256 signing path (ES256), per-call freshness. - [x] Controller: returns JWKS with the single public key, no private material leaks. - [ ] Manual smoke: generate a key locally + set the two env vars + `curl http://localhost:3000/.well-known/jwks.json` should return the JWKS shape with the chosen kid. ## Notes for the reviewer - The strategy uses `setProtectedHeader({ alg, kid })` — the kid in the protected header is the canonical way to tell a verifier "use the entry with this kid in the JWKS". Without it, a verifier holding two keys during rotation has to try both. - The `60 s` TTL is intentionally not env-overridable. ADR-0014 mandates it; making it tunable would create a tempting knob to widen the replay window for "performance". - `jose` was already in the tree transitively (likely via MSAL). Promoting it to a direct dep + pinning means a future hoist deduplication can't silently remove it without our review. ## What's next The chantier's strategy layer is complete. Open follow-ups on the roadmap: - **First concrete downstream integration** — when a real consumer arrives, the framework gets built around the two strategies (DownstreamApiClientFactory, cockatiel resilience, audience pre-check, error translation, OTel spans, audit events). Until then the strategies + cache + JWKS sit ready. - **Strategic security baseline ADR** — RSSI sign-off on ASVS / HDS / GDPR / NIS 2. Paused per [CLAUDE.md](CLAUDE.md) §"Repository status". - **portal-admin v1 modules** — CMS pages, menu management, user list. Each is its own self-contained chantier. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #138
This commit was merged in pull request #138.
This commit is contained in:
@@ -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)
|
||||
# <SERVICE>_API_BASE_URL (per integrated downstream)
|
||||
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000)
|
||||
# OBO_CACHE_ENCRYPTION_KEY — wired
|
||||
# BFF_JWKS_PRIVATE_KEY_PATH — wired
|
||||
# BFF_JWKS_KID — wired
|
||||
# <SERVICE>_API_BASE_URL (per integrated downstream — lands with the first integration)
|
||||
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000 — lands with the first integration)
|
||||
|
||||
@@ -6,5 +6,16 @@ module.exports = {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/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@<version>/node_modules/jose/...`.
|
||||
transformIgnorePatterns: ['/node_modules/(?!.*jose)'],
|
||||
coverageDirectory: '../../coverage/apps/portal-bff',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<BffSigningKey> {
|
||||
// `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 };
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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] };
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>>;
|
||||
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": "<actor_id_hash>",
|
||||
* "aud": "<downstream-name>",
|
||||
* "audience": "workforce" | "customer",
|
||||
* "claims": { … },
|
||||
* "exp": <now + 60s>,
|
||||
* "iat": <now>,
|
||||
* "trace_id": "<W3C 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<string> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<RequestHandler>(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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user