fix(auth): make the JWK Set endpoint publicly readable - #50
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Vault's OIDC login failed at id_token verification:
The HTML was the auth-ui SPA.
/api/oauth2/jwkssits inside the authorization-server filter chain underanyRequest().authenticated(), so an unauthenticated fetch was redirected to/loginand 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
/api/oauth2/jwks(Accept: */*)/api/oauth2/jwks(Accept: application/json)/.well-known/openid-configurationConfirmed 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
v0.5.1.Validation
AuthorizationServerSettingsand 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.Results: FAILURE (3 tests, 1 failed).authorize,token,revoke,introspectanduserinfostay 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.