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 @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"),
)
Expand Down Expand Up @@ -741,27 +754,33 @@ 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
// alone, and param() leaves it empty, which is answered 400 "OAuth 2.0 Parameter:
// 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.
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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)}",
)
Expand All @@ -841,14 +866,78 @@ 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"))
.contains("ROLE_USER")
.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)
Expand Down Expand Up @@ -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) ->
Expand Down Expand Up @@ -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) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ class AuthorizationServerConfig(
fun registeredClientRepository(
// No fallback: without a secret the grimoire client is not registered.
@Value("\${auth.clients.grimoire.secret:}") grimoireSecret: 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(
Expand All @@ -182,6 +185,7 @@ class AuthorizationServerConfig(
buildTribeltClient(clientSecrets.tribelt),
buildEstateDashboardClient(clientSecrets.estateDashboard),
grimoireClientOrNull(grimoireSecret),
findabilityExplorerClientOrNull(findabilityExplorerSecret),
),
)

Expand Down Expand Up @@ -320,6 +324,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,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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.
// Built only through findabilityExplorerClientOrNull: there is no fallback secret.
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()

// 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)
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<no value>"
internal const val UNPROVISIONED_SECRET = "<no value>"

// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = subdomains.toSet()
Expand Down
Loading
Loading