api: fix /oauth2/jwks 500 — add bcpkix runtime dep, cache JWKS#190
Merged
Conversation
Vault OIDC login was failing at id_token signature verification because
/oauth2/jwks returned 500 on every call. Production stacktrace:
ClassNotFoundException: org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
at com.nimbusds.jose.jwk.PEMEncodedKeyParser.<clinit>(...)
at com.nimbusds.jose.jwk.JWK.parseFromPEMEncodedObjects(...)
at OidcJwtConfig.vaultTransitJwkSource$lambda$0(OidcJwtConfig.kt:50)
Nimbus's PEM parser requires bcpkix, which was not on the runtime
classpath — only bcprov was, pulled transitively. Adding bcpkix-jdk18on
as runtimeOnly resolves the immediate failure.
While here, replace the inline JWKSource lambda — which called Vault on
every JWKS request and let every exception surface as a 500 — with a
cached JWKSource that refreshes on a fixed schedule (5m default, knob
via auth.transit.jwks-refresh-ms). On refresh failure it logs and keeps
the previously cached set, so a transient Vault hiccup no longer breaks
OIDC for every relying party.
Unit tests cover: initial fetch success, initial fetch failure recovers
on next schedule, scheduled refresh failure preserves cache, empty
refresh result preserves cache.
5 tasks
ExtraToast
added a commit
that referenced
this pull request
May 11, 2026
…cks the key After #190 fixed the /oauth2/jwks 500, the next Vault sign-in failed with: Provider.VerifyIDToken: invalid id_token: failed to verify signature: failed to verify id token signature: invalid signature Diagnosed by signing a known payload via Vault Transit and verifying with the published JWKS key directly — the crypto is fine, the JWKS public key matches the Transit-engine public key, RSA-PKCS1v15-SHA256 verifies cleanly. So the failure was upstream of the actual signature check. Root cause: VaultTransitJwtEncoder writes kid="api-jwt:v1" into the JWT header, but VaultTransitJwkSource published JWK objects with no kid/alg/ use (Nimbus's RSAKey.parseFromPEMEncodedObjects leaves those unset). The verifier (coreos/go-oidc-v3, used by Vault's OIDC auth) filters JWKS keys by kid: for _, key := range keys { if keyID == "" || key.KeyID == keyID { if payload, err := jws.Verify(&key); err == nil { return payload } } } return errors.New("failed to verify id token signature") JWT header.kid="api-jwt:v1" != JWK.KeyID="" → every key skipped → error. Fix is mechanical: rebuild each published JWK with kid="$keyName:v$version" (matching the encoder), alg=RS256, use=sig. Unit test now asserts kid/alg/use on the published JWK. System test (VaultTransitJwksPlaywrightTest) asserts the same on /oauth2/jwks against the live api. Verified locally: signing-then-verifying round-trips through the JWKS public key.
4 tasks
ExtraToast
added a commit
that referenced
this pull request
May 11, 2026
…cks the key (#192) After #190 fixed the /oauth2/jwks 500, the next Vault sign-in failed with: Provider.VerifyIDToken: invalid id_token: failed to verify signature: failed to verify id token signature: invalid signature Diagnosed by signing a known payload via Vault Transit and verifying with the published JWKS key directly — the crypto is fine, the JWKS public key matches the Transit-engine public key, RSA-PKCS1v15-SHA256 verifies cleanly. So the failure was upstream of the actual signature check. Root cause: VaultTransitJwtEncoder writes kid="api-jwt:v1" into the JWT header, but VaultTransitJwkSource published JWK objects with no kid/alg/ use (Nimbus's RSAKey.parseFromPEMEncodedObjects leaves those unset). The verifier (coreos/go-oidc-v3, used by Vault's OIDC auth) filters JWKS keys by kid: for _, key := range keys { if keyID == "" || key.KeyID == keyID { if payload, err := jws.Verify(&key); err == nil { return payload } } } return errors.New("failed to verify id token signature") JWT header.kid="api-jwt:v1" != JWK.KeyID="" → every key skipped → error. Fix is mechanical: rebuild each published JWK with kid="$keyName:v$version" (matching the encoder), alg=RS256, use=sig. Unit test now asserts kid/alg/use on the published JWK. System test (VaultTransitJwksPlaywrightTest) asserts the same on /oauth2/jwks against the live api. Verified locally: signing-then-verifying round-trips through the JWKS public key.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Vault OIDC sign-in (and every other OIDC RP) failed at id_token signature verification:
Stacktrace from the running api pod (fresh 6-min-old pod with valid Vault token — rules out token expiry):
Nimbus's PEM parser requires
bcpkix; onlybcprovwas on the runtime classpath. The Vault-Transit JWKS path has been broken sinceauth.transit.enabled=truefirst shipped.Verified locally
Reproduced the production failure end-to-end against a real Vault in docker-compose, then verified the fix flips the same path green:
What's in the PR
Fix:
org.bouncycastle:bcpkix-jdk18on:1.81asruntimeOnlyto:services:api(lockfile refreshed). Root-cause fix.VaultTransitJwkSource: fetches Vault Transit public keys at startup and on a fixed schedule (auth.transit.jwks-refresh-ms, default 5 min), serves the cachedJWKSeton every request, preserves the previous set on refresh failure. A transient Vault hiccup never produces a 500 on JWKS again.VaultTransitJwkSourcecatchesThrowable, notException— the bcpkix-missing failure is aNoClassDefFoundError(Error subclass), so the narrow catch let it abort bean creation instead of degrading.Verification (regression):
VaultTransitJwkSource: initial-success, initial-failure-then-recover (usesNoClassDefFoundError, the actual prod failure mode), scheduled-refresh-failure-preserves-cache, empty-result-preserves-cache.VaultTransitJwksPlaywrightTest(@Tag("vault-oidc-live")) drives/oauth2/jwksagainst a real api wired to a real Vault.vaultOidcLiveTestgradle task with the right tag filter.services/vault/docker-compose.yml(oidc-e2e profile) — dev-mode Vault + one-shot init enabling transit and creating the RSAapi-jwtkey.docker-compose.oidc-e2e.ymloverlay — flips api into transit mode and drops the listmonk dep so the api can boot without Listmonk for OIDC e2e.Dockerfile-devpinned toeclipse-temurin:21-jdk-noble— the unpinned21-jdktag is now ubuntu26.04, which Playwright 1.58 can't install browsers for on arm64.Repro / local run
docker compose -f docker-compose.yml -f docker-compose.oidc-e2e.yml \ --profile oidc-e2e up -d curl http://localhost:8080/oauth2/jwks # 200 with keys[…] ./gradlew :services:system-tests:vaultOidcLiveTestTest plan
./gradlew :services:api:test --tests VaultTransitJwkSourceTest— 4/4vaultOidcLiveTestruns SUCCESScurl https://v2.esa-blueshell.nl/api/oauth2/jwksreturns 200https://vault.esa-blueshell.nlcompletes end-to-endhttps://kube.esa-blueshell.nlcompletes end-to-endOut of scope
agent-pre-populate-only). Cache/resilience changes here decouple JWKS availability from token freshness, which is the user-visible failure mode; renewal hygiene is a separate platform PR.VaultTransitJwtEncoder.encode. Low-rate path; optional follow-up.