Skip to content

fix(auth): register the JWK Set endpoint filter - #52

Merged
ExtraToast merged 4 commits into
mainfrom
fix/register-jwks-filter
Aug 30, 2026
Merged

fix(auth): register the JWK Set endpoint filter#52
ExtraToast merged 4 commits into
mainfrom
fix/register-jwks-filter

Conversation

@ExtraToast

Copy link
Copy Markdown
Contributor

Summary

Making the endpoint public (#50) removed the authorization gate and uncovered the real fault beneath it: nothing served the path at all. After that fix, /api/oauth2/jwks reaches the DispatcherServlet and fails:

NoResourceFoundException: No static resource api/oauth2/jwks

OAuth2AuthorizationServerConfigurer registers NimbusJwkSetEndpointFilter only when it can resolve a JWKSource. It checks the HttpSecurity shared object first, otherwise looking the bean up by ResolvableType.forClassWithGenerics(JWKSource::class, SecurityContext::class) — which does not match the Kotlin JWKSource<SecurityContext> bean in JwtConfig. The filter was never added.

Passing the bean in as a parameter and setting it as the shared object makes registration deterministic instead of dependent on generic resolution.

How it was narrowed

observation conclusion
/api/oauth2/authorize → 401 on its customised path other auth-server filters register fine; settings are applied
discovery advertises jwks_uri: …/api/oauth2/jwks the settings bean is in use
/api/oauth2/jwksNoResourceFoundException this one filter is absent
/oauth2/jwks (default path) → 401 from the other chain not registered there either

That combination rules out routing, ingress and Cloudflare, and points at exactly one missing filter.

Both defects were required

A relying party needs both to verify a token.

Validation

  • 173 tests pass, lint and detekt clean.
  • The guard test from fix(auth): make the JWK Set endpoint publicly readable #50 still holds.
  • Real verification is the live endpoint after deploy — it must return {"keys":[…]} with content-type: application/json. I will confirm that against the published URL rather than inferring it from a green build, since the previous fix looked complete and was not.

Note

I should have caught this in #50. The 302 had an obvious cause and I stopped at it, rather than confirming the endpoint actually served a key set once the gate was removed — which is exactly the "verify the value, not the command" rule.

Puts the Hermes dashboard behind estate single sign-on instead of its own
password: the session that opens Grafana, Outline and Headlamp now opens
Hermes too.

Public client with PKCE, the same shape as headlamp and rabbitmq. That is
forced rather than chosen -- Hermes' dashboard takes only
HERMES_DASHBOARD_OIDC_ISSUER, _CLIENT_ID and _SCOPES, with no field for a
client secret, so a confidential client could not authenticate at all. The
upside is there is no secret in Vault and none to rotate.

The HERMES grant moves rather than disappears. Hermes' route carries no
forward-auth -- a middleware there would intercept the OIDC callback and
break the login before the dashboard sees the authorization code, which is
why outline is `direct` too -- so DOWNSTREAM_CLIENT_PERMISSIONS enforces
the grant at the authorize endpoint instead.

The redirect URIs are the one coupling worth watching: Hermes builds its
callback as <public_url>/auth/callback verbatim from
HERMES_DASHBOARD_PUBLIC_URL in fleet-infra. If the two drift the flow fails
at the redirect and the dashboard reports only a generic error, so the test
pins both spellings.
Vault's OIDC login failed at id_token verification:

  failed to verify signature: fetching keys oidc: failed to decode keys:
  expected Content-Type = application/json, got "text/html" ...
  invalid character '<' looking for beginning of value <!doctype html>

The HTML was the auth-ui SPA. /api/oauth2/jwks sat inside the
authorization-server filter chain under anyRequest().authenticated(), so
an unauthenticated fetch was redirected to /login and served the login
page instead of a JWK set. With Accept: application/json the same request
answers 401, which is the honest shape of it.

JWKS publishes public signing keys. Every relying party fetches it to
verify a signature and none of them holds a session, so it has to answer
unauthenticated -- the endpoint is public by specification.

Confirmed in-cluster through a port-forward, with no ingress and no
Cloudflare in the path, so the CDN challenge page in the reported error
was incidental rather than causal. The sibling discovery document was
never affected because its filter short-circuits before authorization,
which is what made this look like a routing or proxy fault.

The path is now a constant used both by AuthorizationServerSettings and
by the public list, and a test asserts the advertised endpoint is in that
list -- a rename that moves one without the other reintroduces the exact
outage, and nothing else in the suite would notice. Verified the test
fails against an empty list before it passed against the fix.

Nothing else in the chain is opened: the test also asserts authorize,
token, revoke, introspect and userinfo stay authenticated. Vault's token
exchange had already succeeded, so only key retrieval was broken.

This was invisible for months because relying parties cache the key set
after one successful fetch; it surfaces only on a cache expiry or a
restart.
Making the endpoint public (#50) removed the authorization gate but
uncovered the real fault beneath it: nothing served the path at all.
It now reaches the DispatcherServlet and fails as

  NoResourceFoundException: No static resource api/oauth2/jwks

OAuth2AuthorizationServerConfigurer registers NimbusJwkSetEndpointFilter
only when it can resolve a JWKSource. It checks the HttpSecurity shared
object first and otherwise looks the bean up by
ResolvableType.forClassWithGenerics(JWKSource::class, SecurityContext::class),
which does not match the Kotlin `JWKSource<SecurityContext>` bean in
JwtConfig. The filter was therefore never added.

Passing the bean in as a parameter and setting it as the shared object
makes the registration deterministic rather than dependent on generic
resolution.

Everything else on this chain was unaffected, which is what disguised
it: /api/oauth2/authorize answers on its customised path, and the
discovery document advertises the customised jwks_uri, so the settings
were plainly being applied and only this one filter was missing.

Both defects had to be fixed for a relying party to verify a token: with
only the first, the endpoint is reachable and returns 500; with only the
second, the filter exists but authorization redirects the caller to
/login before it runs.
@ExtraToast ExtraToast added type: bug Something is broken or behaving incorrectly. area: auth Authentication, authorization, sessions, or identity. component: security Security, permissions, secrets, or vulnerability handling. priority: P0 Critical; production outage, data loss, security incident, or migration blocker. labels Aug 30, 2026
@ExtraToast
ExtraToast merged commit 72970b1 into main Aug 30, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: auth Authentication, authorization, sessions, or identity. component: security Security, permissions, secrets, or vulnerability handling. priority: P0 Critical; production outage, data loss, security incident, or migration blocker. type: bug Something is broken or behaving incorrectly.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant