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.
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user