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
20 changes: 20 additions & 0 deletions docker-compose.oidc-e2e.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion services/api/Dockerfile-dev
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
27 changes: 27 additions & 0 deletions services/system-tests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,30 @@ tasks.register<JavaExec>("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<Test>().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
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
40 changes: 40 additions & 0 deletions services/vault/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Loading