feat(portal-bff): signed-assertion strategy + /.well-known/jwks.json #138
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff-signed-assertion-jwks"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
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, 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
assertJwksConfigBoot 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.BffSigningKeySingleton holding
{ config: JwksConfig, publicJwk: JWK }. ThepublicJwkis derived from the public half of the key (viajose.exportJWKon acreatePublicKey-derivedKeyObject) so no private material can leak through. Single DI source for both consumers (strategy + JWKS controller) so a key rotation only changes one provider.SignedAssertionStrategyWraps
jose.SignJWTwith the ADR-0014 claim shape:JwksControllerGET /.well-known/jwks.jsonreturns{ 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.tsexcludes/.well-known/*from the global/apiprefix 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:
Set in
apps/portal-bff/.env:The repo's existing
*.pem/*.keygitignore patterns cover.secrets/.Dependency
jose@^6added 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:joseships ESM-only, so itsnode_modulespath is removed fromtransformIgnorePatterns. The pattern walks pnpm's deep.pnpm/layout — anything under/node_modules/whose path also containsjosesomewhere gets transformed by ts-jest.Out of scope (deferred until the first concrete integration)
Per ADR-0014's "until then" clause:
DownstreamApiClientFactory+ per-service typedDownstreamApiConfig.cockatielresilience composition (timeout, retry, circuit breaker, bulkhead).audienceConstraint→authz.denyaudit).downstream.<service>.<verb>.<path>.SignedAssertionStrategy.sign()and attachesX-User-Assertion+ theServiceCredentialauth header to an outbound HTTP request.These land alongside the first concrete integration so the framework shape is validated against a real consumer, not speculative needs.
Test plan
pnpm nx test portal-bff— 358 specs pass (was 334; +24: env validators 11, signing key 4, strategy 6, controller 3).pnpm exec nx affected -t format:check lint test build --base=origin/main— clean.d,p,q,dp,dq,qiall absent from the published JWK).exp - iat == 60, audience mismatch rejected, signature mismatch rejected, EC P-256 signing path (ES256), per-call freshness.curl http://localhost:3000/.well-known/jwks.jsonshould return the JWKS shape with the chosen kid.Notes for the reviewer
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.60 sTTL is intentionally not env-overridable. ADR-0014 mandates it; making it tunable would create a tempting knob to widen the replay window for "performance".josewas 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:
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.