Skip to content

fix(auth): make the JWK Set endpoint publicly readable - #50

Merged
ExtraToast merged 3 commits into
mainfrom
fix/public-jwks-endpoint
Aug 30, 2026
Merged

fix(auth): make the JWK Set endpoint publicly readable#50
ExtraToast merged 3 commits into
mainfrom
fix/public-jwks-endpoint

Conversation

@ExtraToast

Copy link
Copy Markdown
Contributor

Summary

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

JWKS publishes public signing keys. Every relying party fetches it to verify a signature and none holds a session — it is public by specification.

Diagnosis

request result
/api/oauth2/jwks (Accept: */*) 302 → /login
/api/oauth2/jwks (Accept: application/json) 401 — the honest shape
/.well-known/openid-configuration 200 JSON — same chain, unaffected

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. The discovery document is unaffected because its filter short-circuits before authorization — which is exactly what made this look like a routing or proxy fault rather than an authorization rule.

Vault's token exchange had already succeeded, so key retrieval was the only broken step.

Not a regression from recent work

  • The faulty line dates to 2026-03-30, carried through the June rebrand.
  • Running pods started 2026-08-27 on v0.5.1.
  • It stayed invisible because relying parties cache the key set after one successful fetch; it surfaces only on a cache expiry or restart.

Validation

  • Full suite: 173 passed.
  • The path is now a constant used by both AuthorizationServerSettings and the public list, with a test asserting the advertised endpoint is in that list. A rename that moves one without the other reintroduces the exact outage and nothing else would notice.
  • Verified the test fails before it passes: against an empty public list, Results: FAILURE (3 tests, 1 failed).
  • Nothing else is opened — the test also asserts authorize, token, revoke, introspect and userinfo stay authenticated.

Blast radius

This blocks every OIDC relying party that must fetch keys, not just Vault — including Hermes, whose SSO login would fail identically on this endpoint.

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.
@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 6d6e230 into main Aug 30, 2026
9 checks passed
ExtraToast added a commit that referenced this pull request Aug 30, 2026
* feat(auth): register hermes as an OIDC client

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.

* fix(auth): make the JWK Set endpoint publicly readable

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.

* style(auth): satisfy ktlint chain-method-continuation

* fix(auth): register the JWK Set endpoint filter

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