From d08e9bd669035c2f7393e44fda2f7dd8431b4052 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Fri, 23 Oct 2026 12:00:56 +0200 Subject: [PATCH 1/2] feat(auth): register the Findability Explorer as an OIDC client The Findability Explorer at digital-strategy.jorisjonkers.dev signs its users in against this authorization server. Adds the registered client, a FINDABILITY_EXPLORER service permission, and the client_id -> permission mapping that gates the authorize endpoint -- the tribelt shape. The client is confidential (client_secret_basic and client_secret_post) and also requires PKCE, with authorization_code and refresh_token and the default token settings. Redirect is /auth/callback, post-logout redirect is the site root, with the .test variants. The secret comes from auth.clients.findability-explorer.secret, supplied as AUTH_CLIENTS_FINDABILITY_EXPLORER_SECRET, defaulting to findability-explorer-secret for local runs and tests. The explorer reads ROLE_ADMIN from the roles claim and renews its tokens to pick up role changes; the integration tests run the real flow to a code and show a promotion made after sign-in in the refreshed ID token. No migration: FINDABILITY_EXPLORER fits user_service_permissions.service VARCHAR(50), which has no check constraint. --- .../auth/flow/ForwardAuthIntegrationTest.kt | 12 +- .../auth/flow/OAuth2FlowIntegrationTest.kt | 129 ++++++++++++++++-- .../auth/config/AuthorizationServerConfig.kt | 6 + .../FindabilityExplorerRegisteredClient.kt | 29 ++++ .../auth/domain/model/ServicePermission.kt | 4 + .../auth/config/RegisteredClientsTest.kt | 38 ++++++ .../domain/model/ServicePermissionTest.kt | 2 + deploy/auth.project.yml | 1 + 8 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/FindabilityExplorerRegisteredClient.kt diff --git a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/ForwardAuthIntegrationTest.kt b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/ForwardAuthIntegrationTest.kt index 5c581fb..83215fa 100644 --- a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/ForwardAuthIntegrationTest.kt +++ b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/ForwardAuthIntegrationTest.kt @@ -198,7 +198,17 @@ class ForwardAuthIntegrationTest : IntegrationTestBase() { @Test fun `USER with no service permissions is denied access to all protected services`() { - listOf("vault", "stalwart", "n8n", "grafana", "dashboard", "notes", "tribelt", "grimoire").forEach { subdomain -> + listOf( + "vault", + "stalwart", + "n8n", + "grafana", + "dashboard", + "notes", + "tribelt", + "grimoire", + "digital-strategy", + ).forEach { subdomain -> mockMvc .get("/api/v1/auth/verify") { with( diff --git a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/OAuth2FlowIntegrationTest.kt b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/OAuth2FlowIntegrationTest.kt index b4c4687..3d474ef 100644 --- a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/OAuth2FlowIntegrationTest.kt +++ b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/OAuth2FlowIntegrationTest.kt @@ -101,6 +101,9 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { private const val ESTATE_DASHBOARD_CLIENT_ID = "estate-dashboard" private const val ESTATE_DASHBOARD_REDIRECT_URI = "https://estate.jorisjonkers.test/auth/callback" private const val ESTATE_DASHBOARD_CLIENT_SECRET = "estate-dashboard-secret" + private const val FINDABILITY_EXPLORER_CLIENT_ID = "findability-explorer" + private const val FINDABILITY_EXPLORER_REDIRECT_URI = "https://digital-strategy.jorisjonkers.test/auth/callback" + private const val FINDABILITY_EXPLORER_CLIENT_SECRET = "findability-explorer-secret" } @BeforeEach @@ -566,6 +569,15 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { assertThat(tribeltClient.clientSettings.isRequireProofKey).isTrue() assertThat(tribeltClient.scopes).contains(OidcScopes.OPENID, OidcScopes.PROFILE, OidcScopes.EMAIL) + val findabilityExplorerClient = registeredClientRepository.findByClientId(FINDABILITY_EXPLORER_CLIENT_ID) + assertThat(findabilityExplorerClient).isNotNull() + assertThat(findabilityExplorerClient!!.redirectUris).contains(FINDABILITY_EXPLORER_REDIRECT_URI) + assertThat(findabilityExplorerClient.clientAuthenticationMethods) + .contains(ClientAuthenticationMethod.CLIENT_SECRET_BASIC, ClientAuthenticationMethod.CLIENT_SECRET_POST) + assertThat(findabilityExplorerClient.clientSettings.isRequireProofKey).isTrue() + assertThat(findabilityExplorerClient.authorizationGrantTypes).contains(AuthorizationGrantType.REFRESH_TOKEN) + assertThat(findabilityExplorerClient.scopes).contains(OidcScopes.OPENID, OidcScopes.PROFILE, OidcScopes.EMAIL) + val grimoireClient = registeredClientRepository.findByClientId("grimoire") assertThat(grimoireClient).isNotNull() assertThat(grimoireClient!!.redirectUris).contains("https://grimoire.jorisjonkers.test/oidc/callback") @@ -600,6 +612,7 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { Triple("n8n", N8N_REDIRECT_URI, "openid profile email"), Triple("outline", OUTLINE_REDIRECT_URI, "openid profile email"), Triple(TRIBELT_CLIENT_ID, TRIBELT_REDIRECT_URI, "openid profile email"), + Triple(FINDABILITY_EXPLORER_CLIENT_ID, FINDABILITY_EXPLORER_REDIRECT_URI, "openid profile email"), Triple("rabbitmq", RABBITMQ_REDIRECT_URI, "openid profile email"), Triple(HEADLAMP_CLIENT_ID, HEADLAMP_REDIRECT_URI, "openid profile email groups"), ) @@ -741,8 +754,9 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { assertThat(decodedIdToken.getClaimAsStringList("roles")).contains("ROLE_USER", "SERVICE_TRIBELT") } - // Runs the estate dashboard's own sign-in: authorize with PKCE, then exchange the code with - // the client secret over client_secret_basic. Returns the token response. + // Runs a confidential PKCE client's own sign-in (the estate dashboard by default): authorize + // with PKCE, then exchange the code with the client secret over client_secret_basic. Returns + // the token response. // // The authorize parameters travel in the query string, not through MockMvc's param(): // the authorization server reads a GET authorization request from the query string @@ -750,18 +764,23 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { // response_type" before the session is ever consulted. The param()-based tests above // accept that 400 and return early, so they never reach a code. A URI, not a template // string, so the already-encoded redirect_uri is not encoded a second time. - private fun signInToEstateDashboard(session: MockHttpSession): JsonNode { + private fun signInWithPkce( + session: MockHttpSession, + clientId: String = ESTATE_DASHBOARD_CLIENT_ID, + redirectUri: String = ESTATE_DASHBOARD_REDIRECT_URI, + clientSecret: String = ESTATE_DASHBOARD_CLIENT_SECRET, + ): JsonNode { val codeVerifier = generateCodeVerifier() val codeChallenge = generateCodeChallenge(codeVerifier) val query = mapOf( "response_type" to "code", - "client_id" to ESTATE_DASHBOARD_CLIENT_ID, - "redirect_uri" to ESTATE_DASHBOARD_REDIRECT_URI, + "client_id" to clientId, + "redirect_uri" to redirectUri, "scope" to "openid profile email", "code_challenge" to codeChallenge, "code_challenge_method" to "S256", - "state" to "estate-dashboard-sign-in", + "state" to "$clientId-sign-in", ).entries.joinToString("&") { (name, value) -> // %20, not URLEncoder's form-style +: the server takes a + in the query // string literally, and "openid+profile+email" is an invalid_scope. @@ -779,23 +798,29 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { .describedAs("authorize answered %s", authorizeResult.response.errorMessage) .isEqualTo(302) val location = authorizeResult.response.getHeader("Location") - assertThat(location).startsWith(ESTATE_DASHBOARD_REDIRECT_URI).contains("code=") + assertThat(location).startsWith(redirectUri).contains("code=") val code = location!!.substringAfter("code=").substringBefore("&") - return postEstateDashboardToken( + return postClientToken( "grant_type=authorization_code" + "&code=${URLEncoder.encode(code, StandardCharsets.UTF_8)}" + - "&redirect_uri=${URLEncoder.encode(ESTATE_DASHBOARD_REDIRECT_URI, StandardCharsets.UTF_8)}" + + "&redirect_uri=${URLEncoder.encode(redirectUri, StandardCharsets.UTF_8)}" + "&code_verifier=$codeVerifier", + clientId, + clientSecret, ) } - private fun postEstateDashboardToken(form: String): JsonNode { + private fun postClientToken( + form: String, + clientId: String = ESTATE_DASHBOARD_CLIENT_ID, + clientSecret: String = ESTATE_DASHBOARD_CLIENT_SECRET, + ): JsonNode { val tokenResult = mockMvc .post("/api/oauth2/token") { contentType = MediaType.APPLICATION_FORM_URLENCODED - with(httpBasic(ESTATE_DASHBOARD_CLIENT_ID, ESTATE_DASHBOARD_CLIENT_SECRET)) + with(httpBasic(clientId, clientSecret)) content = form }.andExpect { status { isOk() } } .andReturn() @@ -814,7 +839,7 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { .execute() val session = extractSession(doSessionLogin(username, password))!! - val tokenJson = signInToEstateDashboard(session) + val tokenJson = signInWithPkce(session) val idToken = jwtDecoder.decode(tokenJson["id_token"].asText()) assertThat(idToken.audience).contains(ESTATE_DASHBOARD_CLIENT_ID) @@ -824,7 +849,7 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { // The dashboard renews its session through the token endpoint; the renewed ID token // must still carry the role its admin check reads. val refreshedJson = - postEstateDashboardToken( + postClientToken( "grant_type=refresh_token" + "&refresh_token=${URLEncoder.encode(tokenJson["refresh_token"].asText(), StandardCharsets.UTF_8)}", ) @@ -841,7 +866,7 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { // No authorize-endpoint gate: the dashboard shows its own Not-an-admin page, so auth // must hand a non-admin a code and let the roles claim carry the refusal. - val tokenJson = signInToEstateDashboard(session) + val tokenJson = signInWithPkce(session) val idToken = jwtDecoder.decode(tokenJson["id_token"].asText()) assertThat(idToken.getClaimAsStringList("roles")) @@ -849,6 +874,70 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { .doesNotContain("ROLE_ADMIN") } + private fun signInToFindabilityExplorer(session: MockHttpSession): JsonNode = + signInWithPkce( + session, + FINDABILITY_EXPLORER_CLIENT_ID, + FINDABILITY_EXPLORER_REDIRECT_URI, + FINDABILITY_EXPLORER_CLIENT_SECRET, + ) + + private fun refreshFindabilityExplorerToken(refreshToken: String): JsonNode = + postClientToken( + "grant_type=refresh_token&refresh_token=${URLEncoder.encode(refreshToken, StandardCharsets.UTF_8)}", + FINDABILITY_EXPLORER_CLIENT_ID, + FINDABILITY_EXPLORER_CLIENT_SECRET, + ) + + @Test + fun `findability explorer grantee signs in and a token renewal re-reads the roles claim`() { + val username = uniqueUsername() + val password = "securepass123" + registerAndConfirmUser(username, password) + grantServicePermission(username, ServicePermission.FINDABILITY_EXPLORER) + val session = extractSession(doSessionLogin(username, password))!! + + val tokenJson = signInToFindabilityExplorer(session) + + val idToken = jwtDecoder.decode(tokenJson["id_token"].asText()) + assertThat(idToken.audience).contains(FINDABILITY_EXPLORER_CLIENT_ID) + assertThat(idToken.getClaimAsStringList("roles")) + .contains("ROLE_USER", "SERVICE_FINDABILITY_EXPLORER") + .doesNotContain("ROLE_ADMIN") + assertThat(tokenJson["refresh_token"]).isNotNull() + + // The explorer renews its tokens on a short interval to pick up role changes: a promotion + // made after sign-in must show in the renewed ID token, without signing in again. + dsl + .update(APP_USER) + .set(APP_USER.ROLE, "ADMIN") + .where(APP_USER.USERNAME.eq(username)) + .execute() + val refreshedJson = refreshFindabilityExplorerToken(tokenJson["refresh_token"].asText()) + val refreshedIdToken = jwtDecoder.decode(refreshedJson["id_token"].asText()) + assertThat(refreshedIdToken.getClaimAsStringList("roles")) + .contains("ROLE_ADMIN", "SERVICE_FINDABILITY_EXPLORER") + assertThat(refreshedJson["refresh_token"]).isNotNull() + } + + @Test + fun `admin without an explicit grant signs in to the findability explorer`() { + val username = uniqueUsername() + val password = "securepass123" + registerAndConfirmUser(username, password) + dsl + .update(APP_USER) + .set(APP_USER.ROLE, "ADMIN") + .where(APP_USER.USERNAME.eq(username)) + .execute() + val session = extractSession(doSessionLogin(username, password))!! + + val tokenJson = signInToFindabilityExplorer(session) + + val idToken = jwtDecoder.decode(tokenJson["id_token"].asText()) + assertThat(idToken.getClaimAsStringList("roles")).contains("ROLE_ADMIN", "SERVICE_FINDABILITY_EXPLORER") + } + @Test fun `user with DASHBOARD permission gets k8s-admin group claim on headlamp id token`() { val headlampClient = registeredClientRepository.findByClientId(HEADLAMP_CLIENT_ID) @@ -956,6 +1045,12 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { "openid profile email", true, ), + OidcClientRequest( + FINDABILITY_EXPLORER_CLIENT_ID, + FINDABILITY_EXPLORER_REDIRECT_URI, + "openid profile email", + true, + ), ) requests.forEach { (clientId, redirectUri, scope, requiresPkce) -> @@ -1017,6 +1112,12 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() { "openid profile email", TRIBELT_CLIENT_SECRET, ), + ConfidentialClientRequest( + FINDABILITY_EXPLORER_CLIENT_ID, + FINDABILITY_EXPLORER_REDIRECT_URI, + "openid profile email", + FINDABILITY_EXPLORER_CLIENT_SECRET, + ), ) requests.forEach { (clientId, redirectUri, _, clientSecret) -> 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 7da76eb..bcee99f 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 @@ -164,6 +164,10 @@ class AuthorizationServerConfig( fun registeredClientRepository( // No fallback: without a secret the grimoire client is not registered. @Value("\${auth.clients.grimoire.secret:}") grimoireSecret: String, + // Supplied as AUTH_CLIENTS_FINDABILITY_EXPLORER_SECRET; the dash-dropping + // AUTH_CLIENTS_FINDABILITYEXPLORER_SECRET does not resolve it (see estate-dashboard). + @Value("\${auth.clients.findability-explorer.secret:findability-explorer-secret}") + findabilityExplorerSecret: String, ): RegisteredClientRepository = InMemoryRegisteredClientRepository( listOfNotNull( @@ -182,6 +186,7 @@ class AuthorizationServerConfig( buildTribeltClient(clientSecrets.tribelt), buildEstateDashboardClient(clientSecrets.estateDashboard), grimoireClientOrNull(grimoireSecret), + buildFindabilityExplorerClient(findabilityExplorerSecret), ), ) @@ -320,6 +325,7 @@ class AuthorizationServerConfig( // Grimoire also checks the grant in the roles claim; refusing here keeps a code // from being issued to somebody without it. "grimoire" to ServicePermission.GRIMOIRE, + "findability-explorer" to ServicePermission.FINDABILITY_EXPLORER, ) } } diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/FindabilityExplorerRegisteredClient.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/FindabilityExplorerRegisteredClient.kt new file mode 100644 index 0000000..22bd4a4 --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/FindabilityExplorerRegisteredClient.kt @@ -0,0 +1,29 @@ +package com.jorisjonkers.personalstack.auth.config + +import org.springframework.security.oauth2.core.AuthorizationGrantType +import org.springframework.security.oauth2.core.ClientAuthenticationMethod +import org.springframework.security.oauth2.core.oidc.OidcScopes +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient + +// The Findability Explorer at digital-strategy.jorisjonkers.dev: a confidential client that also +// sends PKCE, the tribelt shape. It keeps the refresh token to re-read the roles claim, where +// ROLE_ADMIN is its admin role; the FINDABILITY_EXPLORER grant is enforced at the authorize endpoint. +fun buildFindabilityExplorerClient(secret: String): RegisteredClient = + RegisteredClient + .withId(deterministicId("findability-explorer")) + .clientId("findability-explorer") + .clientSecret("{noop}$secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri("https://digital-strategy.jorisjonkers.dev/auth/callback") + .redirectUri("https://digital-strategy.jorisjonkers.test/auth/callback") + .postLogoutRedirectUri("https://digital-strategy.jorisjonkers.dev/") + .postLogoutRedirectUri("https://digital-strategy.jorisjonkers.test/") + .scope(OidcScopes.OPENID) + .scope(OidcScopes.PROFILE) + .scope(OidcScopes.EMAIL) + .clientSettings(noConsentSettings(requirePkce = true)) + .tokenSettings(defaultTokenSettings()) + .build() diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermission.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermission.kt index aad6bfd..445f72e 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermission.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermission.kt @@ -96,6 +96,10 @@ enum class ServicePermission( // Grimoire D&D table at grimoire.jorisjonkers.dev, behind forward-auth; this entry is its only per-user gate. GRIMOIRE("grimoire"), + + // The Findability Explorer at digital-strategy.jorisjonkers.dev runs its own OIDC flow; the + // grant is enforced at the authorize endpoint, and here too should forward-auth front the host. + FINDABILITY_EXPLORER("digital-strategy"), ; val subdomains: Set = subdomains.toSet() 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 413e6c4..3bf8a62 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 @@ -191,4 +191,42 @@ class RegisteredClientsTest { // What the Vault template renders for a key that is not provisioned yet. assertThat(grimoireClientOrNull("")).isNull() } + + @Test + fun `findability-explorer is a confidential PKCE client whose redirect matches the explorer callback`() { + val client = buildFindabilityExplorerClient("findability-explorer-test-secret") + + assertThat(client.id).isEqualTo(UUID.nameUUIDFromBytes("findability-explorer".toByteArray()).toString()) + assertThat(client.clientId).isEqualTo("findability-explorer") + assertThat(client.clientSecret).isEqualTo("{noop}findability-explorer-test-secret") + assertThat(client.clientAuthenticationMethods) + .containsExactlyInAnyOrder( + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, + ClientAuthenticationMethod.CLIENT_SECRET_POST, + ) + assertThat(client.clientSettings.isRequireProofKey).isTrue + assertThat(client.clientSettings.isRequireAuthorizationConsent).isFalse + assertThat(client.authorizationGrantTypes) + .containsExactlyInAnyOrder( + AuthorizationGrantType.AUTHORIZATION_CODE, + AuthorizationGrantType.REFRESH_TOKEN, + ) + assertThat(client.scopes) + .containsExactlyInAnyOrder( + OidcScopes.OPENID, + OidcScopes.PROFILE, + OidcScopes.EMAIL, + ) + assertThat(client.redirectUris) + .containsExactlyInAnyOrder( + "https://digital-strategy.jorisjonkers.dev/auth/callback", + "https://digital-strategy.jorisjonkers.test/auth/callback", + ) + assertThat(client.postLogoutRedirectUris) + .containsExactlyInAnyOrder( + "https://digital-strategy.jorisjonkers.dev/", + "https://digital-strategy.jorisjonkers.test/", + ) + assertThat(client.tokenSettings.isReuseRefreshTokens).isFalse + } } diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermissionTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermissionTest.kt index 1a7dae9..8d58ffa 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermissionTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermissionTest.kt @@ -42,6 +42,8 @@ class ServicePermissionTest { "tribelt.jorisjonkers.test, TRIBELT", "grimoire.jorisjonkers.dev, GRIMOIRE", "grimoire.jorisjonkers.test, GRIMOIRE", + "digital-strategy.jorisjonkers.dev, FINDABILITY_EXPLORER", + "digital-strategy.jorisjonkers.test, FINDABILITY_EXPLORER", "hindsight.jorisjonkers.dev, MEMORY_PRIVATE", "hindsight-private.jorisjonkers.dev, MEMORY_PRIVATE", "hindsight-api.jorisjonkers.dev, MEMORY_API", diff --git a/deploy/auth.project.yml b/deploy/auth.project.yml index 771ef2d..c6c71d8 100644 --- a/deploy/auth.project.yml +++ b/deploy/auth.project.yml @@ -82,6 +82,7 @@ applications: - path: secret/data/auth-api keys: - auth.clients.estate-dashboard.secret + - auth.clients.findability-explorer.secret - auth.clients.grafana.secret - auth.clients.grimoire.secret - auth.clients.n8n.secret From 662cdc11ea53d26290aa955cc4b16fc9f3e1e79d Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Fri, 23 Oct 2026 12:04:12 +0200 Subject: [PATCH 2/2] feat(auth): register findability-explorer only once a real secret is supplied The grimoire shape: auth.clients.findability-explorer.secret has no default, and the client is not registered for a blank secret or the a Vault template renders while the key is unprovisioned, so production never carries a client with a guessable secret. The integration test base supplies a secret, as it does for grimoire. --- .../personalstack/auth/IntegrationTestBase.kt | 2 ++ .../auth/config/AuthorizationServerConfig.kt | 9 ++++----- .../config/FindabilityExplorerRegisteredClient.kt | 6 ++++++ .../auth/config/GrimoireRegisteredClient.kt | 2 +- .../auth/config/RegisteredClientsTest.kt | 12 ++++++++++++ 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/IntegrationTestBase.kt b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/IntegrationTestBase.kt index c0941cb..4bb1b58 100644 --- a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/IntegrationTestBase.kt +++ b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/IntegrationTestBase.kt @@ -61,6 +61,8 @@ abstract class IntegrationTestBase { fun configureProperties(registry: DynamicPropertyRegistry) { // The grimoire client has no fallback secret, so the tests supply one to register it. registry.add("auth.clients.grimoire.secret") { "grimoire-secret" } + // Nor does the findability-explorer client. + registry.add("auth.clients.findability-explorer.secret") { "findability-explorer-secret" } registry.add("spring.datasource.url") { postgres.jdbcUrl } registry.add("spring.datasource.username") { postgres.username } registry.add("spring.datasource.password") { postgres.password } 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 bcee99f..cd477bb 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 @@ -164,10 +164,9 @@ class AuthorizationServerConfig( fun registeredClientRepository( // No fallback: without a secret the grimoire client is not registered. @Value("\${auth.clients.grimoire.secret:}") grimoireSecret: String, - // Supplied as AUTH_CLIENTS_FINDABILITY_EXPLORER_SECRET; the dash-dropping - // AUTH_CLIENTS_FINDABILITYEXPLORER_SECRET does not resolve it (see estate-dashboard). - @Value("\${auth.clients.findability-explorer.secret:findability-explorer-secret}") - findabilityExplorerSecret: String, + // No fallback either. Supplied as AUTH_CLIENTS_FINDABILITY_EXPLORER_SECRET; the + // dash-dropping AUTH_CLIENTS_FINDABILITYEXPLORER_SECRET does not resolve it. + @Value("\${auth.clients.findability-explorer.secret:}") findabilityExplorerSecret: String, ): RegisteredClientRepository = InMemoryRegisteredClientRepository( listOfNotNull( @@ -186,7 +185,7 @@ class AuthorizationServerConfig( buildTribeltClient(clientSecrets.tribelt), buildEstateDashboardClient(clientSecrets.estateDashboard), grimoireClientOrNull(grimoireSecret), - buildFindabilityExplorerClient(findabilityExplorerSecret), + findabilityExplorerClientOrNull(findabilityExplorerSecret), ), ) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/FindabilityExplorerRegisteredClient.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/FindabilityExplorerRegisteredClient.kt index 22bd4a4..ed948f3 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/FindabilityExplorerRegisteredClient.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/FindabilityExplorerRegisteredClient.kt @@ -8,6 +8,7 @@ import org.springframework.security.oauth2.server.authorization.client.Registere // The Findability Explorer at digital-strategy.jorisjonkers.dev: a confidential client that also // sends PKCE, the tribelt shape. It keeps the refresh token to re-read the roles claim, where // ROLE_ADMIN is its admin role; the FINDABILITY_EXPLORER grant is enforced at the authorize endpoint. +// Built only through findabilityExplorerClientOrNull: there is no fallback secret. fun buildFindabilityExplorerClient(secret: String): RegisteredClient = RegisteredClient .withId(deterministicId("findability-explorer")) @@ -27,3 +28,8 @@ fun buildFindabilityExplorerClient(secret: String): RegisteredClient = .clientSettings(noConsentSettings(requirePkce = true)) .tokenSettings(defaultTokenSettings()) .build() + +// No fallback secret, the grimoire shape: until its Vault key is provisioned the client is not +// registered at all, rather than registered with a secret anybody could guess. +fun findabilityExplorerClientOrNull(secret: String): RegisteredClient? = + secret.takeIf { it.isNotBlank() && it != UNPROVISIONED_SECRET }?.let(::buildFindabilityExplorerClient) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/GrimoireRegisteredClient.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/GrimoireRegisteredClient.kt index 70d1e8a..ff9ddc4 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/GrimoireRegisteredClient.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/GrimoireRegisteredClient.kt @@ -27,7 +27,7 @@ fun buildGrimoireClient(secret: String): RegisteredClient = .build() // What a Vault template renders for a key that does not exist yet. -private const val UNPROVISIONED_SECRET = "" +internal const val UNPROVISIONED_SECRET = "" // Grimoire's client has no fallback secret: until its Vault key is provisioned the client is not // registered at all, rather than registered with a secret anybody could guess. 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 3bf8a62..2168dd6 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 @@ -229,4 +229,16 @@ class RegisteredClientsTest { ) assertThat(client.tokenSettings.isReuseRefreshTokens).isFalse } + + @Test + fun `findability-explorer is registered only once a real secret is supplied`() { + assertThat(findabilityExplorerClientOrNull("s3cr3t-from-vault")?.clientId).isEqualTo("findability-explorer") + assertThat(findabilityExplorerClientOrNull("s3cr3t-from-vault")?.clientSecret) + .isEqualTo("{noop}s3cr3t-from-vault") + // No secret: nothing is registered, so there is never a client with a guessable one. + assertThat(findabilityExplorerClientOrNull("")).isNull() + assertThat(findabilityExplorerClientOrNull(" ")).isNull() + // What the Vault template renders for a key that is not provisioned yet. + assertThat(findabilityExplorerClientOrNull("")).isNull() + } }