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
2 changes: 2 additions & 0 deletions services/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions services/api/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,7 @@ class OidcJwtConfig {
vaultClient: VaultTransitClient,
@Value("\${auth.transit.key-name:api-jwt}") keyName: String,
): JWKSource<SecurityContext> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SecurityContext> {

private val current = AtomicReference(JWKSet(emptyList<RSAKey>()))

@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<com.nimbusds.jose.jwk.JWK> =
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)
}
}
1 change: 1 addition & 0 deletions services/api/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Original file line number Diff line number Diff line change
@@ -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<String, java.math.BigInteger> {
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
}
}
Loading