diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/oidc/VaultTransitJwkSource.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/oidc/VaultTransitJwkSource.kt index d87acf448..1ca344ed8 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/oidc/VaultTransitJwkSource.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/oidc/VaultTransitJwkSource.kt @@ -1,7 +1,9 @@ package net.blueshell.api.platform.oidc +import com.nimbusds.jose.JWSAlgorithm import com.nimbusds.jose.jwk.JWKSelector import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.KeyUse import com.nimbusds.jose.jwk.RSAKey import com.nimbusds.jose.jwk.source.JWKSource import com.nimbusds.jose.proc.SecurityContext @@ -47,7 +49,17 @@ class VaultTransitJwkSource( private fun refresh() { val publicKeys = client.readPublicKeys(keyName) - val jwks = publicKeys.map { RSAKey.parseFromPEMEncodedObjects(it.publicKeyPem) as RSAKey } + // kid/alg/use MUST be set on the published JWK: go-oidc-v3 (Vault's verifier) + // skips any JWKS key whose `kid` doesn't match the JWT header's `kid`, and + // VaultTransitJwtEncoder writes kid="$keyName:v$version" into the header. + val jwks = publicKeys.map { vk -> + val parsed = RSAKey.parseFromPEMEncodedObjects(vk.publicKeyPem) as RSAKey + RSAKey.Builder(parsed) + .keyID("$keyName:v${vk.keyVersion}") + .algorithm(JWSAlgorithm.RS256) + .keyUse(KeyUse.SIGNATURE) + .build() + } if (jwks.isEmpty()) error("Vault returned no public keys for transit key '$keyName'") current.set(JWKSet(jwks)) } diff --git a/services/api/src/test/kotlin/net/blueshell/api/platform/oidc/VaultTransitJwkSourceTest.kt b/services/api/src/test/kotlin/net/blueshell/api/platform/oidc/VaultTransitJwkSourceTest.kt index b9d9fc2ce..71a26d789 100644 --- a/services/api/src/test/kotlin/net/blueshell/api/platform/oidc/VaultTransitJwkSourceTest.kt +++ b/services/api/src/test/kotlin/net/blueshell/api/platform/oidc/VaultTransitJwkSourceTest.kt @@ -20,7 +20,7 @@ class VaultTransitJwkSourceTest { private val selector = JWKSelector(JWKMatcher.Builder().build()) @Test - fun `initial refresh succeeds and get returns the cached key`() { + fun `initial refresh succeeds and get returns the cached key with kid alg use`() { val (pem, modulus) = generateRsaPem() val client: VaultTransitClient = mock() whenever(client.readPublicKeys(eq(keyName))) @@ -30,7 +30,13 @@ class VaultTransitJwkSourceTest { val keys = source.get(selector, null) assertThat(keys).hasSize(1) - assertThat((keys.single() as RSAKey).modulus.decodeToBigInteger()).isEqualTo(modulus) + val rsa = keys.single() as RSAKey + assertThat(rsa.modulus.decodeToBigInteger()).isEqualTo(modulus) + // Must match the kid VaultTransitJwtEncoder writes into the JWT header; + // go-oidc-v3 (Vault) filters JWKS keys by kid and would skip otherwise. + assertThat(rsa.keyID).isEqualTo("$keyName:v1") + assertThat(rsa.algorithm?.name).isEqualTo("RS256") + assertThat(rsa.keyUse?.identifier()).isEqualTo("sig") } @Test diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/VaultTransitJwksPlaywrightTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/VaultTransitJwksPlaywrightTest.kt index ae754d5ff..6e451da58 100644 --- a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/VaultTransitJwksPlaywrightTest.kt +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/VaultTransitJwksPlaywrightTest.kt @@ -105,5 +105,13 @@ class VaultTransitJwksPlaywrightTest { assertThat(first["kty"]?.asString()).isEqualTo("RSA") assertThat(first["n"]?.asString()).isNotBlank() assertThat(first["e"]?.asString()).isNotBlank() + // kid/alg/use must be present and match what VaultTransitJwtEncoder + // stamps into the JWT header — go-oidc-v3 (Vault) filters JWKS keys + // by kid and refuses to verify when there's no match. + assertThat(first["kid"]?.asString()) + .withFailMessage("JWKS key must carry kid='api-jwt:v' so Vault's go-oidc verifier picks it up") + .startsWith("api-jwt:v") + assertThat(first["alg"]?.asString()).isEqualTo("RS256") + assertThat(first["use"]?.asString()).isEqualTo("sig") } }