fix(portal-bff): serve /.well-known/jwks.json via express (path-to-regexp v8 ducks the dot) #139

Merged
julien merged 1 commits from fix/portal-bff-jwks-express-route into main 2026-05-14 19:12:39 +02:00
5 changed files with 80 additions and 60 deletions
@@ -6,7 +6,7 @@ import { RedisModule } from '../redis/redis.module';
import { BFF_SIGNING_KEY, buildBffSigningKey } from './bff-signing-key'; import { BFF_SIGNING_KEY, buildBffSigningKey } from './bff-signing-key';
import { DownstreamTokenCache } from './downstream-token-cache.service'; import { DownstreamTokenCache } from './downstream-token-cache.service';
import { OBO_CACHE_KEY } from './downstream.token'; import { OBO_CACHE_KEY } from './downstream.token';
import { JwksController } from './jwks.controller'; import { JwksPublisher } from './jwks.publisher';
import { OboStrategy } from './strategies/obo.strategy'; import { OboStrategy } from './strategies/obo.strategy';
import { SignedAssertionStrategy } from './strategies/signed-assertion.strategy'; import { SignedAssertionStrategy } from './strategies/signed-assertion.strategy';
@@ -38,7 +38,6 @@ import { SignedAssertionStrategy } from './strategies/signed-assertion.strategy'
*/ */
@Module({ @Module({
imports: [AuthModule, RedisModule], imports: [AuthModule, RedisModule],
controllers: [JwksController],
providers: [ providers: [
{ {
provide: OBO_CACHE_KEY, provide: OBO_CACHE_KEY,
@@ -51,7 +50,13 @@ import { SignedAssertionStrategy } from './strategies/signed-assertion.strategy'
DownstreamTokenCache, DownstreamTokenCache,
OboStrategy, OboStrategy,
SignedAssertionStrategy, SignedAssertionStrategy,
JwksPublisher,
], ],
exports: [OboStrategy, SignedAssertionStrategy, DownstreamTokenCache], // `JwksPublisher` is exported so `main.ts` can resolve it from the
// Nest container and wire it into an Express-direct `GET
// /.well-known/jwks.json` handler. The handler bypasses Nest's
// path-to-regexp-based router because v8 doesn't cleanly route a
// leading-dot segment to a bare-root URL.
exports: [OboStrategy, SignedAssertionStrategy, DownstreamTokenCache, JwksPublisher],
}) })
export class DownstreamModule {} export class DownstreamModule {}
@@ -1,38 +0,0 @@
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] };
}
}
@@ -1,37 +1,37 @@
import { createPrivateKey, generateKeyPairSync } from 'node:crypto'; import { createPrivateKey, generateKeyPairSync } from 'node:crypto';
import { buildBffSigningKey } from './bff-signing-key'; import { buildBffSigningKey } from './bff-signing-key';
import { JwksController } from './jwks.controller'; import { JwksPublisher } from './jwks.publisher';
async function makeController() { async function makePublisher() {
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const key = await buildBffSigningKey({ const key = await buildBffSigningKey({
privateKey: createPrivateKey(privateKey.export({ type: 'pkcs8', format: 'pem' })), privateKey: createPrivateKey(privateKey.export({ type: 'pkcs8', format: 'pem' })),
kid: 'bff-2026-05', kid: 'bff-2026-05',
alg: 'RS256', alg: 'RS256',
}); });
return { controller: new JwksController(key), key }; return { publisher: new JwksPublisher(key), key };
} }
describe('JwksController', () => { describe('JwksPublisher', () => {
it('returns a JWKS-shaped object with the single configured public key', async () => { it('returns a JWKS-shaped object with the single configured public key', async () => {
const { controller, key } = await makeController(); const { publisher, key } = await makePublisher();
const res = controller.jwks(); const res = publisher.jwks();
expect(Array.isArray(res.keys)).toBe(true); expect(Array.isArray(res.keys)).toBe(true);
expect(res.keys).toHaveLength(1); expect(res.keys).toHaveLength(1);
expect(res.keys[0]).toBe(key.publicJwk); expect(res.keys[0]).toBe(key.publicJwk);
}); });
it('the served key carries the kid + alg + use=sig the publisher derived', async () => { it('the served key carries the kid + alg + use=sig the signing-key derived', async () => {
const { controller } = await makeController(); const { publisher } = await makePublisher();
const [jwk] = controller.jwks().keys; const [jwk] = publisher.jwks().keys;
expect(jwk?.kid).toBe('bff-2026-05'); expect(jwk?.kid).toBe('bff-2026-05');
expect(jwk?.alg).toBe('RS256'); expect(jwk?.alg).toBe('RS256');
expect(jwk?.use).toBe('sig'); expect(jwk?.use).toBe('sig');
}); });
it('does NOT leak private RSA components (d/p/q/dp/dq/qi) over the wire', async () => { it('does NOT leak private RSA components (d/p/q/dp/dq/qi) over the wire', async () => {
const { controller } = await makeController(); const { publisher } = await makePublisher();
const [jwk] = controller.jwks().keys; const [jwk] = publisher.jwks().keys;
expect(jwk?.d).toBeUndefined(); expect(jwk?.d).toBeUndefined();
expect(jwk?.p).toBeUndefined(); expect(jwk?.p).toBeUndefined();
expect(jwk?.q).toBeUndefined(); expect(jwk?.q).toBeUndefined();
@@ -0,0 +1,39 @@
import { Inject, Injectable } from '@nestjs/common';
import type { JWK } from 'jose';
import { BFF_SIGNING_KEY, type BffSigningKey } from './bff-signing-key';
/**
* Builds the JWKS payload served at `/.well-known/jwks.json` per
* [ADR-0014](../../../../docs/decisions/0014-downstream-api-access-obo-pattern.md)
* §"Service strategy". v1 returns a single public key; the rotation
* chantier extends `keys` to a window of currently-valid material
* so a downstream that cached a previous JWK keeps verifying during
* cut-over.
*
* **Why a service, not a `@Controller`.** Path-to-regexp v8 (used
* by Nest 11) parses controller paths through a grammar that does
* not cleanly route a leading-dot segment like `.well-known/...`
* to a bare-root URL — the original `@Controller('.well-known/jwks.json')`
* + `setGlobalPrefix('api', { exclude: ... })` combination landed
* the route at neither `/api/.well-known/jwks.json` nor
* `/.well-known/jwks.json`. We sidestep the whole class of bug by
* registering the JWKS handler at the Express layer in `main.ts`
* (before Nest's router) and letting this service produce the
* payload. The Nest DI graph still owns the BFF signing key — only
* the route wiring lives outside.
*
* **No auth / no CSRF.** Public by design — the JWKS is the
* downstream's verification anchor; gating it would defeat the
* purpose. The CSRF middleware exempts GET, and the route is
* mounted ahead of the rate limiter so a hammered JWKS endpoint
* can't lock the rest of the BFF out — discovery endpoints are
* meant to be polled.
*/
@Injectable()
export class JwksPublisher {
constructor(@Inject(BFF_SIGNING_KEY) private readonly key: BffSigningKey) {}
jwks(): { keys: readonly JWK[] } {
return { keys: [this.key.publicJwk] };
}
}
+22 -8
View File
@@ -3,7 +3,7 @@
// OpenTelemetry auto-instrumentations and is silently un-traced. // OpenTelemetry auto-instrumentations and is silently un-traced.
import './observability/tracing'; import './observability/tracing';
import { RequestMethod, ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import helmet from 'helmet'; import helmet from 'helmet';
@@ -21,6 +21,7 @@ import { assertSessionSecret } from './config/check-session-secret';
import { createRateLimitMiddleware, readRateLimitConfig } from './security/rate-limit.middleware'; import { createRateLimitMiddleware, readRateLimitConfig } from './security/rate-limit.middleware';
import { CSRF_MIDDLEWARE } from './security/security.token'; import { CSRF_MIDDLEWARE } from './security/security.token';
import { StructuredErrorFilter } from './security/structured-error.filter'; import { StructuredErrorFilter } from './security/structured-error.filter';
import { JwksPublisher } from './downstream/jwks.publisher';
import type { NextFunction, Request, Response } from 'express'; import type { NextFunction, Request, Response } from 'express';
import { import {
ADMIN_SESSION_MIDDLEWARE, ADMIN_SESSION_MIDDLEWARE,
@@ -191,14 +192,27 @@ async function bootstrap() {
app.use(app.get<RequestHandler>(CSRF_MIDDLEWARE)); app.use(app.get<RequestHandler>(CSRF_MIDDLEWARE));
const globalPrefix = 'api'; const globalPrefix = 'api';
// `/.well-known/*` is reserved by RFC 8615 for bare-root metadata app.setGlobalPrefix(globalPrefix);
// endpoints. The BFF's JWKS controller (ADR-0014 signed-assertion
// strategy) lives at `/.well-known/jwks.json` so downstream // JWKS endpoint at the RFC 8615 bare-root path. Wired at the
// services pointing at the standard location find it. Excluding // Express layer rather than as a Nest `@Controller` because
// the prefix lets Nest's router resolve the route at the root. // path-to-regexp v8 (Nest 11's router) does not cleanly route a
app.setGlobalPrefix(globalPrefix, { // leading-dot segment like `.well-known/jwks.json` to a bare-root
exclude: [{ path: '.well-known/jwks.json', method: RequestMethod.GET }], // URL — the previous Nest-side attempt with `setGlobalPrefix`
// `exclude` landed the route at neither `/api/.well-known/jwks.json`
// nor `/.well-known/jwks.json` (both 404'd). Express's own
// routing accepts the leading dot verbatim, and the Nest DI
// container still owns the underlying `JwksPublisher` service.
//
// Public by design (no session, no CSRF) — the JWKS is the
// downstream's verification anchor; gating it defeats the
// purpose. Mounted before `app.listen()` so the route is live
// by the time the BFF reports ready.
const jwksPublisher = app.get(JwksPublisher);
app.getHttpAdapter().get('/.well-known/jwks.json', (_req: Request, res: Response) => {
res.json(jwksPublisher.jwks());
}); });
const port = process.env['PORT'] ?? 3000; const port = process.env['PORT'] ?? 3000;
await app.listen(port); await app.listen(port);