From 8ddb1843b07674f24406a9869f243ed07575c9e0 Mon Sep 17 00:00:00 2001 From: "j.w.jonkers" Date: Mon, 11 May 2026 18:47:23 +0200 Subject: [PATCH] api: add Vault-Transit JWKS Playwright system test + harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduces the production /oauth2/jwks 500 locally and pins the fix under a real Vault. Verified red on bug-state, green with fix. Pieces: - `services/vault/docker-compose.yml` (oidc-e2e profile) — Vault dev container + one-shot init that enables transit and creates an RSA api-jwt key. Pulled into root compose via include. - `docker-compose.oidc-e2e.yml` — overlay that flips api into transit mode (AUTH_TRANSIT_ENABLED=true, VAULT_ADDR=..., SPRING_CLOUD_VAULT_TOKEN=root) and drops the listmonk-setup precondition so the api can boot without Listmonk for OIDC e2e. - `services/system-tests/.../VaultTransitJwksPlaywrightTest.kt` — drives /oauth2/jwks against the live api via Playwright's native APIRequest (not chromium — chromium's network stack trips ECONNRESET on plain-HTTP localhost). @Tag("vault-oidc-live"). Includes a /health readiness wait in @BeforeAll because Spring Boot devtools may be mid-restart after any preceding gradle build touches the shared /src mount. - `vaultOidcLiveTest` gradle task with `useJUnitPlatform.includeTags` scoped to the new tag (overrides the project-wide system tag filter). Two collateral fixes the test surfaced: - `Dockerfile-dev`: pin base to `eclipse-temurin:21-jdk-noble`. The un-pinned `21-jdk` tag is now ubuntu26.04, which Playwright 1.58 can't install browsers for on arm64 — `installChromium` failed. - `VaultTransitJwkSource`: catch Throwable, not Exception, in init/refresh. The bcpkix-missing failure is a `NoClassDefFoundError` (Error subclass), so the previous catch let it abort bean creation instead of degrading to an empty JWKSet. Test updated to use NoClassDefFoundError, matching the actual prod failure mode. To repro locally: docker compose -f docker-compose.yml -f docker-compose.oidc-e2e.yml \ --profile oidc-e2e up -d ./gradlew :services:system-tests:vaultOidcLiveTest --- docker-compose.oidc-e2e.yml | 20 ++++ docker-compose.yml | 1 + services/api/Dockerfile-dev | 2 +- .../platform/oidc/VaultTransitJwkSource.kt | 4 +- .../oidc/VaultTransitJwkSourceTest.kt | 4 +- services/system-tests/build.gradle.kts | 27 +++++ .../VaultTransitJwksPlaywrightTest.kt | 109 ++++++++++++++++++ services/vault/docker-compose.yml | 40 +++++++ 8 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 docker-compose.oidc-e2e.yml create mode 100644 services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/VaultTransitJwksPlaywrightTest.kt create mode 100644 services/vault/docker-compose.yml 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