From 8d11ad2ba927301cd78e512a72a52178cb16bef9 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sat, 29 Aug 2026 22:16:09 +0200 Subject: [PATCH 1/4] 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 /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. --- .../auth/config/AuthorizationServerConfig.kt | 5 +++ .../auth/config/ServiceRegisteredClients.kt | 25 +++++++++++++++ .../auth/config/RegisteredClientsTest.kt | 32 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt index 4717002..a1a0288 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt @@ -123,6 +123,7 @@ class AuthorizationServerConfig( buildVaultClient(vaultClientSecret), buildHeadlampClient(), buildImmichClient(), + buildHermesClient(), ) // The JdbcOAuth2AuthorizationService constructor calls getColumnMetadata() @@ -242,6 +243,10 @@ class AuthorizationServerConfig( "headlamp" to ServicePermission.DASHBOARD, "rabbitmq" to ServicePermission.RABBITMQ, "immich" to ServicePermission.IMMICH, + // Hermes runs its own OIDC flow, so its route carries no + // forward-auth and the HERMES grant is enforced here at the + // authorize endpoint instead — the same shape as outline. + "hermes" to ServicePermission.HERMES, ) } } diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/ServiceRegisteredClients.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/ServiceRegisteredClients.kt index 4499e3c..a3396c7 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/ServiceRegisteredClients.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/ServiceRegisteredClients.kt @@ -104,6 +104,31 @@ fun buildHeadlampClient(): RegisteredClient = .tokenSettings(defaultTokenSettings()) .build() +fun buildHermesClient(): RegisteredClient = + RegisteredClient + .withId(deterministicId("hermes")) + .clientId("hermes") + // Public client with PKCE — same pattern as headlamp and rabbitmq. + // Hermes' dashboard takes only HERMES_DASHBOARD_OIDC_ISSUER, + // _CLIENT_ID and _SCOPES; it has no field for a client secret, so a + // confidential client could not be configured even if we wanted one. + // The dashboard proves possession of the auth code with the PKCE + // verifier instead, and there is no secret to rotate in Vault. + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + // Hermes builds the callback as /auth/callback verbatim, + // where public_url is HERMES_DASHBOARD_PUBLIC_URL. These must match + // that construction exactly or the flow fails at the redirect. + .redirectUri("https://hermes.jorisjonkers.dev/auth/callback") + .redirectUri("https://hermes.jorisjonkers.test/auth/callback") + .scope(OidcScopes.OPENID) + .scope(OidcScopes.PROFILE) + .scope(OidcScopes.EMAIL) + .clientSettings(noConsentSettings(requirePkce = true)) + .tokenSettings(defaultTokenSettings()) + .build() + fun buildImmichClient(): RegisteredClient = RegisteredClient .withId(deterministicId("immich")) diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/config/RegisteredClientsTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/config/RegisteredClientsTest.kt index 09d6d93..a686284 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/config/RegisteredClientsTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/config/RegisteredClientsTest.kt @@ -40,4 +40,36 @@ class RegisteredClientsTest { "http://localhost/callback", ) } + + @Test + fun `hermes is a public PKCE client whose redirect matches the dashboard callback`() { + val client = buildHermesClient() + + assertThat(client.id).isEqualTo(UUID.nameUUIDFromBytes("hermes".toByteArray()).toString()) + assertThat(client.clientId).isEqualTo("hermes") + // Hermes' dashboard has no client-secret field, so a confidential + // client could not authenticate at all. + assertThat(client.clientAuthenticationMethods).containsExactly(ClientAuthenticationMethod.NONE) + assertThat(client.clientSettings.isRequireProofKey).isTrue + assertThat(client.authorizationGrantTypes) + .containsExactlyInAnyOrder( + AuthorizationGrantType.AUTHORIZATION_CODE, + AuthorizationGrantType.REFRESH_TOKEN, + ) + assertThat(client.scopes) + .containsExactlyInAnyOrder( + OidcScopes.OPENID, + OidcScopes.PROFILE, + OidcScopes.EMAIL, + ) + // Hermes constructs the callback as /auth/callback + // verbatim. If these drift from HERMES_DASHBOARD_PUBLIC_URL in + // fleet-infra, the login fails at the redirect with a mismatch the + // dashboard reports only as a generic error. + assertThat(client.redirectUris) + .containsExactlyInAnyOrder( + "https://hermes.jorisjonkers.dev/auth/callback", + "https://hermes.jorisjonkers.test/auth/callback", + ) + } } From 15e21d2aac8627a759ad82ad330611d00eec5d9e Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sun, 30 Aug 2026 09:47:45 +0200 Subject: [PATCH 2/4] 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 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. --- .../auth/config/AuthorizationServerConfig.kt | 38 ++++++++++++- .../AuthorizationServerPublicEndpointsTest.kt | 55 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 api/src/test/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerPublicEndpointsTest.kt diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt index a1a0288..dc886fd 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt @@ -95,7 +95,31 @@ class AuthorizationServerConfig( .securityMatcher(oauthEndpoints) .cors { it.configurationSource(corsConfigurationSource) } .with(authServerConfigurer, Customizer.withDefaults()) - .authorizeHttpRequests { it.anyRequest().authenticated() } + .authorizeHttpRequests { authorize -> + // The JWK Set endpoint publishes public signing keys and must be + // readable without authentication: every relying party fetches it + // to verify an id_token signature, and none of them has a session. + // + // Without this, anyRequest().authenticated() gated it. Vault's OIDC + // login exchanged its code successfully and then failed verifying + // the id_token, because fetching the keys returned the auth-ui SPA: + // + // failed to verify signature: fetching keys oidc: failed to + // decode keys: expected Content-Type = application/json, got + // "text/html" ... invalid character '<' + // + // The HTML was /login. With an Accept: application/json request the + // same endpoint answered 401, which is the honest shape of the bug. + // + // The sibling discovery document was never affected -- its filter + // short-circuits before authorization -- so this looked like a + // Cloudflare or routing fault long before it looked like an + // authorization rule. + PUBLIC_OAUTH2_ENDPOINTS.forEach { endpoint -> + authorize.requestMatchers(PathPatternRequestMatcher.pathPattern(endpoint)).permitAll() + } + authorize.anyRequest().authenticated() + } .addFilterAfter(downstreamClientAuthorizationFilter(), SecurityContextHolderFilter::class.java) .securityContext { ctx -> ctx.securityContextRepository(HttpSessionSecurityContextRepository()) @@ -189,7 +213,7 @@ class AuthorizationServerConfig( .issuer(issuer) .authorizationEndpoint("/api/oauth2/authorize") .tokenEndpoint("/api/oauth2/token") - .jwkSetEndpoint("/api/oauth2/jwks") + .jwkSetEndpoint(JWK_SET_ENDPOINT) .tokenRevocationEndpoint("/api/oauth2/revoke") .tokenIntrospectionEndpoint("/api/oauth2/introspect") .oidcUserInfoEndpoint("/api/userinfo") @@ -234,6 +258,16 @@ class AuthorizationServerConfig( } companion object { + /** + * Endpoints inside the authorization-server filter chain that must answer + * without authentication. Kept as a list rather than inlined so + * [AuthorizationServerConfigTest] can assert it still covers the JWK Set + * endpoint this server actually advertises in its discovery document. + */ + const val JWK_SET_ENDPOINT = "/api/oauth2/jwks" + + val PUBLIC_OAUTH2_ENDPOINTS = listOf(JWK_SET_ENDPOINT) + private val DOWNSTREAM_CLIENT_PERMISSIONS: Map = mapOf( "grafana" to ServicePermission.GRAFANA, diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerPublicEndpointsTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerPublicEndpointsTest.kt new file mode 100644 index 0000000..d65c3d8 --- /dev/null +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerPublicEndpointsTest.kt @@ -0,0 +1,55 @@ +package com.jorisjonkers.personalstack.auth.config + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** + * The JWK Set endpoint must stay publicly readable. + * + * It publishes public signing keys, and every relying party fetches it to verify + * an id_token signature without holding a session. When it was gated behind + * `anyRequest().authenticated()`, Vault's OIDC login exchanged its authorization + * code successfully and then failed verification, because fetching the keys + * redirected to /login and returned the auth-ui SPA: + * + * failed to verify signature: fetching keys oidc: failed to decode keys: + * expected Content-Type = application/json, got "text/html" + * + * The failure was invisible for months: JWKS is cached by relying parties after + * one successful fetch, so it only surfaces on a cache expiry or a restart. + */ +class AuthorizationServerPublicEndpointsTest { + @Test + fun `the advertised jwk set endpoint is one of the public endpoints`() { + // The bug this guards is a rename: moving jwkSetEndpoint without adding + // the new path to the public list restores the exact outage above, and + // nothing else in the suite would notice. + assertThat(AuthorizationServerConfig.PUBLIC_OAUTH2_ENDPOINTS) + .contains(AuthorizationServerConfig.JWK_SET_ENDPOINT) + } + + @Test + fun `the jwk set endpoint sits under the authorization server security matcher`() { + // The chain only matches /api/oauth2/**, /api/userinfo, /api/connect/logout + // and /.well-known/**. A public endpoint outside those prefixes would be + // permitted in a chain that never sees the request. + assertThat(AuthorizationServerConfig.PUBLIC_OAUTH2_ENDPOINTS) + .allSatisfy { endpoint -> + assertThat(endpoint).startsWith("/api/oauth2/") + } + } + + @Test + fun `no token-issuing or user-facing endpoint is public`() { + // permitAll on any of these would hand out tokens or user data without a + // session. Only key material belongs in the public list. + assertThat(AuthorizationServerConfig.PUBLIC_OAUTH2_ENDPOINTS) + .doesNotContain( + "/api/oauth2/authorize", + "/api/oauth2/token", + "/api/oauth2/revoke", + "/api/oauth2/introspect", + "/api/userinfo", + ) + } +} From 11d0430089a8059b8ab104b685b2220ea0a57206 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sun, 30 Aug 2026 09:53:31 +0200 Subject: [PATCH 3/4] style(auth): satisfy ktlint chain-method-continuation --- .../personalstack/auth/config/AuthorizationServerConfig.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt index dc886fd..a664b0f 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt @@ -119,8 +119,7 @@ class AuthorizationServerConfig( authorize.requestMatchers(PathPatternRequestMatcher.pathPattern(endpoint)).permitAll() } authorize.anyRequest().authenticated() - } - .addFilterAfter(downstreamClientAuthorizationFilter(), SecurityContextHolderFilter::class.java) + }.addFilterAfter(downstreamClientAuthorizationFilter(), SecurityContextHolderFilter::class.java) .securityContext { ctx -> ctx.securityContextRepository(HttpSessionSecurityContextRepository()) }.exceptionHandling { exceptions -> From 6a383f460c6ef34bdec6782278372607e576ac8a Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sun, 30 Aug 2026 10:28:15 +0200 Subject: [PATCH 4/4] 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` 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. --- .../auth/config/AuthorizationServerConfig.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt index a664b0f..c3c37fb 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/AuthorizationServerConfig.kt @@ -3,6 +3,8 @@ package com.jorisjonkers.personalstack.auth.config import com.jorisjonkers.personalstack.auth.domain.model.ServicePermission import com.jorisjonkers.personalstack.auth.domain.model.UserId import com.jorisjonkers.personalstack.auth.infrastructure.security.AuthenticatedUser +import com.nimbusds.jose.jwk.source.JWKSource +import com.nimbusds.jose.proc.SecurityContext import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -79,7 +81,25 @@ class AuthorizationServerConfig( fun authorizationServerSecurityFilterChain( http: HttpSecurity, corsConfigurationSource: CorsConfigurationSource, + jwkSource: JWKSource, ): SecurityFilterChain { + // Hand the JWK source over explicitly. + // + // OAuth2AuthorizationServerConfigurer registers NimbusJwkSetEndpointFilter + // only when it can resolve a JWKSource, and it looks first at this shared + // object before falling back to a bean lookup by + // ResolvableType.forClassWithGenerics(JWKSource::class, SecurityContext::class). + // That fallback does not match the Kotlin `JWKSource` bean + // in JwtConfig, so the filter was never registered and /api/oauth2/jwks + // reached the DispatcherServlet instead: + // + // NoResourceFoundException: No static resource api/oauth2/jwks + // + // Every other endpoint on this chain was fine -- /api/oauth2/authorize + // answers on its customised path -- which is what made the JWK Set look + // like a routing problem rather than a missing filter. + http.setSharedObject(JWKSource::class.java, jwkSource) + val authServerConfigurer = OAuth2AuthorizationServerConfigurer() authServerConfigurer.oidc(Customizer.withDefaults())