fix(portal-bff): serve /.well-known/jwks.json via express (path-to-regexp v8 ducks the dot) #139
@@ -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 {}
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -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();
|
||||
@@ -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] };
|
||||
}
|
||||
}
|
||||
@@ -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<RequestHandler>(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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user