feat(portal-bff): signed-assertion strategy + /.well-known/jwks.json #138

Merged
julien merged 1 commits from feat/portal-bff-signed-assertion-jwks into main 2026-05-14 18:34:08 +02:00
Owner

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

assertJwksConfig

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

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

Wraps jose.SignJWT with the ADR-0014 claim shape:

{
  "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

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 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:

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:

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 (audienceConstraintauthz.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

  • pnpm nx test portal-bff358 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.
  • Env validator: missing path, unreadable file, garbage PEM, RSA-1024 (weak), Ed25519 (unsupported), missing kid, illegal kid charset, kid too short.
  • 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).
  • Strategy: claim shape matches ADR-0014, exp - iat == 60, audience mismatch rejected, signature mismatch rejected, EC P-256 signing path (ES256), per-call freshness.
  • 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 §"Repository status".
  • portal-admin v1 modules — CMS pages, menu management, user list. Each is its own self-contained chantier.
## 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.
julien added 1 commit 2026-05-14 18:32:54 +02:00
feat(portal-bff): signed-assertion strategy + /.well-known/jwks.json
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
e43fa5ce24
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.
julien merged commit 282a972346 into main 2026-05-14 18:34:08 +02:00
julien deleted branch feat/portal-bff-signed-assertion-jwks 2026-05-14 18:34:10 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#138