From 98446a9f350150dd22f7217f61b8ae47a982cd4d Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Thu, 14 May 2026 19:03:56 +0200 Subject: [PATCH] fix(portal-bff): serve /.well-known/jwks.json via express (path-to-regexp v8 ducks the dot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Nest `@Controller('.well-known/jwks.json')` declared in PR #138 combined with `setGlobalPrefix('api', { exclude: [...] })` landed the JWKS route at neither `/.well-known/jwks.json` (intended) nor `/api/.well-known/jwks.json` (with-prefix fallback). Both URLs 404'd. Nest 11 routes via path-to-regexp v8, whose grammar broke backward compatibility on several leading-character cases — the combination of a leading-dot path segment + the `exclude` rewrite falls into one of them. Fix Sidestep Nest's router for this one route. The JWKS payload-builder stays in the DI graph (`JwksPublisher`, formerly `JwksController`, minus the Nest decorators), and `main.ts` resolves it from the container then registers a plain Express GET handler at `/.well-known/jwks.json`. Express's router accepts the leading dot verbatim and the route lands exactly where RFC 8615 says it should. Touched - jwks.controller.{ts,spec.ts} → jwks.publisher.{ts,spec.ts}. Same constructor, same `jwks()` method shape — only the @Controller / @Get decorators are gone. The DI signature is unchanged so the existing tests rename → green without other edits. - downstream.module.ts: drops the `controllers` array, lists `JwksPublisher` as a provider + export so `main.ts` can resolve it. - main.ts: drops the `setGlobalPrefix` exclude option, drops the `RequestMethod` import, registers an Express GET handler at the bare-root JWKS path immediately before `app.listen()`. Verified locally: `curl http://localhost:3000/.well-known/jwks.json` returns the expected JWKS shape (`kty=RSA`, `kid=bff-2026-05`, `alg=RS256`, `use=sig`). Tests: still 358 specs passing. No new specs added — the routing fix is a wiring change tested manually with the real BFF; the publisher's `jwks()` method is unchanged so the rename-only spec delta keeps the existing coverage. --- .../src/downstream/downstream.module.ts | 11 ++++-- .../src/downstream/jwks.controller.ts | 38 ------------------ ...troller.spec.ts => jwks.publisher.spec.ts} | 22 +++++------ .../src/downstream/jwks.publisher.ts | 39 +++++++++++++++++++ apps/portal-bff/src/main.ts | 30 ++++++++++---- 5 files changed, 80 insertions(+), 60 deletions(-) delete mode 100644 apps/portal-bff/src/downstream/jwks.controller.ts rename apps/portal-bff/src/downstream/{jwks.controller.spec.ts => jwks.publisher.spec.ts} (65%) create mode 100644 apps/portal-bff/src/downstream/jwks.publisher.ts diff --git a/apps/portal-bff/src/downstream/downstream.module.ts b/apps/portal-bff/src/downstream/downstream.module.ts index b0382f0..115951f 100644 --- a/apps/portal-bff/src/downstream/downstream.module.ts +++ b/apps/portal-bff/src/downstream/downstream.module.ts @@ -6,7 +6,7 @@ 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 { JwksPublisher } from './jwks.publisher'; import { OboStrategy } from './strategies/obo.strategy'; import { SignedAssertionStrategy } from './strategies/signed-assertion.strategy'; @@ -38,7 +38,6 @@ import { SignedAssertionStrategy } from './strategies/signed-assertion.strategy' */ @Module({ imports: [AuthModule, RedisModule], - controllers: [JwksController], providers: [ { provide: OBO_CACHE_KEY, @@ -51,7 +50,13 @@ import { SignedAssertionStrategy } from './strategies/signed-assertion.strategy' DownstreamTokenCache, OboStrategy, 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 {} diff --git a/apps/portal-bff/src/downstream/jwks.controller.ts b/apps/portal-bff/src/downstream/jwks.controller.ts deleted file mode 100644 index b74271e..0000000 --- a/apps/portal-bff/src/downstream/jwks.controller.ts +++ /dev/null @@ -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] }; - } -} diff --git a/apps/portal-bff/src/downstream/jwks.controller.spec.ts b/apps/portal-bff/src/downstream/jwks.publisher.spec.ts similarity index 65% rename from apps/portal-bff/src/downstream/jwks.controller.spec.ts rename to apps/portal-bff/src/downstream/jwks.publisher.spec.ts index 9776a9c..fbae41c 100644 --- a/apps/portal-bff/src/downstream/jwks.controller.spec.ts +++ b/apps/portal-bff/src/downstream/jwks.publisher.spec.ts @@ -1,37 +1,37 @@ import { createPrivateKey, generateKeyPairSync } from 'node:crypto'; 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 key = await buildBffSigningKey({ privateKey: createPrivateKey(privateKey.export({ type: 'pkcs8', format: 'pem' })), kid: 'bff-2026-05', 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 () => { - const { controller, key } = await makeController(); - const res = controller.jwks(); + const { publisher, key } = await makePublisher(); + const res = publisher.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; + it('the served key carries the kid + alg + use=sig the signing-key derived', async () => { + const { publisher } = await makePublisher(); + const [jwk] = publisher.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; + const { publisher } = await makePublisher(); + const [jwk] = publisher.jwks().keys; expect(jwk?.d).toBeUndefined(); expect(jwk?.p).toBeUndefined(); expect(jwk?.q).toBeUndefined(); diff --git a/apps/portal-bff/src/downstream/jwks.publisher.ts b/apps/portal-bff/src/downstream/jwks.publisher.ts new file mode 100644 index 0000000..872fedb --- /dev/null +++ b/apps/portal-bff/src/downstream/jwks.publisher.ts @@ -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] }; + } +} diff --git a/apps/portal-bff/src/main.ts b/apps/portal-bff/src/main.ts index 8ffc075..2b3fee9 100644 --- a/apps/portal-bff/src/main.ts +++ b/apps/portal-bff/src/main.ts @@ -3,7 +3,7 @@ // OpenTelemetry auto-instrumentations and is silently un-traced. import './observability/tracing'; -import { RequestMethod, ValidationPipe } from '@nestjs/common'; +import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import cookieParser from 'cookie-parser'; import helmet from 'helmet'; @@ -21,6 +21,7 @@ import { assertSessionSecret } from './config/check-session-secret'; import { createRateLimitMiddleware, readRateLimitConfig } from './security/rate-limit.middleware'; import { CSRF_MIDDLEWARE } from './security/security.token'; import { StructuredErrorFilter } from './security/structured-error.filter'; +import { JwksPublisher } from './downstream/jwks.publisher'; import type { NextFunction, Request, Response } from 'express'; import { ADMIN_SESSION_MIDDLEWARE, @@ -191,14 +192,27 @@ async function bootstrap() { app.use(app.get(CSRF_MIDDLEWARE)); const globalPrefix = 'api'; - // `/.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 }], + app.setGlobalPrefix(globalPrefix); + + // JWKS endpoint at the RFC 8615 bare-root path. Wired at the + // Express layer rather than as a Nest `@Controller` because + // path-to-regexp v8 (Nest 11's router) does not cleanly route a + // leading-dot segment like `.well-known/jwks.json` to a bare-root + // 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; await app.listen(port);