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
Owner

Summary

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. The user reported it on the merged PR; this fix reroutes the endpoint so the JWKS lands at the correct RFC 8615 bare-root path.

Root cause

Nest 11 routes via path-to-regexp v8.4.2, whose grammar broke backward compatibility on several leading-character cases. The combination of a leading-dot path segment (.well-known) plus the setGlobalPrefix exclude rewrite falls into one of those cases — the route registers but matches no incoming request. Without the exclude, it would register under /api/.well-known/jwks.json, which would at least be reachable, but with exclude enabled it ends up in a path-to-regexp limbo.

Fix

Sidestep Nest's router for this one route. The JWKS payload-builder stays in the Nest DI graph (renamed JwksControllerJwksPublisher, just the decorators stripped), 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.

const jwksPublisher = app.get(JwksPublisher);
app.getHttpAdapter().get('/.well-known/jwks.json', (_req, res) => {
  res.json(jwksPublisher.jwks());
});

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

Verification

Verified locally against a running BFF (with a generated RSA-3072 key + BFF_JWKS_KID=bff-2026-05):

$ curl -s http://localhost:3000/.well-known/jwks.json | jq .
{
  "keys": [
    {
      "kty": "RSA",
      "n": "ppDvWBUEQTD6sv-7FFG-UfCPALG…",
      "e": "AQAB",
      "kid": "bff-2026-05",
      "alg": "RS256",
      "use": "sig"
    }
  ]
}

Test plan

  • pnpm nx test portal-bff358 specs pass (unchanged: the publisher's jwks() method shape is identical, the rename-only spec delta keeps the existing coverage).
  • pnpm exec nx affected -t format:check lint test build --base=origin/main — clean.
  • Manual: curl http://localhost:3000/.well-known/jwks.json returns the JWKS with the configured kid, alg=RS256, use=sig. No private RSA components (d / p / q / dp / dq / qi) in the response.

Notes for the reviewer

  • The "use Express directly when path-to-regexp v8 fights you" escape hatch is rare. It's the right move here because the path is fixed by RFC 8615 — we can't compromise on the URL shape. For any other route we'd let Nest's router handle it.
  • The publisher class is still injectable, still in the DI graph, still trivially mockable in tests. The only thing that's "outside Nest" is the route binding in main.ts. Production behaviour is identical to a Nest-routed controller; only the registration mechanism differs.
  • No new specs were added because the routing fix is a wiring change. A controller-spec-style integration test using Nest's TestingModule wouldn't exercise the actual Express route binding either, so the manual curl + the publisher's existing unit tests are the right coverage.
## Summary 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. The user reported it on the merged PR; this fix reroutes the endpoint so the JWKS lands at the correct RFC 8615 bare-root path. ## Root cause Nest 11 routes via [path-to-regexp v8.4.2](https://github.com/pillarjs/path-to-regexp/blob/main/Readme.md), whose grammar broke backward compatibility on several leading-character cases. The combination of a leading-dot path segment (`.well-known`) plus the `setGlobalPrefix` `exclude` rewrite falls into one of those cases — the route registers but matches no incoming request. Without the `exclude`, it would register under `/api/.well-known/jwks.json`, which would at least be reachable, but with `exclude` enabled it ends up in a path-to-regexp limbo. ## Fix Sidestep Nest's router for this one route. The JWKS payload-builder stays in the Nest DI graph (renamed `JwksController` → `JwksPublisher`, just the decorators stripped), and [`main.ts`](apps/portal-bff/src/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. ```ts const jwksPublisher = app.get(JwksPublisher); app.getHttpAdapter().get('/.well-known/jwks.json', (_req, res) => { res.json(jwksPublisher.jwks()); }); ``` ## Touched - [`jwks.controller.{ts,spec.ts}`](apps/portal-bff/src/downstream/) → [`jwks.publisher.{ts,spec.ts}`](apps/portal-bff/src/downstream/). 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`](apps/portal-bff/src/downstream/downstream.module.ts): drops the `controllers` array, lists `JwksPublisher` as a provider + export so `main.ts` can resolve it. - [`main.ts`](apps/portal-bff/src/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()`. ## Verification Verified locally against a running BFF (with a generated RSA-3072 key + `BFF_JWKS_KID=bff-2026-05`): ```bash $ curl -s http://localhost:3000/.well-known/jwks.json | jq . { "keys": [ { "kty": "RSA", "n": "ppDvWBUEQTD6sv-7FFG-UfCPALG…", "e": "AQAB", "kid": "bff-2026-05", "alg": "RS256", "use": "sig" } ] } ``` ## Test plan - [x] `pnpm nx test portal-bff` — **358 specs pass** (unchanged: the publisher's `jwks()` method shape is identical, the rename-only spec delta keeps the existing coverage). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Manual: `curl http://localhost:3000/.well-known/jwks.json` returns the JWKS with the configured `kid`, `alg=RS256`, `use=sig`. No private RSA components (`d` / `p` / `q` / `dp` / `dq` / `qi`) in the response. ## Notes for the reviewer - The "use Express directly when path-to-regexp v8 fights you" escape hatch is rare. It's the right move here because the path is fixed by RFC 8615 — we can't compromise on the URL shape. For any other route we'd let Nest's router handle it. - The publisher class is still injectable, still in the DI graph, still trivially mockable in tests. The only thing that's "outside Nest" is the route binding in `main.ts`. Production behaviour is identical to a Nest-routed controller; only the registration mechanism differs. - No new specs were added because the routing fix is a wiring change. A controller-spec-style integration test using Nest's `TestingModule` wouldn't exercise the actual Express route binding either, so the manual curl + the publisher's existing unit tests are the right coverage.
julien added 1 commit 2026-05-14 19:07:38 +02:00
fix(portal-bff): serve /.well-known/jwks.json via express (path-to-regexp v8 ducks the dot)
CI / scan (pull_request) Successful in 2m24s
CI / commits (pull_request) Successful in 2m33s
CI / check (pull_request) Successful in 2m41s
CI / a11y (pull_request) Successful in 1m15s
CI / perf (pull_request) Successful in 2m47s
98446a9f35
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.
julien merged commit 96339cc99b into main 2026-05-14 19:12:39 +02:00
julien deleted branch fix/portal-bff-jwks-express-route 2026-05-14 19:12:39 +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#139