Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,7 +81,25 @@ class AuthorizationServerConfig(
fun authorizationServerSecurityFilterChain(
http: HttpSecurity,
corsConfigurationSource: CorsConfigurationSource,
jwkSource: JWKSource<SecurityContext>,
): 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<SecurityContext>` 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())

Expand All @@ -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 ->
Expand All @@ -123,6 +166,7 @@ class AuthorizationServerConfig(
buildVaultClient(vaultClientSecret),
buildHeadlampClient(),
buildImmichClient(),
buildHermesClient(),
)

// The JdbcOAuth2AuthorizationService constructor calls getColumnMetadata()
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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<String, ServicePermission> =
mapOf(
"grafana" to ServicePermission.GRAFANA,
Expand All @@ -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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <public_url>/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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <public_url>/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",
)
}
}
Loading