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..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()) @@ -95,8 +115,31 @@ class AuthorizationServerConfig( .securityMatcher(oauthEndpoints) .cors { it.configurationSource(corsConfigurationSource) } .with(authServerConfigurer, Customizer.withDefaults()) - .authorizeHttpRequests { it.anyRequest().authenticated() } - .addFilterAfter(downstreamClientAuthorizationFilter(), SecurityContextHolderFilter::class.java) + .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()) }.exceptionHandling { exceptions -> @@ -123,6 +166,7 @@ class AuthorizationServerConfig( buildVaultClient(vaultClientSecret), buildHeadlampClient(), buildImmichClient(), + buildHermesClient(), ) // The JdbcOAuth2AuthorizationService constructor calls getColumnMetadata() @@ -188,7 +232,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") @@ -233,6 +277,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, @@ -242,6 +296,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/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", + ) + } +} 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", + ) + } }