diff --git a/docker-compose.oidc-e2e.yml b/docker-compose.oidc-e2e.yml new file mode 100644 index 000000000..3e64c1e68 --- /dev/null +++ b/docker-compose.oidc-e2e.yml @@ -0,0 +1,20 @@ +# Override that activates the oidc-e2e profile (Vault + transit-signing api). +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.oidc-e2e.yml \ +# --profile oidc-e2e up -d +# curl http://localhost:8080/oauth2/jwks + +services: + api: + depends_on: !reset + db: + condition: service_healthy + vault-init: + condition: service_completed_successfully + environment: + - AUTH_TRANSIT_ENABLED=true + - VAULT_ENABLED=true + - VAULT_ADDR=http://vault:8200 + - SPRING_CLOUD_VAULT_TOKEN=root + - AUTH_ISSUER=http://localhost:8080 diff --git a/docker-compose.yml b/docker-compose.yml index 1994dc453..71155504e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,3 +3,4 @@ include: - services/frontend/docker-compose.yml - services/stalwart/docker-compose.yml - services/listmonk/docker-compose.yml + - services/vault/docker-compose.yml diff --git a/services/api/Dockerfile-dev b/services/api/Dockerfile-dev index b458161cc..dc49144f4 100644 --- a/services/api/Dockerfile-dev +++ b/services/api/Dockerfile-dev @@ -2,7 +2,7 @@ # # Build context: repo root (see services/api/docker-compose.dev.yml). -FROM eclipse-temurin:21-jdk +FROM eclipse-temurin:21-jdk-noble WORKDIR /src 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 80ecb9eef..d87acf448 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 @@ -28,7 +28,7 @@ class VaultTransitJwkSource( fun init() { try { refresh() - } catch (ex: Exception) { + } catch (ex: Throwable) { log.error("Initial JWKS fetch from Vault failed; serving empty set until next refresh", ex) } } @@ -37,7 +37,7 @@ class VaultTransitJwkSource( fun scheduledRefresh() { try { refresh() - } catch (ex: Exception) { + } catch (ex: Throwable) { log.warn("JWKS refresh from Vault failed; keeping previously cached set", ex) } } 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 2ba294aff..b9d9fc2ce 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 @@ -37,8 +37,10 @@ class VaultTransitJwkSourceTest { fun `initial refresh failure serves empty set then recovers on schedule`() { val (pem, modulus) = generateRsaPem() val client: VaultTransitClient = mock() + // NoClassDefFoundError (Error subclass) — the failure mode when + // bcpkix is absent is exactly this, so the catch must cover Error too. whenever(client.readPublicKeys(eq(keyName))) - .thenThrow(RuntimeException("vault down")) + .thenThrow(NoClassDefFoundError("simulating missing bcpkix")) .thenReturn(listOf(VaultPublicKey(1, pem))) val source = VaultTransitJwkSource(client, keyName).also { it.init() } diff --git a/services/system-tests/build.gradle.kts b/services/system-tests/build.gradle.kts index c3b9583b6..658bddd95 100644 --- a/services/system-tests/build.gradle.kts +++ b/services/system-tests/build.gradle.kts @@ -150,3 +150,30 @@ tasks.register("installChromium") { mainClass.set("com.microsoft.playwright.CLI") args("install", "chromium") } + +// Drives `/oauth2/jwks` against a real api wired to Vault Transit. Requires +// the compose stack to be up via the oidc-e2e profile (see +// docker-compose.oidc-e2e.yml). Excluded from `:check`. +val vaultOidcLiveTest by tasks.registering(Test::class) { + description = + "Runs the Vault-Transit JWKS regression test against a live api on :8080. " + + "Bring up docker-compose.oidc-e2e.yml first." + group = "verification" + testClassesDirs = sourceSets["test"].output.classesDirs + classpath = sourceSets["test"].runtimeClasspath + shouldRunAfter(tasks.test) + useJUnitPlatform { + // Override the project-wide includeTags("system") set in + // tasks.withType().configureEach — we only want vault-oidc-live. + includeTags.clear() + includeTags("vault-oidc-live") + } + jvmArgumentProviders += CommandLineArgumentProvider { + listOf("-javaagent:${mockitoAgent.singleFile.absolutePath}") + } + testLogging { + events(TestLogEvent.PASSED, TestLogEvent.FAILED, TestLogEvent.SKIPPED) + exceptionFormat = TestExceptionFormat.FULL + showStackTraces = true + } +} 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 new file mode 100644 index 000000000..ae754d5ff --- /dev/null +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/VaultTransitJwksPlaywrightTest.kt @@ -0,0 +1,109 @@ +package net.blueshell.api.system.oidc.playwright + +import com.microsoft.playwright.APIRequestContext +import com.microsoft.playwright.Playwright +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import net.blueshell.api.system.oidc.OidcTestHelper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Drives `/oauth2/jwks` against a real api wired to Vault Transit. Verifies + * the production code path that 500'd because `org.bouncycastle:bcpkix-jdk18on` + * (carrying `JcaPEMKeyConverter`, required by Nimbus's + * `RSAKey.parseFromPEMEncodedObjects`) was missing from the runtime classpath. + * + * Not run as part of `:check`. Bring up the stack first: + * + * docker compose -f docker-compose.yml -f docker-compose.oidc-e2e.yml \ + * --profile oidc-e2e up -d + * + * Then: + * + * ./gradlew :services:system-tests:vaultOidcLiveTest + * + * Uses Playwright's native APIRequest client (not a chromium BrowserContext) + * so the GET to the api uses Playwright's own HTTP client instead of the + * chromium network stack — that one trips ECONNRESET on the first plain-HTTP + * localhost request because chromium tries h2c/altsvc handshakes that the + * Spring/Tomcat connector closes. + */ +@Tag("vault-oidc-live") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class VaultTransitJwksPlaywrightTest { + + private lateinit var playwright: Playwright + private lateinit var request: APIRequestContext + + private val apiBaseUrl: String = + System.getProperty("test.api.url", "http://localhost:8080") + + @BeforeAll + fun startPlaywright() { + waitForApiReady() + playwright = Playwright.create() + request = playwright.request().newContext() + } + + /** + * Block until the api answers 200 on /health. In dev compose the api uses + * Spring Boot devtools, which auto-restarts whenever Gradle touches the + * shared `/src` mount — so any preceding `gradle` invocation (e.g. the one + * that just compiled this test class) can leave the api mid-restart when + * we start. This is a *precondition*, not a retry on the JWKS assertion. + */ + private fun waitForApiReady() { + val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build() + val request = HttpRequest.newBuilder(URI.create("$apiBaseUrl/health")) + .timeout(Duration.ofSeconds(2)) + .GET() + .build() + val deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos() + while (System.nanoTime() < deadline) { + val ok = runCatching { client.send(request, HttpResponse.BodyHandlers.discarding()).statusCode() == 200 } + .getOrDefault(false) + if (ok) return + Thread.sleep(500) + } + error("api at $apiBaseUrl did not become ready within 60s — is the oidc-e2e compose stack up?") + } + + @AfterAll + fun stopPlaywright() { + request.dispose() + playwright.close() + } + + @Test + fun `jwks endpoint serves at least one RSA signing key when transit is enabled`() { + val response = request.get("$apiBaseUrl/oauth2/jwks") + + assertThat(response.status()) + .withFailMessage( + "Expected 200 from /oauth2/jwks, got %d. Body:\n%s", + response.status(), + response.text(), + ).isEqualTo(200) + + val body = OidcTestHelper.parseJson(response.text()) + val keys = body["keys"] + assertThat(keys).isNotNull + assertThat(keys.isArray).isTrue() + assertThat(keys.size()) + .withFailMessage("Expected /oauth2/jwks to publish ≥1 key; got %s", keys) + .isGreaterThanOrEqualTo(1) + + val first = OidcTestHelper.mapElements(keys) { it }.first() + assertThat(first["kty"]?.asString()).isEqualTo("RSA") + assertThat(first["n"]?.asString()).isNotBlank() + assertThat(first["e"]?.asString()).isNotBlank() + } +} diff --git a/services/vault/docker-compose.yml b/services/vault/docker-compose.yml new file mode 100644 index 000000000..3bdcc9f12 --- /dev/null +++ b/services/vault/docker-compose.yml @@ -0,0 +1,40 @@ +services: + vault: + image: hashicorp/vault:1.18 + profiles: [oidc-e2e] + cap_add: + - IPC_LOCK + environment: + VAULT_DEV_ROOT_TOKEN_ID: root + VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200 + VAULT_ADDR: http://127.0.0.1:8200 + ports: + - "8200:8200" + networks: + - global_network + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1:8200/v1/sys/health"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 5s + + vault-init: + image: hashicorp/vault:1.18 + profiles: [oidc-e2e] + depends_on: + vault: + condition: service_healthy + environment: + VAULT_ADDR: http://vault:8200 + VAULT_TOKEN: root + networks: + - global_network + entrypoint: + - /bin/sh + - -ec + - | + # Transit engine + api-jwt RSA key (OIDC needs RSA, default is ed25519). + vault secrets enable -path=transit transit || true + vault write -f transit/keys/api-jwt type=rsa-2048 + vault read transit/keys/api-jwt