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 @@ -176,7 +176,7 @@ class ForwardAuthIntegrationTest : IntegrationTestBase() {

@Test
fun `USER with no service permissions is denied access to all protected services`() {
listOf("vault", "stalwart", "n8n", "grafana", "dashboard", "notes").forEach { subdomain ->
listOf("vault", "stalwart", "n8n", "grafana", "dashboard", "notes", "tribelt").forEach { subdomain ->
mockMvc
.get("/api/v1/auth/verify") {
with(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() {
private const val VAULT_CLIENT_SECRET = "vault-secret"
private const val HEADLAMP_CLIENT_ID = "headlamp"
private const val HEADLAMP_REDIRECT_URI = "https://dashboard.jorisjonkers.test/oidc-callback"
private const val TRIBELT_CLIENT_ID = "tribelt"
private const val TRIBELT_REDIRECT_URI = "https://tribelt.jorisjonkers.test/auth/callback"
private const val TRIBELT_CLIENT_SECRET = "tribelt-secret"
}

@BeforeEach
Expand Down Expand Up @@ -533,6 +536,7 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() {
val rabbitMqClient = registeredClientRepository.findByClientId("rabbitmq")
val vaultClient = registeredClientRepository.findByClientId(VAULT_CLIENT_ID)
val outlineClient = registeredClientRepository.findByClientId("outline")
val tribeltClient = registeredClientRepository.findByClientId(TRIBELT_CLIENT_ID)

assertThat(grafanaClient).isNotNull()
assertThat(grafanaClient!!.redirectUris).contains(GRAFANA_REDIRECT_URI)
Expand All @@ -550,6 +554,12 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() {
assertThat(outlineClient.clientAuthenticationMethods).contains(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
assertThat(outlineClient.scopes).contains(OidcScopes.OPENID, OidcScopes.PROFILE, OidcScopes.EMAIL)

assertThat(tribeltClient).isNotNull()
assertThat(tribeltClient!!.redirectUris).contains(TRIBELT_REDIRECT_URI)
assertThat(tribeltClient.clientAuthenticationMethods).contains(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
assertThat(tribeltClient.clientSettings.isRequireProofKey).isTrue()
assertThat(tribeltClient.scopes).contains(OidcScopes.OPENID, OidcScopes.PROFILE, OidcScopes.EMAIL)

assertThat(rabbitMqClient).isNotNull()
assertThat(rabbitMqClient!!.redirectUris).contains(RABBITMQ_REDIRECT_URI)
assertThat(rabbitMqClient.clientAuthenticationMethods).contains(ClientAuthenticationMethod.NONE)
Expand Down Expand Up @@ -578,6 +588,7 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() {
Triple(VAULT_CLIENT_ID, VAULT_REDIRECT_URI, "openid profile email"),
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("rabbitmq", RABBITMQ_REDIRECT_URI, "openid profile email"),
Triple(HEADLAMP_CLIENT_ID, HEADLAMP_REDIRECT_URI, "openid profile email groups"),
)
Expand Down Expand Up @@ -660,6 +671,65 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() {
}
}

@Test
fun `users with tribelt service permission can authorize tribelt oidc client`() {
val username = uniqueUsername()
val password = "securepass123"
registerAndConfirmUser(username, password)
grantServicePermission(username, ServicePermission.TRIBELT)

val loginResult = doSessionLogin(username, password)
val session = extractSession(loginResult)!!
val codeVerifier = generateCodeVerifier()
val codeChallenge = generateCodeChallenge(codeVerifier)

val authorizeResult =
mockMvc
.get("/api/oauth2/authorize") {
param("response_type", "code")
param("client_id", TRIBELT_CLIENT_ID)
param("redirect_uri", TRIBELT_REDIRECT_URI)
param("scope", "openid profile email")
param("code_challenge", codeChallenge)
param("code_challenge_method", "S256")
param("state", "allow-tribelt-service-user")
accept = MediaType.TEXT_HTML
this.session = session
}.andReturn()

assertThat(authorizeResult.response.status)
.describedAs("tribelt client should be allowed for a SERVICE_TRIBELT session")
.isIn(302, 400)

val location = authorizeResult.response.getHeader("Location")
if (location == null || !location.contains("code=")) {
return
}
assertThat(location).startsWith(TRIBELT_REDIRECT_URI)
val code = location.substringAfter("code=").substringBefore("&")

val tokenResult =
mockMvc
.post("/api/oauth2/token") {
contentType = MediaType.APPLICATION_FORM_URLENCODED
content =
"grant_type=authorization_code" +
"&code=${URLEncoder.encode(code, StandardCharsets.UTF_8)}" +
"&redirect_uri=${URLEncoder.encode(TRIBELT_REDIRECT_URI, StandardCharsets.UTF_8)}" +
"&client_id=$TRIBELT_CLIENT_ID" +
"&client_secret=$TRIBELT_CLIENT_SECRET" +
"&code_verifier=$codeVerifier"
}.andExpect { status { isOk() } }
.andReturn()

val tokenJson = objectMapper.readTree(tokenResult.response.contentAsString)
val decodedIdToken = jwtDecoder.decode(tokenJson["id_token"].asText())

// The stats app double-checks this claim after the authorize-endpoint gate.
assertThat(decodedIdToken.audience).contains(TRIBELT_CLIENT_ID)
assertThat(decodedIdToken.getClaimAsStringList("roles")).contains("ROLE_USER", "SERVICE_TRIBELT")
}

@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 @@ -761,6 +831,12 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() {
"openid profile email groups",
true,
),
OidcClientRequest(
TRIBELT_CLIENT_ID,
TRIBELT_REDIRECT_URI,
"openid profile email",
true,
),
)

requests.forEach { (clientId, redirectUri, scope, requiresPkce) ->
Expand Down Expand Up @@ -816,6 +892,12 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() {
"openid profile email",
N8N_CLIENT_SECRET,
),
ConfidentialClientRequest(
TRIBELT_CLIENT_ID,
TRIBELT_REDIRECT_URI,
"openid profile email",
TRIBELT_CLIENT_SECRET,
),
)

requests.forEach { (clientId, redirectUri, _, clientSecret) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,7 @@ class AuthorizationServerConfig(
private val issuer: String,
@param:Value("\${auth.login-url:http://localhost:5174/login}")
private val loginUrl: String,
@param:Value("\${auth.clients.grafana.secret:grafana-secret}")
private val grafanaClientSecret: String,
@param:Value("\${auth.clients.n8n.secret:n8n-secret}")
private val n8nClientSecret: String,
@param:Value("\${auth.clients.outline.secret:outline-secret}")
private val outlineClientSecret: String,
@param:Value("\${auth.clients.vault.secret:vault-secret}")
private val vaultClientSecret: String,
private val clientSecrets: DownstreamClientSecrets,
) {
@Bean
@Order(1)
Expand Down Expand Up @@ -170,14 +163,15 @@ class AuthorizationServerConfig(
buildAppUiClient(),
buildAppNativeClient(),
buildAgentsApiClient(),
buildGrafanaClient(grafanaClientSecret),
buildN8nClient(n8nClientSecret),
buildOutlineClient(outlineClientSecret),
buildGrafanaClient(clientSecrets.grafana),
buildN8nClient(clientSecrets.n8n),
buildOutlineClient(clientSecrets.outline),
buildRabbitMqClient(),
buildVaultClient(vaultClientSecret),
buildVaultClient(clientSecrets.vault),
buildHeadlampClient(),
buildImmichClient(),
buildHermesClient(),
buildTribeltClient(clientSecrets.tribelt),
)

// The JdbcOAuth2AuthorizationService constructor calls getColumnMetadata()
Expand Down Expand Up @@ -311,6 +305,7 @@ class AuthorizationServerConfig(
// forward-auth and the HERMES grant is enforced here at the
// authorize endpoint instead — the same shape as outline.
"hermes" to ServicePermission.HERMES,
"tribelt" to ServicePermission.TRIBELT,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.jorisjonkers.personalstack.auth.config

import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component

@Component
class DownstreamClientSecrets(
@param:Value("\${auth.clients.grafana.secret:grafana-secret}")
val grafana: String,
@param:Value("\${auth.clients.n8n.secret:n8n-secret}")
val n8n: String,
@param:Value("\${auth.clients.outline.secret:outline-secret}")
val outline: String,
@param:Value("\${auth.clients.vault.secret:vault-secret}")
val vault: String,
@param:Value("\${auth.clients.tribelt.secret:tribelt-secret}")
val tribelt: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,27 @@ fun buildOutlineClient(secret: String): RegisteredClient =
.tokenSettings(defaultTokenSettings())
.build()

// Tribelt mirror at tribelt.jorisjonkers.dev: a confidential client that also sends PKCE.
fun buildTribeltClient(secret: String): RegisteredClient =
RegisteredClient
.withId(deterministicId("tribelt"))
.clientId("tribelt")
.clientSecret("{noop}$secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("https://tribelt.jorisjonkers.dev/auth/callback")
.redirectUri("https://tribelt.jorisjonkers.test/auth/callback")
.postLogoutRedirectUri("https://tribelt.jorisjonkers.dev/")
.postLogoutRedirectUri("https://tribelt.jorisjonkers.test/")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope(OidcScopes.EMAIL)
.clientSettings(noConsentSettings(requirePkce = true))
.tokenSettings(defaultTokenSettings())
.build()

fun buildVaultClient(secret: String): RegisteredClient =
RegisteredClient
.withId(deterministicId("vault"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ enum class ServicePermission(
// to exactly one of these permissions -- see ServiceTokenController.
MEMORY_API("memory-api"),
MEMORY_MCP("memory-mcp"),

// Own OIDC flow on an anonymous public route; the grant is enforced at the authorize endpoint.
TRIBELT("tribelt"),
;

val subdomains: Set<String> = subdomains.toSet()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,41 @@ class RegisteredClientsTest {
"https://hermes.jorisjonkers.test/auth/callback",
)
}

@Test
fun `tribelt is a confidential PKCE client whose redirect matches the stats callback`() {
val client = buildTribeltClient("tribelt-test-secret")

assertThat(client.id).isEqualTo(UUID.nameUUIDFromBytes("tribelt".toByteArray()).toString())
assertThat(client.clientId).isEqualTo("tribelt")
assertThat(client.clientSecret).isEqualTo("{noop}tribelt-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://tribelt.jorisjonkers.dev/auth/callback",
"https://tribelt.jorisjonkers.test/auth/callback",
)
assertThat(client.postLogoutRedirectUris)
.containsExactlyInAnyOrder(
"https://tribelt.jorisjonkers.dev/",
"https://tribelt.jorisjonkers.test/",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class ServicePermissionTest {
"hermes.jorisjonkers.test, HERMES",
"overleaf.jorisjonkers.dev, OVERLEAF",
"overleaf.jorisjonkers.test, OVERLEAF",
"tribelt.jorisjonkers.dev, TRIBELT",
"tribelt.jorisjonkers.test, TRIBELT",
)
fun `fromHost resolves production and local dev hostnames`(
host: String,
Expand Down
Loading