fix(portal-bff): serve /.well-known/jwks.json via express (path-to-regexp v8 ducks the dot) (#139)
CI / commits (push) Has been skipped
CI / check (push) Successful in 2m23s
CI / scan (push) Successful in 2m29s
CI / a11y (push) Successful in 59s
CI / perf (push) Successful in 4m0s

## 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.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #139
This commit was merged in pull request #139.
This commit is contained in:
2026-05-14 19:12:38 +02:00
parent 282a972346
commit 96339cc99b
5 changed files with 80 additions and 60 deletions
@@ -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] };
}
}