diff --git a/services/api/build.gradle.kts b/services/api/build.gradle.kts index 459973f58..5c9eeb44f 100644 --- a/services/api/build.gradle.kts +++ b/services/api/build.gradle.kts @@ -81,6 +81,8 @@ dependencies { developmentOnly("org.springframework.boot:spring-boot-devtools") implementation("com.nimbusds:nimbus-jose-jwt:10.8") + // Nimbus RSAKey.parseFromPEMEncodedObjects needs JcaPEMKeyConverter (bcpkix); bcprov alone is insufficient. + runtimeOnly("org.bouncycastle:bcpkix-jdk18on:1.81") implementation("io.jsonwebtoken:jjwt-api:0.13.0") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0") diff --git a/services/api/gradle.lockfile b/services/api/gradle.lockfile index c59cfca93..aba057c6e 100644 --- a/services/api/gradle.lockfile +++ b/services/api/gradle.lockfile @@ -192,7 +192,9 @@ org.aspectj:aspectjweaver:1.9.25.1=aotCompileClasspath,aotTestCompileClasspath,c org.assertj:assertj-core:3.27.7=aotTestCompileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,nativeImageTestClasspath,processTestAotClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.attoparser:attoparser:2.0.7.RELEASE=aotCompileClasspath,aotTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,nativeImageClasspath,nativeImageTestClasspath,processAotClasspath,processTestAotClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=aotTestCompileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,nativeImageTestClasspath,processTestAotClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.bouncycastle:bcpkix-jdk18on:1.81=aotCompileClasspath,aotTestCompileClasspath,integrationTestRuntimeClasspath,nativeImageClasspath,nativeImageTestClasspath,processAotClasspath,processTestAotClasspath,productionRuntimeClasspath,runtimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.bouncycastle:bcprov-jdk18on:1.81=aotCompileClasspath,aotTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,nativeImageClasspath,nativeImageTestClasspath,processAotClasspath,processTestAotClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.bouncycastle:bcutil-jdk18on:1.81=aotCompileClasspath,aotTestCompileClasspath,integrationTestRuntimeClasspath,nativeImageClasspath,nativeImageTestClasspath,processAotClasspath,processTestAotClasspath,productionRuntimeClasspath,runtimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.ccil.cowan.tagsoup:tagsoup:1.2.1=aotTestCompileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,nativeImageTestClasspath,processTestAotClasspath,testCompileClasspath,testRuntimeClasspath org.commonmark:commonmark-ext-gfm-tables:0.27.1=aotCompileClasspath,aotTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,nativeImageClasspath,nativeImageTestClasspath,processAotClasspath,processTestAotClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.commonmark:commonmark:0.27.1=aotCompileClasspath,aotTestCompileClasspath,compileClasspath,integrationTestCompileClasspath,integrationTestRuntimeClasspath,nativeImageClasspath,nativeImageTestClasspath,processAotClasspath,processTestAotClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/oidc/OidcJwtConfig.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/oidc/OidcJwtConfig.kt index baef57cb9..7f298b78c 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/oidc/OidcJwtConfig.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/oidc/OidcJwtConfig.kt @@ -44,13 +44,7 @@ class OidcJwtConfig { vaultClient: VaultTransitClient, @Value("\${auth.transit.key-name:api-jwt}") keyName: String, ): JWKSource { - return JWKSource { selector, _ -> - val publicKeys = vaultClient.readPublicKeys(keyName) - val jwks = publicKeys.map { vk -> - RSAKey.parseFromPEMEncodedObjects(vk.publicKeyPem) as RSAKey - } - selector.select(JWKSet(jwks)) - } + return VaultTransitJwkSource(vaultClient, keyName) } @Bean 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 new file mode 100644 index 000000000..80ecb9eef --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/oidc/VaultTransitJwkSource.kt @@ -0,0 +1,58 @@ +package net.blueshell.api.platform.oidc + +import com.nimbusds.jose.jwk.JWKSelector +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.source.JWKSource +import com.nimbusds.jose.proc.SecurityContext +import jakarta.annotation.PostConstruct +import net.blueshell.common.vault.VaultTransitClient +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import java.util.concurrent.atomic.AtomicReference + +/** + * Caches the JWKS derived from Vault Transit public keys. Refresh runs on a + * background schedule so `/oauth2/jwks` never blocks on (or fails because of) + * a Vault read. If a refresh throws, the previous set is kept — a Vault blip + * never produces a 500 on the JWKS endpoint. + */ +class VaultTransitJwkSource( + private val client: VaultTransitClient, + private val keyName: String, +) : JWKSource { + + private val current = AtomicReference(JWKSet(emptyList())) + + @PostConstruct + fun init() { + try { + refresh() + } catch (ex: Exception) { + log.error("Initial JWKS fetch from Vault failed; serving empty set until next refresh", ex) + } + } + + @Scheduled(fixedDelayString = "\${auth.transit.jwks-refresh-ms:300000}") + fun scheduledRefresh() { + try { + refresh() + } catch (ex: Exception) { + log.warn("JWKS refresh from Vault failed; keeping previously cached set", ex) + } + } + + override fun get(selector: JWKSelector, context: SecurityContext?): List = + selector.select(current.get()) + + private fun refresh() { + val publicKeys = client.readPublicKeys(keyName) + val jwks = publicKeys.map { RSAKey.parseFromPEMEncodedObjects(it.publicKeyPem) as RSAKey } + if (jwks.isEmpty()) error("Vault returned no public keys for transit key '$keyName'") + current.set(JWKSet(jwks)) + } + + companion object { + private val log = LoggerFactory.getLogger(VaultTransitJwkSource::class.java) + } +} diff --git a/services/api/src/main/resources/application.yaml b/services/api/src/main/resources/application.yaml index 0c3a8a3a7..7b379a058 100644 --- a/services/api/src/main/resources/application.yaml +++ b/services/api/src/main/resources/application.yaml @@ -213,6 +213,7 @@ auth: transit: enabled: ${AUTH_TRANSIT_ENABLED:false} key-name: ${AUTH_TRANSIT_KEY_NAME:api-jwt} + jwks-refresh-ms: ${AUTH_TRANSIT_JWKS_REFRESH_MS:300000} clients: vault: secret: ${VAULT_OIDC_CLIENT_SECRET:changeme} 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 new file mode 100644 index 000000000..2ba294aff --- /dev/null +++ b/services/api/src/test/kotlin/net/blueshell/api/platform/oidc/VaultTransitJwkSourceTest.kt @@ -0,0 +1,95 @@ +package net.blueshell.api.platform.oidc + +import com.nimbusds.jose.jwk.JWKMatcher +import com.nimbusds.jose.jwk.JWKSelector +import com.nimbusds.jose.jwk.RSAKey +import net.blueshell.common.vault.VaultPublicKey +import net.blueshell.common.vault.VaultTransitClient +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.security.KeyPairGenerator +import java.security.interfaces.RSAPublicKey +import java.util.Base64 + +class VaultTransitJwkSourceTest { + + private val keyName = "api-jwt" + private val selector = JWKSelector(JWKMatcher.Builder().build()) + + @Test + fun `initial refresh succeeds and get returns the cached key`() { + val (pem, modulus) = generateRsaPem() + val client: VaultTransitClient = mock() + whenever(client.readPublicKeys(eq(keyName))) + .thenReturn(listOf(VaultPublicKey(1, pem))) + + val source = VaultTransitJwkSource(client, keyName).also { it.init() } + + val keys = source.get(selector, null) + assertThat(keys).hasSize(1) + assertThat((keys.single() as RSAKey).modulus.decodeToBigInteger()).isEqualTo(modulus) + } + + @Test + fun `initial refresh failure serves empty set then recovers on schedule`() { + val (pem, modulus) = generateRsaPem() + val client: VaultTransitClient = mock() + whenever(client.readPublicKeys(eq(keyName))) + .thenThrow(RuntimeException("vault down")) + .thenReturn(listOf(VaultPublicKey(1, pem))) + + val source = VaultTransitJwkSource(client, keyName).also { it.init() } + assertThat(source.get(selector, null)).isEmpty() + + source.scheduledRefresh() + + val keys = source.get(selector, null) + assertThat(keys).hasSize(1) + assertThat((keys.single() as RSAKey).modulus.decodeToBigInteger()).isEqualTo(modulus) + } + + @Test + fun `scheduled refresh failure preserves previously cached set`() { + val (pem, modulus) = generateRsaPem() + val client: VaultTransitClient = mock() + whenever(client.readPublicKeys(eq(keyName))) + .thenReturn(listOf(VaultPublicKey(1, pem))) + .thenThrow(RuntimeException("vault hiccup")) + + val source = VaultTransitJwkSource(client, keyName).also { it.init() } + assertThat(source.get(selector, null)).hasSize(1) + + source.scheduledRefresh() + + val keys = source.get(selector, null) + assertThat(keys).hasSize(1) + assertThat((keys.single() as RSAKey).modulus.decodeToBigInteger()).isEqualTo(modulus) + } + + @Test + fun `refresh with empty result preserves previously cached set`() { + val (pem, modulus) = generateRsaPem() + val client: VaultTransitClient = mock() + whenever(client.readPublicKeys(eq(keyName))) + .thenReturn(listOf(VaultPublicKey(1, pem))) + .thenReturn(emptyList()) + + val source = VaultTransitJwkSource(client, keyName).also { it.init() } + source.scheduledRefresh() + + val keys = source.get(selector, null) + assertThat(keys).hasSize(1) + assertThat((keys.single() as RSAKey).modulus.decodeToBigInteger()).isEqualTo(modulus) + } + + private fun generateRsaPem(): Pair { + val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val pub = pair.public as RSAPublicKey + val b64 = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(pub.encoded) + val pem = "-----BEGIN PUBLIC KEY-----\n$b64\n-----END PUBLIC KEY-----\n" + return pem to pub.modulus + } +}