Files
apf_portal/apps/portal-bff/src/config/check-jwks-config.spec.ts
T
Julien Gautier e43fa5ce24
CI / scan (pull_request) Successful in 2m43s
CI / commits (pull_request) Successful in 2m43s
CI / check (pull_request) Successful in 5m2s
CI / a11y (pull_request) Successful in 2m15s
CI / perf (pull_request) Successful in 5m55s
feat(portal-bff): signed-assertion strategy + /.well-known/jwks.json
Second half of the DownstreamApiClient + OBO chantier per ADR-0014.
Ships the signed-assertion strategy (non-Entra downstreams) and the
JWKS publishing endpoint as testable primitives. The framework
around them (DownstreamApiClientFactory, cockatiel, audience
pre-check, error translation) still waits for the first concrete
integration per the ADR's "until then" clause.

What lands

- assertJwksConfig (config/check-jwks-config.ts):
  - Reads the PEM private key once at boot, refuses missing /
    unreadable / weak material (RSA < 2048, Ed25519, unknown key
    type). Derives the JOSE algorithm (RS256 / ES256 / ES384) from
    the key shape so neither the strategy nor the JWKS controller
    has to re-decide on the hot path.
  - Validates BFF_JWKS_KID against [A-Za-z0-9_-]{4,128} so the
    value lives unescaped in JWT headers + JWKS payloads.
  - Wired in main.ts alongside the other assertX() validators.

- BffSigningKey (downstream/bff-signing-key.ts):
  - Singleton holding { config: JwksConfig, publicJwk: JWK }.
    publicJwk is derived from the private key via `jose.exportJWK`
    on a public KeyObject — no private material leaks through.
  - DI token BFF_SIGNING_KEY wires both consumers (strategy +
    controller) to the same source of truth.

- SignedAssertionStrategy (downstream/strategies/signed-assertion.strategy.ts):
  - Wraps `jose.SignJWT` with the ADR-0014 claim shape: iss,
    sub, aud, audience (workforce|customer), claims (curated
    subset), trace_id, iat, exp.
  - 60 s TTL hard-coded — the ADR mandates it; cache disabled
    because the savings on a 60 s JWT would be marginal and a
    cache would let replayed assertions linger past their TTL.
  - kid 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 (downstream/jwks.controller.ts):
  - GET /.well-known/jwks.json returns { keys: [<single jwk>] }.
  - 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). Read-only, so the CSRF middleware's
    GET-exempt path already handles it.

Configuration

- Generate a key:
    mkdir -p apps/portal-bff/.secrets && \
    openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
      -out apps/portal-bff/.secrets/jwks.pem
- BFF_JWKS_PRIVATE_KEY_PATH (path to the PEM)
- BFF_JWKS_KID (URL-safe id, 4..128 chars)
- Both mandatory at boot.
- `apps/portal-bff/.secrets/` is matched by the repo's existing
  *.pem / *.key gitignore patterns.

Deps

- jose@^6 added as a direct dep (was transitive). 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/ that
  also contains `jose` somewhere in the path gets transformed.

Tests: +24 specs (env validators 11, signing key 4, strategy 6,
controller 3).

Out of scope (deferred per ADR-0014 "until then"):
- DownstreamApiClientFactory + per-service typed config.
- cockatiel resilience composition.
- Audience pre-check at the call site.
- Error translation tables.
- OTel custom spans `downstream.<service>.<verb>.<path>`.
- The framework wiring that calls SignedAssertionStrategy.sign()
  + attaches the `X-User-Assertion` + ServiceCredential auth
  header to outbound HTTP requests.
- Key rotation (the JWKS lists one key for now; rotation chantier
  adds a second entry + a window-based eviction policy).

These land alongside the first concrete integration so the
framework shape is validated against a real consumer.
2026-05-14 18:28:52 +02:00

136 lines
4.6 KiB
TypeScript

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