From c79a4393d8506a0d043d52085fb235b71bf5f4c4 Mon Sep 17 00:00:00 2001 From: "j.w.jonkers" Date: Mon, 11 May 2026 20:07:19 +0200 Subject: [PATCH] api: stamp kid/alg/use on Transit JWKS so Vault's go-oidc verifier picks the key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After #190 fixed the /oauth2/jwks 500, the next Vault sign-in failed with: Provider.VerifyIDToken: invalid id_token: failed to verify signature: failed to verify id token signature: invalid signature Diagnosed by signing a known payload via Vault Transit and verifying with the published JWKS key directly — the crypto is fine, the JWKS public key matches the Transit-engine public key, RSA-PKCS1v15-SHA256 verifies cleanly. So the failure was upstream of the actual signature check. Root cause: VaultTransitJwtEncoder writes kid="api-jwt:v1" into the JWT header, but VaultTransitJwkSource published JWK objects with no kid/alg/ use (Nimbus's RSAKey.parseFromPEMEncodedObjects leaves those unset). The verifier (coreos/go-oidc-v3, used by Vault's OIDC auth) filters JWKS keys by kid: for _, key := range keys { if keyID == "" || key.KeyID == keyID { if payload, err := jws.Verify(&key); err == nil { return payload } } } return errors.New("failed to verify id token signature") JWT header.kid="api-jwt:v1" != JWK.KeyID="" → every key skipped → error. Fix is mechanical: rebuild each published JWK with kid="$keyName:v$version" (matching the encoder), alg=RS256, use=sig. Unit test now asserts kid/alg/use on the published JWK. System test (VaultTransitJwksPlaywrightTest) asserts the same on /oauth2/jwks against the live api. Verified locally: signing-then-verifying round-trips through the JWKS public key. --- .../api/platform/oidc/VaultTransitJwkSource.kt | 14 +++++++++++++- .../api/platform/oidc/VaultTransitJwkSourceTest.kt | 10 ++++++++-- .../playwright/VaultTransitJwksPlaywrightTest.kt | 8 ++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) 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") } }