feat(portal-bff): signed-assertion strategy + /.well-known/jwks.json (#138)
CI / scan (push) Successful in 2m40s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m38s
CI / a11y (push) Successful in 1m38s
CI / perf (push) Successful in 3m36s

## 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:
2026-05-14 18:34:07 +02:00
parent d665c66c4e
commit 282a972346
14 changed files with 722 additions and 19 deletions
@@ -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);
}
}