Skip to content

JWKS auto refresh logic#2801

Merged
rrayst merged 31 commits intomasterfrom
jwks-auto-refresh
Mar 6, 2026
Merged

JWKS auto refresh logic#2801
rrayst merged 31 commits intomasterfrom
jwks-auto-refresh

Conversation

@bridgerdier
Copy link
Contributor

@bridgerdier bridgerdier commented Feb 18, 2026

Summary by CodeRabbit

  • New Features

    • JWT auth accepts either explicit JWK lists or JWKS URIs with automatic periodic refresh; per-key HTTP settings and refresh interval configurable.
  • Improvements

    • Faster key lookup by key ID, stricter validation and clearer errors when keys are missing, and a centralized timer-task utility used across periodic jobs.
  • Tests

    • New integration test validating JWKS rotation and refresh behavior.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 18, 2026

📝 Walkthrough

Walkthrough

Adds JWKS URI-based loading with periodic refresh, per-JWK HTTP client config and lifecycle init(Router); centralizes key-by-kid lookup in Jwks and switches JwtAuthInterceptor to use it; introduces TimerTaskUtil and replaces inline TimerTasks; adds integration test for JWKS refresh.

Changes

Cohort / File(s) Summary
JWT / JWKS core
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java, core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java
Add JWKS URI loading with periodic refresh, enforce mutual exclusivity between jwks and jwksUris, add init(Router), expose getKeyByKid(String), per-JWK HttpClientConfiguration, and maintain synchronized keysByKid; JwtAuthInterceptor now fetches keys via jwks.getKeyByKid(...).
AuthorizationService config
core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/AuthorizationService.java
Add jwksRefreshInterval field (default 24h) with public getter and @MCAttribute setter to control JWKS refresh frequency.
TimerTask utility & adopters
core/src/main/java/com/predic8/membrane/core/util/TimerTaskUtil.java, core/src/main/java/.../FileExchangeStore.java, .../DiskWatcher.java, .../MemoryWatcher.java, .../BalancerHealthMonitor.java, .../faultmonitoring/FaultMonitoringState.java, .../RuleReinitializer.java, .../HttpEndpointListener.java, .../Http2ClientPool.java, .../AcmeSSLContext.java
Introduce TimerTaskUtil.createTimerTask(Runnable) and replace many inline anonymous TimerTask usages with the factory to centralize periodic task creation.
Integration test — JWKS refresh
core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/JwksRefreshTest.java
Add integration test that simulates a JWKS provider, verifies key rotation and periodic refresh, and asserts acceptance/rejection of rotated keys.
Tests — JWKS list handling
core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptorTest.java, core/src/test/java/.../JwtAuthInterceptorUnitTests.java, core/src/test/java/.../openapi/.../JWTInterceptorAndSecurityValidatorTest.java, core/src/test/java/.../security/JWTSecuritySchemeTest.java
Replace mutable JWKS list mutations in tests with immutable List.of(...) assignments.
CORS util change
core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsUtil.java
Replace manual split/trim/filter pipeline with StringList.parseToSet(origins) for space-split parsing.

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant Router as Router/Validator
    participant JwtAuth as JwtAuthInterceptor
    participant Jwks as Jwks
    participant TimerMgr as TimerManager
    participant Provider as JWKS Provider
    participant Cache as Key Cache

    Client->>Router: HTTP request with JWT
    Router->>JwtAuth: handleRequest()
    JwtAuth->>Jwks: getKeyByKid(kid)
    Jwks->>Cache: lookup key by kid
    Cache-->>Jwks: RsaJsonWebKey (if present)
    Jwks-->>JwtAuth: Optional<RsaJsonWebKey>
    JwtAuth->>JwtAuth: verify JWT
    JwtAuth-->>Router: verification result
    Router-->>Client: HTTP 200/400

    rect rgba(100,150,200,0.5)
    Note over Jwks,TimerMgr: Periodic JWKS refresh
    TimerMgr->>Jwks: scheduled refresh
    Jwks->>Provider: GET /jwks
    Provider-->>Jwks: JWKS JSON
    Jwks->>Cache: rebuild key map
    end

    Client->>Router: Request with rotated-key JWT
    Router->>JwtAuth: handleRequest()
    JwtAuth->>Jwks: getKeyByKid(newKid)
    Jwks->>Cache: lookup updated key
    Cache-->>Jwks: new RsaJsonWebKey
    Jwks-->>JwtAuth: Optional<RsaJsonWebKey>
    JwtAuth->>JwtAuth: verify JWT with new key
    JwtAuth-->>Router: verification result
    Router-->>Client: HTTP 200
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

7.x

Suggested reviewers

  • rrayst
  • christiangoerdes
  • predic8

Poem

🐇
I hopped through keys and ticking clocks,
Fetched new roots from distant docks.
I cached each kid in snug little nooks,
At every tick I turned the books.
Rabbit hums: "Refresh — and off we box!"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'JWKS auto refresh logic' directly corresponds to the main objective of the changes: implementing automatic refresh functionality for JWKS (JSON Web Key Sets), as evidenced by new refresh interval configuration, periodic refresh mechanisms, and the JwksRefreshTest integration test.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch jwks-auto-refresh

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@bridgerdier
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 18, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bridgerdier
Copy link
Contributor Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 18, 2026

✅ Actions performed

Full review triggered.

@membrane-ci-server
Copy link

This pull request needs "/ok-to-test" from an authorized committer.

@bridgerdier
Copy link
Contributor Author

/ok-to-test

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (5)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (3)

189-195: Redundant error logging — e.toString() already includes the message.

Lines 191-192 log both e.toString() and e.getMessage(), which are typically redundant since toString() usually includes the message. Also, logging the full exception with stack trace would be more useful for debugging refresh failures.

Suggested fix
         } catch (Exception e) {
             if (suppressExceptions) {
-                log.error(e.toString());
-                log.error(e.getMessage());
+                log.error("Unexpected error loading JWKS from {}", uri, e);
             } else {
                 throw new RuntimeException(e);
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 189 - 195, In the Jwks class catch block where exceptions are
handled (check the suppressExceptions flag), remove the redundant
log.error(e.toString()) and log.error(e.getMessage()) calls and replace them
with a single call that logs a clear context message and the exception itself to
include the stack trace (e.g. log.error("Failed to refresh JWKS", e)); keep the
existing behavior of throwing new RuntimeException(e) when suppressExceptions is
false.

142-149: Unnecessary ObjectMapper instantiation — instance field mapper already exists.

Line 143 creates a new ObjectMapper even though line 55 declares an instance-level mapper. This is passed through to parseJwksUriIntoList and convertToJwk. Use the existing field instead.

Suggested fix
 private List<Jwk> loadJwks(boolean suppressExceptions) {
-    ObjectMapper mapper = new ObjectMapper();
     return Arrays.stream(jwksUris.split(" "))
-            .map(uri -> parseJwksUriIntoList(router.getResolverMap(), router.getConfiguration().getBaseLocation(), mapper, uri, suppressExceptions))
-            .flatMap(l -> l.jwks().stream().map(jwkRaw -> convertToJwk(jwkRaw, mapper, l.uri(), suppressExceptions)))
+            .map(uri -> parseJwksUriIntoList(router.getResolverMap(), router.getConfiguration().getBaseLocation(), this.mapper, uri, suppressExceptions))
+            .flatMap(l -> l.jwks().stream().map(jwkRaw -> convertToJwk(jwkRaw, this.mapper, l.uri(), suppressExceptions)))
             .filter(Objects::nonNull)
             .toList();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 142 - 149, In loadJwks change the local ObjectMapper creation to
use the existing instance field mapper (remove "new ObjectMapper()") so the
method uses the class-level mapper when calling parseJwksUriIntoList and
convertToJwk; update the loadJwks method to reference the field mapper and leave
the rest of the stream chain (jwksUris, router.getResolverMap(),
router.getConfiguration().getBaseLocation(), parseJwksUriIntoList, convertToJwk)
unchanged.

57-58: Consider returning Map interface instead of HashMap from the public API.

getKeysByKid() (line 108) returns HashMap<String, RsaJsonWebKey>. Exposing the concrete HashMap type in the public API unnecessarily couples consumers to the implementation. Returning Map<String, RsaJsonWebKey> would be more flexible.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 57 - 58, Change the public API to return the Map interface rather
than the concrete HashMap: update the field declaration for keysByKid to use the
Map<String, RsaJsonWebKey> type (keeping the volatile modifier) and change the
return type of getKeysByKid() from HashMap<String, RsaJsonWebKey> to Map<String,
RsaJsonWebKey>; ensure callers still compile (they should rely only on Map
operations) and leave the instantiation as new HashMap<>() so implementation
stays the same but clients are not coupled to HashMap.
core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java (2)

148-150: Thread.sleep(2000) makes the test timing-dependent and potentially flaky.

With a 1-second refresh interval, the 2-second sleep should usually suffice, but slow CI environments could make this unreliable. If the framework supports it, consider polling with a timeout instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`
around lines 148 - 150, Replace the brittle Thread.sleep(2000) in
JwksRefreshTest with a polling loop that waits until the JWKS consumer has
actually refreshed (or a timeout expires): repeatedly check the observed
key/state that should change after currentJwkSet.set(new
JsonWebKeySet(publicKey2)) (e.g., whatever method or field the test uses to
assert the active key) in short intervals (e.g., 50–200ms) and break when the
expected key is seen or throw/assert a timeout failure after a sensible limit;
this keeps the refresh wait deterministic and avoids reliance on a fixed 2s
sleep.

35-36: Hardcoded ports may cause flaky test failures in CI.

Ports 3000 and 3001 could collide with other services or parallel test runs. Consider using port 0 (OS-assigned) and retrieving the actual port after startup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`
around lines 35 - 36, Replace the hardcoded PROVIDER_PORT and
AUTH_INTERCEPTOR_PORT with 0 to let the OS assign free ports, then after
starting the relevant servers in JwksRefreshTest retrieve each server's actual
bound port (e.g., from the server/bootstrap object returned by your test setup)
and use those runtime ports when configuring the test clients/interceptor
(update all usages of PROVIDER_PORT and AUTH_INTERCEPTOR_PORT in the test to the
retrieved values); ensure that any URL strings built for the JWKS provider or
the auth interceptor are reconstructed using the obtained ports before executing
assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 74-83: The code calls
authorizationService.getJwksRefreshInterval() unguarded which can NPE when
authorizationService is null; update the block around setJwks(loadJwks(false))
to check that authorizationService != null (and optionally > 0) before
scheduling the periodic task so setJwks(loadJwks(true)) is only scheduled when
authorizationService is present; reference authorizationService,
getJwksRefreshInterval(), setJwks(...) and loadJwks(...) when locating the
change.
- Around line 169-198: The parseJwksUriIntoList method can return a JwkListByUri
containing a null keys list when mapper.readValue(...).get("keys") is missing,
which later causes an NPE when callers (e.g., stream() on the list) expect a
non-null list; modify parseJwksUriIntoList so after reading the JSON into a Map
(mapper.readValue(..., Map.class)) you safely extract the "keys" entry, check
for null and if null substitute Collections.emptyList() (or List.of()), and pass
that non-null list into the JwkListByUri constructor; ensure the same null-safe
behavior is used both in the normal return path and in the exception fallback so
JwkListByUri always contains a non-null List.
- Around line 108-110: The current getKeysByKid() exposes a volatile keysByKid
map which callers (e.g., JwtAuthInterceptor.handleJwt()) call twice leading to a
TOCTOU race; implement a single-lookup API such as public RsaJsonWebKey
getKeyByKid(String kid) that reads the volatile keysByKid once and returns the
mapped RsaJsonWebKey (or null) or alternatively have getKeysByKid() return an
unmodifiable snapshot (Collections.unmodifiableMap(new HashMap<>(keysByKid)))
and document that callers must capture the reference; update
JwtAuthInterceptor.handleJwt() to use the new getKeyByKid(kid) (or capture the
snapshot into a local variable) so contains/get are performed on the same map
instance.

In
`@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java`:
- Around line 130-136: In JwtAuthInterceptor, avoid the TOCTOU race by
consolidating the two calls to jwks.getKeysByKid(): read the map once into a
local variable (or better, call jwks.getKey(kid)) and use that single lookup to
both check presence and retrieve the RsaJsonWebKey; if the single lookup returns
null, throw the same JWTException(ERROR_UNKNOWN_KEY, ERROR_UNKNOWN_KEY_ID). This
removes the race between the presence check and the subsequent get and ensures
consistent key retrieval.

In
`@core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/AuthorizationService.java`:
- Line 65: Change the jwksRefreshInterval field and its accessor signatures to a
primitive int to avoid NPE and match other `@MCAttribute` setters: replace the
private Integer jwksRefreshInterval = 24 * 60 * 60; with a private int
jwksRefreshInterval = 24 * 60 * 60;, update getJwksRefreshInterval() to return
int (primitive) and change setJwksRefreshInterval(...) so it returns void
instead of AuthorizationService (removing the fluent return), and adjust any
callers of AuthorizationService.setJwksRefreshInterval(...) if they depended on
the fluent return.

---

Nitpick comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 189-195: In the Jwks class catch block where exceptions are
handled (check the suppressExceptions flag), remove the redundant
log.error(e.toString()) and log.error(e.getMessage()) calls and replace them
with a single call that logs a clear context message and the exception itself to
include the stack trace (e.g. log.error("Failed to refresh JWKS", e)); keep the
existing behavior of throwing new RuntimeException(e) when suppressExceptions is
false.
- Around line 142-149: In loadJwks change the local ObjectMapper creation to use
the existing instance field mapper (remove "new ObjectMapper()") so the method
uses the class-level mapper when calling parseJwksUriIntoList and convertToJwk;
update the loadJwks method to reference the field mapper and leave the rest of
the stream chain (jwksUris, router.getResolverMap(),
router.getConfiguration().getBaseLocation(), parseJwksUriIntoList, convertToJwk)
unchanged.
- Around line 57-58: Change the public API to return the Map interface rather
than the concrete HashMap: update the field declaration for keysByKid to use the
Map<String, RsaJsonWebKey> type (keeping the volatile modifier) and change the
return type of getKeysByKid() from HashMap<String, RsaJsonWebKey> to Map<String,
RsaJsonWebKey>; ensure callers still compile (they should rely only on Map
operations) and leave the instantiation as new HashMap<>() so implementation
stays the same but clients are not coupled to HashMap.

In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`:
- Around line 148-150: Replace the brittle Thread.sleep(2000) in JwksRefreshTest
with a polling loop that waits until the JWKS consumer has actually refreshed
(or a timeout expires): repeatedly check the observed key/state that should
change after currentJwkSet.set(new JsonWebKeySet(publicKey2)) (e.g., whatever
method or field the test uses to assert the active key) in short intervals
(e.g., 50–200ms) and break when the expected key is seen or throw/assert a
timeout failure after a sensible limit; this keeps the refresh wait
deterministic and avoids reliance on a fixed 2s sleep.
- Around line 35-36: Replace the hardcoded PROVIDER_PORT and
AUTH_INTERCEPTOR_PORT with 0 to let the OS assign free ports, then after
starting the relevant servers in JwksRefreshTest retrieve each server's actual
bound port (e.g., from the server/bootstrap object returned by your test setup)
and use those runtime ports when configuring the test clients/interceptor
(update all usages of PROVIDER_PORT and AUTH_INTERCEPTOR_PORT in the test to the
retrieved values); ensure that any URL strings built for the JWKS provider or
the auth interceptor are reconstructed using the obtained ports before executing
assertions.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (3)

142-143: Redundant ObjectMapper instantiation — reuse the instance field.

loadJwks creates a new ObjectMapper on every call, but there's already this.mapper (line 55). Reuse the instance field for consistency and to avoid unnecessary allocations during periodic refresh.

Proposed fix
     private List<Jwk> loadJwks(boolean suppressExceptions) {
-        ObjectMapper mapper = new ObjectMapper();
-        return Arrays.stream(jwksUris.split(" "))
-                .map(uri -> parseJwksUriIntoList(router.getResolverMap(), router.getConfiguration().getBaseLocation(), mapper, uri, suppressExceptions))
-                .flatMap(l -> l.jwks().stream().map(jwkRaw -> convertToJwk(jwkRaw, mapper, l.uri(), suppressExceptions)))
+        return Arrays.stream(jwksUris.split(" "))
+                .map(uri -> parseJwksUriIntoList(router.getResolverMap(), router.getConfiguration().getBaseLocation(), this.mapper, uri, suppressExceptions))
+                .flatMap(l -> l.jwks().stream().map(jwkRaw -> convertToJwk(jwkRaw, this.mapper, l.uri(), suppressExceptions)))
                 .filter(Objects::nonNull)
                 .toList();
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 142 - 143, In loadJwks(boolean suppressExceptions) replace the
local new ObjectMapper() with the existing instance field (this.mapper) so the
method reuses the shared ObjectMapper instead of allocating a new one each call;
update any local variable references to use mapper (or this.mapper) and remove
the redundant instantiation to avoid unnecessary allocations in the Jwks class.

108-110: Return Map interface instead of HashMap.

Returning HashMap<String, RsaJsonWebKey> leaks the implementation type in the public API. Prefer Map<String, RsaJsonWebKey> for the return type.

Proposed fix
-    public HashMap<String, RsaJsonWebKey> getKeysByKid() {
+    public Map<String, RsaJsonWebKey> getKeysByKid() {
         return keysByKid;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 108 - 110, Change the public accessor getKeysByKid() to return the
interface type Map<String, RsaJsonWebKey> instead of the concrete HashMap to
avoid leaking implementation details: update the method signature of
getKeysByKid() to return Map<String, RsaJsonWebKey> and keep returning the
existing keysByKid field (which can remain a HashMap implementation), ensuring
any imports/reference types compile; adjust any callers if they relied on
HashMap-specific methods (prefer treating the result as a Map).

260-272: Raw type List<Map> and unhelpful orElseThrow() message.

(List<Map>) mapped.get("keys") uses a raw generic type, and .findFirst().orElseThrow() throws NoSuchElementException without context about which kid was not found in the JWKS response.

Proposed fix
     private String handleJwks(ObjectMapper mapper, Map<String, Object> mapped) {
-        return ((List<Map>) mapped.get("keys")).stream()
+        `@SuppressWarnings`("unchecked")
+        List<Map<String, Object>> keys = (List<Map<String, Object>>) mapped.get("keys");
+        return keys.stream()
                 .filter(m -> m.get("kid").toString().equals(kid))
                 .map(m -> {
                     try {
                         return mapper.writeValueAsString(m);
                     } catch (JsonProcessingException e) {
                         throw new RuntimeException(e);
                     }
                 })
-                .findFirst().orElseThrow();
+                .findFirst()
+                .orElseThrow(() -> new RuntimeException("No JWK found with kid '%s' in JWKS response".formatted(kid)));
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 260 - 272, The method handleJwks currently casts raw types and
throws an unhelpful NoSuchElementException; change the cast to a typed
collection (e.g., List<Map<String,Object>> keys = (List<Map<String,Object>>)
mapped.get("keys")) and null-check keys, then use .findFirst().orElseThrow(() ->
new NoSuchElementException("JWKS does not contain key with kid='" + kid + "'"))
so the error includes the missing kid; keep the existing
mapper.writeValueAsString(m) handling (including JsonProcessingException
wrapping) and adjust any imports or generics to satisfy the compiler.
core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java (1)

148-150: Thread.sleep(2000) is a fragile way to wait for a 1-second refresh cycle.

With a 1-second refresh interval and a 2-second sleep, this leaves only ~1 second of margin for the refresh to actually complete (including HTTP round-trip and key parsing). Under CI load, this could be flaky. Consider polling with a timeout (e.g., retry JWT validation with the new key up to N times with short sleeps) instead of a fixed sleep.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`
around lines 148 - 150, The test uses Thread.sleep(2000) after swapping
currentJwkSet which is fragile; replace the fixed sleep with a polling loop that
retries validating the JWT (or invoking the same validation path used elsewhere
in JwksRefreshTest) until the new key is accepted or a timeout elapses (e.g.,
total 5s) with short pauses (e.g., 100-200ms) between attempts; specifically,
after currentJwkSet.set(new JsonWebKeySet(publicKey2)) poll by calling the JWT
validation helper used in this test (or the interceptor validation method) in a
loop, break when validation succeeds with the new key, and fail the test if the
timeout is reached. Ensure the loop uses a reasonable timeout and sleep interval
to avoid flakiness under CI.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 74-83: The TimerTask scheduled via
router.getTimerManager().schedulePeriodicTask currently calls
setJwks(loadJwks(true)) directly, so any RuntimeException from
buildKeyMap/setJwks will escape and kill the scheduler; wrap the body of the
anonymous TimerTask.run in a try-catch that catches Exception (or Throwable),
logs the failure (include context like "JWKS refresh failed") via the existing
logger or router logger, and suppresses the exception so the task continues to
be scheduled; keep the original behavior of not overwriting keysByKid on failure
by only calling setJwks on success and do not rethrow the exception.

In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`:
- Around line 35-36: Replace the hardcoded PROVIDER_PORT and
AUTH_INTERCEPTOR_PORT constants in JwksRefreshTest with 0 so the OS assigns free
ports, then, after each server/startup call, read the actual bound port from the
server instance (e.g. providerServer.getPort() or providerServer.getLocalPort(),
and authInterceptor.getPort()/getLocalPort()) and use those values wherever the
constants were referenced; ensure any URL constructions use the retrieved port
variables instead of the old constants.

---

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Line 75: The condition in Jwks (around the use of
authorizationService.getJwksRefreshInterval()) can throw an NPE when
authorizationService is null; change the guard to check authorizationService !=
null before calling getJwksRefreshInterval (e.g. authorizationService != null &&
authorizationService.getJwksRefreshInterval() > 0) so the JWKS refresh logic
only runs when authorizationService is present—this keeps behavior consistent
with parseJwksUriIntoList which already handles a null authorizationService.
- Around line 169-198: parseJwksUriIntoList currently assumes
mapper.readValue(...).get("keys") returns a List and leaves loadJwks vulnerable
to NPE if "keys" is missing or not a list; change parseJwksUriIntoList to read
the JSON into a Map (e.g., Map<?,?> parsed = mapper.readValue(..., Map.class)),
then extract Object keysObj = parsed.get("keys') and if keysObj is an instance
of List<?> use that list, otherwise treat it as Collections.emptyList();
construct and return new JwkListByUri(uri, keysList) so downstream code (e.g.,
loadJwks) never receives null.
- Around line 108-110: The method getKeysByKid() exposes the volatile HashMap
reference and causes a TOCTOU if callers (e.g., JwtAuthInterceptor.handleJwt)
perform multiple lookups; replace it with a single-lookup accessor getKey(String
kid) that reads the volatile keysByKid reference once into a local variable and
returns local.get(kid), remove or make getKeysByKid() non-public, and update
callers (JwtAuthInterceptor.handleJwt and any other usages) to call getKey(kid)
so each key lookup is atomic with respect to the map reference.

In
`@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java`:
- Around line 130-136: The code does two separate reads of jwks.getKeysByKid()
(containsKey(kid) then get(kid)), causing a TOCTOU race if the JWKS map is
refreshed between calls; fix by reading the map once into a local variable
(e.g., Map<String,?> keys = jwks.getKeysByKid()) and then perform a single
lookup (keys.get(kid)) and null-check, or add a jwks.getKey(kid) helper that
atomically returns the RsaJsonWebKey (or null) so you only access the map once
before assigning to the RsaJsonWebKey variable.

In
`@core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/AuthorizationService.java`:
- Line 65: Change the boxed Integer field jwksRefreshInterval to a primitive int
and make its setter setJwksRefreshInterval return void (not
AuthorizationService) to match other `@MCAttribute` setters; update the getter to
return int and remove any fluent return usage so Jwks.init() won’t risk NPE from
auto-unboxing. Apply the same change to the other attribute/setter pair
mentioned around lines 179-187 (convert boxed types to primitives, change their
setters to void, and adjust corresponding getters/usages accordingly).

---

Nitpick comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 142-143: In loadJwks(boolean suppressExceptions) replace the local
new ObjectMapper() with the existing instance field (this.mapper) so the method
reuses the shared ObjectMapper instead of allocating a new one each call; update
any local variable references to use mapper (or this.mapper) and remove the
redundant instantiation to avoid unnecessary allocations in the Jwks class.
- Around line 108-110: Change the public accessor getKeysByKid() to return the
interface type Map<String, RsaJsonWebKey> instead of the concrete HashMap to
avoid leaking implementation details: update the method signature of
getKeysByKid() to return Map<String, RsaJsonWebKey> and keep returning the
existing keysByKid field (which can remain a HashMap implementation), ensuring
any imports/reference types compile; adjust any callers if they relied on
HashMap-specific methods (prefer treating the result as a Map).
- Around line 260-272: The method handleJwks currently casts raw types and
throws an unhelpful NoSuchElementException; change the cast to a typed
collection (e.g., List<Map<String,Object>> keys = (List<Map<String,Object>>)
mapped.get("keys")) and null-check keys, then use .findFirst().orElseThrow(() ->
new NoSuchElementException("JWKS does not contain key with kid='" + kid + "'"))
so the error includes the missing kid; keep the existing
mapper.writeValueAsString(m) handling (including JsonProcessingException
wrapping) and adjust any imports or generics to satisfy the compiler.

In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`:
- Around line 148-150: The test uses Thread.sleep(2000) after swapping
currentJwkSet which is fragile; replace the fixed sleep with a polling loop that
retries validating the JWT (or invoking the same validation path used elsewhere
in JwksRefreshTest) until the new key is accepted or a timeout elapses (e.g.,
total 5s) with short pauses (e.g., 100-200ms) between attempts; specifically,
after currentJwkSet.set(new JsonWebKeySet(publicKey2)) poll by calling the JWT
validation helper used in this test (or the interceptor validation method) in a
loop, break when validation succeeds with the new key, and fail the test if the
timeout is reached. Ensure the loop uses a reasonable timeout and sleep interval
to avoid flakiness under CI.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (1)

270-280: ⚠️ Potential issue | 🟡 Minor

NPE in handleJwks if any JWK in the "keys" array is missing a "kid" field.

m.get("kid").toString() at line 272 throws NPE for any key entry where "kid" is absent, aborting key setup entirely. orElseThrow() on line 280 also gives no diagnostic context when no match is found.

🐛 Proposed fix
-            return ((List<Map>) mapped.get("keys")).stream()
-                    .filter(m -> m.get("kid").toString().equals(kid))
+            return ((List<Map<String, Object>>) mapped.get("keys")).stream()
+                    .filter(m -> kid != null && kid.equals(Objects.toString(m.get("kid"), null)))
                     .map(m -> {
                         try {
                             return mapper.writeValueAsString(m);
                         } catch (JsonProcessingException e) {
                             throw new RuntimeException(e);
                         }
                     })
-                    .findFirst().orElseThrow();
+                    .findFirst().orElseThrow(() ->
+                            new RuntimeException("No JWK with kid='" + kid + "' found in JWKS response"));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 270 - 280, handleJwks currently assumes every entry in
mapped.get("keys") has a "kid" and calls m.get("kid").toString(), causing NPE
and an unhelpful orElseThrow() when no match is found; update handleJwks to
safely handle missing "kid" by checking m.containsKey("kid") or m.get("kid") !=
null before calling toString (or use Objects.equals(kid,
String.valueOf(m.get("kid")))), skip entries without a kid, and replace the bare
orElseThrow() with an exception that includes context (e.g., the requested kid
and a short description of the JWKS payload) so failures are diagnostic; refer
to handleJwks, mapped, mapper, kid, and the "keys" list when making the change.
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java (1)

76-82: ⚠️ Potential issue | 🟠 Major

NPE if <jwks> is omitted from configuration.

jwks.init(router) at line 81 dereferences jwks unconditionally. If the <jwks> child element is missing from the proxies configuration, jwks is null and startup throws a bare NullPointerException rather than a descriptive error. A pre-flight null check with a ConfigurationException would surface a clear root cause.

🐛 Proposed fix
     `@Override`
     public void init() {
         super.init();
         if(jwtRetriever == null)
             jwtRetriever = new HeaderJwtRetriever("Authorization","Bearer");
+
+        if (jwks == null)
+            throw new ConfigurationException("jwtAuth requires a <jwks> child element to be configured.");
+
         jwks.init(router);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java`
around lines 76 - 82, In JwtAuthInterceptor.init(), avoid the
NullPointerException by checking if the jwks field is null before calling
jwks.init(router); if jwks is null throw a ConfigurationException with a clear
message like "JwtAuthInterceptor: <jwks> element is missing in configuration" so
startup fails with a descriptive error, otherwise call jwks.init(router); keep
the existing jwtRetriever defaulting logic intact.
🧹 Nitpick comments (4)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (4)

198-204: Redundant double logging in the generic catch block — no stack trace captured.

e.toString() already includes the message; the second log.error(e.getMessage()) is a no-op in terms of new information. Neither call records the stack trace, making diagnosis of unexpected failures difficult.

♻️ Proposed fix
         } catch (Exception e) {
             if (suppressExceptions) {
-                log.error(e.toString());
-                log.error(e.getMessage());
+                log.error("Unexpected error resolving JWKS URI {}", uri, e);
             } else {
                 throw new RuntimeException(e);
             }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 198 - 204, In Jwks.catch block where exceptions are handled, remove
the redundant log.error(e.getMessage()) and replace the existing log.error call
with one that logs the exception and its stack trace (e.g.,
log.error("...context...", e)) so that when suppressExceptions is true the full
stacktrace is recorded; if suppressExceptions is false keep throwing new
RuntimeException(e) unchanged.

151-158: loadJwks creates a redundant ObjectMapper that shadows the instance field.

ObjectMapper is thread-safe after configuration and is already available as this.mapper (line 55). Creating a new instance on every call (including every refresh cycle) is unnecessary and the shadowing makes the scope ambiguous.

♻️ Proposed refactor
     private List<Jwk> loadJwks(boolean suppressExceptions) {
-        ObjectMapper mapper = new ObjectMapper();
-        return Arrays.stream(jwksUris.split(" "))
-                .map(uri -> parseJwksUriIntoList(router.getResolverMap(), router.getConfiguration().getBaseLocation(), mapper, uri, suppressExceptions))
-                .flatMap(l -> l.jwks().stream().map(jwkRaw -> convertToJwk(jwkRaw, mapper, l.uri(), suppressExceptions)))
+        return Arrays.stream(jwksUris.split(" "))
+                .map(uri -> parseJwksUriIntoList(router.getResolverMap(), router.getConfiguration().getBaseLocation(), this.mapper, uri, suppressExceptions))
+                .flatMap(l -> l.jwks().stream().map(jwkRaw -> convertToJwk(jwkRaw, this.mapper, l.uri(), suppressExceptions)))
                 .filter(Objects::nonNull)
                 .toList();
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 151 - 158, The loadJwks method creates a new local ObjectMapper
that shadows the instance field; replace the local "ObjectMapper mapper = new
ObjectMapper();" with usage of the existing instance field "mapper" so the
method uses the configured, thread-safe mapper (keep calls to
parseJwksUriIntoList and convertToJwk unchanged, i.e., pass the instance field
mapper into those calls), and remove the redundant local variable to avoid
shadowing and repeated allocations.

117-119: getKeysByKid() exposes the mutable internal HashMap — prefer returning an unmodifiable Map view.

The method returns the live, mutable HashMap under a volatile reference. Any caller that accidentally mutates it corrupts the in-memory key store without going through the volatile write, silently breaking JWKS validation until the next refresh. Changing the return type to Map<String, RsaJsonWebKey> and wrapping with Collections.unmodifiableMap closes this gap.

♻️ Proposed refactor
-    public HashMap<String, RsaJsonWebKey> getKeysByKid() {
-        return keysByKid;
+    public Map<String, RsaJsonWebKey> getKeysByKid() {
+        return Collections.unmodifiableMap(keysByKid);
     }

and update buildKeyMap's return type:

-    private HashMap<String, RsaJsonWebKey> buildKeyMap(List<Jwk> jwks) {
+    private Map<String, RsaJsonWebKey> buildKeyMap(List<Jwk> jwks) {
-    private volatile HashMap<String, RsaJsonWebKey> keysByKid = new HashMap<>();
+    private volatile Map<String, RsaJsonWebKey> keysByKid = new HashMap<>();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 117 - 119, Change getKeysByKid to return a read-only view instead
of exposing the mutable HashMap: change its return type to Map<String,
RsaJsonWebKey> and return Collections.unmodifiableMap(keysByKid). Also update
buildKeyMap to return Map<String, RsaJsonWebKey> (not HashMap) so the types
align, and ensure any assignment to the volatile keysByKid still stores a
mutable map internally before wrapping for callers; reference getKeysByKid,
keysByKid, and buildKeyMap when making these edits.

74-92: Silent no-op refresh when authorizationService is not wired — worth logging.

If jwksUris is configured but authorizationService is null, the JWKS is loaded once at startup and never refreshed. There's no log message to alert operators to this condition. Consider adding a log.info (or log.warn) so the absence of auto-refresh is observable.

💡 Suggested addition
         setJwks(loadJwks(false));
         if (authorizationService != null && authorizationService.getJwksRefreshInterval() > 0) {
             router.getTimerManager().schedulePeriodicTask(...);
+        } else if (authorizationService == null) {
+            log.info("No authorizationService configured — JWKS will not be refreshed automatically.");
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 74 - 92, When jwksUris is configured but authorizationService is
null the code calls setJwks(loadJwks(false)) once and never schedules a refresh;
add a concise log (info or warn) immediately after the initial setJwks/loadJwks
call to surface that no periodic JWKS refresh will be scheduled because
authorizationService is null (referencing methods/fields: setJwks, loadJwks,
authorizationService and router.getTimerManager().schedulePeriodicTask) so
operators can observe the absence of auto-refresh.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 270-280: handleJwks currently assumes every entry in
mapped.get("keys") has a "kid" and calls m.get("kid").toString(), causing NPE
and an unhelpful orElseThrow() when no match is found; update handleJwks to
safely handle missing "kid" by checking m.containsKey("kid") or m.get("kid") !=
null before calling toString (or use Objects.equals(kid,
String.valueOf(m.get("kid")))), skip entries without a kid, and replace the bare
orElseThrow() with an exception that includes context (e.g., the requested kid
and a short description of the JWKS payload) so failures are diagnostic; refer
to handleJwks, mapped, mapper, kid, and the "keys" list when making the change.

In
`@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java`:
- Around line 76-82: In JwtAuthInterceptor.init(), avoid the
NullPointerException by checking if the jwks field is null before calling
jwks.init(router); if jwks is null throw a ConfigurationException with a clear
message like "JwtAuthInterceptor: <jwks> element is missing in configuration" so
startup fails with a descriptive error, otherwise call jwks.init(router); keep
the existing jwtRetriever defaulting logic intact.

---

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 178-207: The parseJwksUriIntoList method must explicitly guard
against a missing or non-array "keys" node before calling mapper.convertValue to
avoid JsonProcessingException or null propagation; update parseJwksUriIntoList
to read the tree into a JsonNode, fetch JsonNode keysNode = tree.path("keys"),
check keysNode.isMissingNode() or !keysNode.isArray() and in that case log an
appropriate message (respecting suppressExceptions) and return new
JwkListByUri(uri, emptyList()) instead of calling convertValue on the
MissingNode; only call mapper.convertValue(keysNode, List.class) when keysNode
is present and an array so JwkListByUri never receives null and loadJwks won't
hit an NPE.

---

Nitpick comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 198-204: In Jwks.catch block where exceptions are handled, remove
the redundant log.error(e.getMessage()) and replace the existing log.error call
with one that logs the exception and its stack trace (e.g.,
log.error("...context...", e)) so that when suppressExceptions is true the full
stacktrace is recorded; if suppressExceptions is false keep throwing new
RuntimeException(e) unchanged.
- Around line 151-158: The loadJwks method creates a new local ObjectMapper that
shadows the instance field; replace the local "ObjectMapper mapper = new
ObjectMapper();" with usage of the existing instance field "mapper" so the
method uses the configured, thread-safe mapper (keep calls to
parseJwksUriIntoList and convertToJwk unchanged, i.e., pass the instance field
mapper into those calls), and remove the redundant local variable to avoid
shadowing and repeated allocations.
- Around line 117-119: Change getKeysByKid to return a read-only view instead of
exposing the mutable HashMap: change its return type to Map<String,
RsaJsonWebKey> and return Collections.unmodifiableMap(keysByKid). Also update
buildKeyMap to return Map<String, RsaJsonWebKey> (not HashMap) so the types
align, and ensure any assignment to the volatile keysByKid still stores a
mutable map internally before wrapping for callers; reference getKeysByKid,
keysByKid, and buildKeyMap when making these edits.
- Around line 74-92: When jwksUris is configured but authorizationService is
null the code calls setJwks(loadJwks(false)) once and never schedules a refresh;
add a concise log (info or warn) immediately after the initial setJwks/loadJwks
call to surface that no periodic JWKS refresh will be scheduled because
authorizationService is null (referencing methods/fields: setJwks, loadJwks,
authorizationService and router.getTimerManager().schedulePeriodicTask) so
operators can observe the absence of auto-refresh.

@bridgerdier bridgerdier requested a review from rrayst February 18, 2026 09:20
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java (1)

74-81: ⚠️ Potential issue | 🟠 Major

NPE when jwks is not configured.

jwks.init(router) is called unconditionally, but jwks is never initialized to a non-null default — it stays null if the user omits the <jwks> child element. The resulting NullPointerException is far less informative than a proactive configuration check. Contrast with getLongDescription() (line 228), which already guards jwks != null.

🛡️ Proposed fix
 `@Override`
 public void init() {
     super.init();
     if(jwtRetriever == null)
         jwtRetriever = new HeaderJwtRetriever("Authorization","Bearer");

+    if (jwks == null)
+        throw new ConfigurationException("A <jwks> child element is required for jwtAuth.");
     jwks.init(router);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java`
around lines 74 - 81, The init() method in JwtAuthInterceptor calls
jwks.init(router) unconditionally which causes an NPE when the <jwks> element is
omitted; update init() to check jwks != null before invoking jwks.init(router)
and handle the missing configuration proactively (e.g., throw an
IllegalStateException with a clear message about missing jwks configuration or
log an explicit error and return), while leaving the existing defaulting of
jwtRetriever (HeaderJwtRetriever) intact; refer to JwtAuthInterceptor.init(),
the jwks field, jwtRetriever/ HeaderJwtRetriever and the getLongDescription()
guard for guidance.
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (2)

269-280: ⚠️ Potential issue | 🟠 Major

NPE when a JWK in the set has no "kid" field.

m.get("kid") returns null for any JWK that omits the "kid" member; calling .toString() on that null immediately throws NullPointerException. Additionally, orElseThrow() at line 279 carries no message, making the "not found" case equally opaque.

🛡️ Proposed fix
 private String handleJwks(ObjectMapper mapper, Map<String, Object> mapped) {
     return ((List<Map>) mapped.get("keys")).stream()
-            .filter(m -> m.get("kid").toString().equals(kid))
+            .filter(m -> kid != null && kid.equals(Objects.toString(m.get("kid"), null)))
             .map(m -> {
                 try {
                     return mapper.writeValueAsString(m);
                 } catch (JsonProcessingException e) {
                     throw new RuntimeException(e);
                 }
             })
-            .findFirst().orElseThrow();
+            .findFirst().orElseThrow(() -> new RuntimeException(
+                    "No JWK with kid '%s' found in JWKS response.".formatted(kid)));
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 269 - 280, handleJwks currently calls m.get("kid").toString() which
throws NPE if "kid" is missing and uses orElseThrow() with no message; fix by
filtering null-safe: use
Optional.ofNullable(m.get("kid")).map(Object::toString).filter(s ->
s.equals(kid)) (or check m.containsKey("kid") &&
kid.equals(String.valueOf(m.get("kid")))) before mapping to JSON, and replace
orElseThrow() with orElseThrow(() -> new RuntimeException("JWK with kid='" + kid
+ "' not found in set")) so the method (handleJwks) returns a clear error when
the key is absent while avoiding NPEs from toString().

239-241: ⚠️ Potential issue | 🟠 Major

Change @MCAttribute to @MCChildElement for the setHttpClientConfig method.

HttpClientConfiguration is a complex nested configuration bean with multiple sub-properties (ConnectionConfiguration, ProxyConfiguration, AuthenticationConfiguration, SSLParser, etc.). @MCAttribute is only for simple scalar attributes; complex objects require @MCChildElement. All 7 other similar setters throughout the codebase correctly use @MCChildElement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 239 - 241, The setter method setHttpClientConfig currently
annotated with `@MCAttribute` should be changed to `@MCChildElement` because
HttpClientConfiguration is a nested configuration bean (with
ConnectionConfiguration, ProxyConfiguration, AuthenticationConfiguration,
SSLParser, etc.); locate the setHttpClientConfig(HttpClientConfiguration
httpClientConfig) method in class Jwks and replace the `@MCAttribute` annotation
with `@MCChildElement` so the parser treats it as a complex child element
(consistent with the other seven similar setters).
🧹 Nitpick comments (2)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (2)

197-203: Redundant double-logging in the generic catch block.

e.toString() already contains the class name and the message, so log.error(e.getMessage()) on the next line prints a duplicate (and incomplete) substring of the first line.

♻️ Proposed fix
-        } catch (Exception e) {
-            if (suppressExceptions) {
-                log.error(e.toString());
-                log.error(e.getMessage());
-            } else {
-                throw new RuntimeException(e);
-            }
+        } catch (Exception e) {
+            if (suppressExceptions) {
+                log.error("Unexpected error resolving JWKS from {}: {}", uri, e.toString());
+            } else {
+                throw new RuntimeException(e);
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 197 - 203, In Jwks class inside the catch block that checks
suppressExceptions, remove the redundant log.error(e.getMessage()) and replace
the remaining logging with a single call that includes the exception (e.g.
log.error("message", e)) so you log a clear contextual message plus the full
stacktrace; keep the suppressExceptions conditional logic intact and only change
the logging lines in that catch block.

121-132: buildKeyMap stream collector can be simplified.

The three-arg collect with an explicit Supplier/BiConsumer/BiConsumer is more verbose than the built-in Collectors.toMap, and the lambda () -> new HashMap<>() can be written as a method reference.

♻️ Proposed refactor
-        var keyMap = jwks.stream()
-                .map(this::extractRsaJsonWebKey)
-                .collect(
-                        () -> new HashMap<String, RsaJsonWebKey>(),
-                        (m,e) -> m.put(e.getKeyId(),e),
-                        HashMap::putAll
-                );
+        var keyMap = jwks.stream()
+                .map(this::extractRsaJsonWebKey)
+                .collect(Collectors.toMap(RsaJsonWebKey::getKeyId, Function.identity()));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 121 - 132, The current buildKeyMap method uses a verbose three-arg
collect; simplify it by using Collectors.toMap: after mapping with
extractRsaJsonWebKey, collect into a HashMap with
Collectors.toMap(RsaJsonWebKey::getKeyId, Function.identity(), (a,b) -> b,
HashMap::new) to create the map and resolve duplicate keyIds by keeping the
latter; keep the existing empty-check and RuntimeException. Update imports as
needed and reference buildKeyMap and extractRsaJsonWebKey when making the
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 269-280: handleJwks currently calls m.get("kid").toString() which
throws NPE if "kid" is missing and uses orElseThrow() with no message; fix by
filtering null-safe: use
Optional.ofNullable(m.get("kid")).map(Object::toString).filter(s ->
s.equals(kid)) (or check m.containsKey("kid") &&
kid.equals(String.valueOf(m.get("kid")))) before mapping to JSON, and replace
orElseThrow() with orElseThrow(() -> new RuntimeException("JWK with kid='" + kid
+ "' not found in set")) so the method (handleJwks) returns a clear error when
the key is absent while avoiding NPEs from toString().
- Around line 239-241: The setter method setHttpClientConfig currently annotated
with `@MCAttribute` should be changed to `@MCChildElement` because
HttpClientConfiguration is a nested configuration bean (with
ConnectionConfiguration, ProxyConfiguration, AuthenticationConfiguration,
SSLParser, etc.); locate the setHttpClientConfig(HttpClientConfiguration
httpClientConfig) method in class Jwks and replace the `@MCAttribute` annotation
with `@MCChildElement` so the parser treats it as a complex child element
(consistent with the other seven similar setters).

In
`@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java`:
- Around line 74-81: The init() method in JwtAuthInterceptor calls
jwks.init(router) unconditionally which causes an NPE when the <jwks> element is
omitted; update init() to check jwks != null before invoking jwks.init(router)
and handle the missing configuration proactively (e.g., throw an
IllegalStateException with a clear message about missing jwks configuration or
log an explicit error and return), while leaving the existing defaulting of
jwtRetriever (HeaderJwtRetriever) intact; refer to JwtAuthInterceptor.init(),
the jwks field, jwtRetriever/ HeaderJwtRetriever and the getLongDescription()
guard for guidance.

---

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 117-119: No change required: the getKeyByKid(String kid) method's
current implementation returning Optional.ofNullable(keysByKid.get(kid)) is fine
under the publish-once refresh pattern; leave Jwks.getKeyByKid as-is and no
modifications to keysByKid access or volatility are needed.
- Around line 177-206: In parseJwksUriIntoList, the call
mapper.convertValue(mapper.readTree(resolve).path("keys"), List.class) can
receive a MissingNode (or wrap to IllegalArgumentException) so add an explicit
guard: read the JSON tree first into a JsonNode (e.g., JsonNode root =
mapper.readTree(resolve)), check root.has("keys") &&
!root.get("keys").isMissingNode() && root.get("keys").isArray() before
converting; if the keys field is absent or not an array, handle according to
suppressExceptions (log and return new JwkListByUri(uri, emptyList()) or throw
ConfigurationException), otherwise call mapper.convertValue(root.get("keys"),
List.class) to avoid the opaque IllegalArgumentException path; keep existing
exception handling for ResourceRetrievalException and JsonProcessingException.
- Around line 64-93: The timer-task error handling in init(Router) is correct:
the TimerTask around loadJwks(true) already catches exceptions and the
empty-list guard prevents overwriting keysByKid, so no code changes are needed;
leave the init, loadJwks, setJwks and the scheduled TimerTask as implemented.

In
`@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java`:
- Line 131: Replace the previous two-step lookup with a single atomic retrieval
to avoid TOCTOU: in JwtAuthInterceptor, use jwks.getKeyByKid(kid).orElseThrow(()
-> new JWTException(ERROR_UNKNOWN_KEY, ERROR_UNKNOWN_KEY_ID)) so the lookup and
exception handling occur in one call (refers to the getKeyByKid method and kid
variable).

---

Nitpick comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 197-203: In Jwks class inside the catch block that checks
suppressExceptions, remove the redundant log.error(e.getMessage()) and replace
the remaining logging with a single call that includes the exception (e.g.
log.error("message", e)) so you log a clear contextual message plus the full
stacktrace; keep the suppressExceptions conditional logic intact and only change
the logging lines in that catch block.
- Around line 121-132: The current buildKeyMap method uses a verbose three-arg
collect; simplify it by using Collectors.toMap: after mapping with
extractRsaJsonWebKey, collect into a HashMap with
Collectors.toMap(RsaJsonWebKey::getKeyId, Function.identity(), (a,b) -> b,
HashMap::new) to create the map and resolve duplicate keyIds by keeping the
latter; keep the existing empty-check and RuntimeException. Update imports as
needed and reference buildKeyMap and extractRsaJsonWebKey when making the
change.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java (2)

150-151: Thread.sleep-based refresh wait is inherently flaky.

JWKS_REFRESH_INTERVAL * 1_000 * 2 (2 s) leaves no margin for scheduler jitter, GC pauses, or a loaded CI runner. If the refresh fires even slightly after the sleep elapses the assertions on Lines 159 and 167 will produce misleading failures.

Consider replacing the sleep with a short polling loop that retries the request until the expected status appears (or a hard timeout is exceeded):

// Poll until key2 is accepted (max ~5 s)
long deadline = System.currentTimeMillis() + 5_000;
int status;
do {
    Exchange poll = new Request.Builder()
            .get(authInterceptorUrl)
            .header("Authorization", "Bearer " + createJwt(privateKey2))
            .buildExchange();
    hc.call(poll);
    status = poll.getResponse().getStatusCode();
    if (status != 200) Thread.sleep(200);
} while (status != 200 && System.currentTimeMillis() < deadline);
assertEquals(200, status, "JWKS refresh did not propagate in time");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`
around lines 150 - 151, Replace the fragile Thread.sleep-based wait (where
currentJwkSet.set(...) is followed by Thread.sleep(JWKS_REFRESH_INTERVAL * 1_000
* 2)) with a polling loop that repeatedly issues the same request used in the
test (use authInterceptorUrl and createJwt(privateKey2) to build the request and
call hc.call on the Exchange) until the response status becomes 200 or a hard
deadline is exceeded; poll with a short delay (e.g., 200ms) between attempts and
assert after the loop that the final status is 200 with a helpful message like
"JWKS refresh did not propagate in time" so scheduler jitter/GC pauses don’t
cause flaky failures.

154-167: exc2 / exc3 variable names are out of execution order.

exc3 is declared and used before exc2, making the numeric suffixes misleading. Rename them to exc3exc2 and exc2exc3 (or use excNewKey / excOldKey) to match the execution sequence.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`
around lines 154 - 167, In JwksRefreshTest.java swap or rename the misordered
Exchange variables so names reflect execution order: change the first-created
"exc3" to "exc2" (or "excNewKey") and the subsequently-created "exc2" to "exc3"
(or "excOldKey"), and update all usages (the Request.Builder(), hc.call(...),
and assertEquals(...).getResponse() checks) to use the new identifiers so the
newer-key request is declared/used first and the old-key request second.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`:
- Around line 67-81: Replace the brittle Thread.sleep(1000) / implicit startup
assumption with an explicit readiness poll for both the JWKS provider and the
jwtValidator router: after calling DefaultRouter.start() on jwtValidator (and
after the JWKS provider start), loop with short delays and a timeout to attempt
a lightweight TCP/HTTP probe (e.g., connect to AUTH_INTERCEPTOR_PORT or send an
HTTP HEAD/GET) until successful before proceeding to hc.call(exc1) in
testRefresh(); this ensures the listener bound state is detected
deterministically (you can use Awaitility or a simple retry loop) and reference
the Thread.sleep(1000), jwtValidator, DefaultRouter.start(), and hc.call(exc1)
locations when implementing the change.
- Around line 130-132: teardown currently calls jwksProvider.stop() then
jwtValidator.stop(), but if jwksProvider.stop() throws the jwtValidator.stop()
call is skipped; wrap the jwksProvider.stop() in a try block and ensure
jwtValidator.stop() is always executed in a finally (or vice versa), capturing
any thrown exceptions and rethrowing one (adding the other as suppressed) so
both servers are attempted to be stopped; update the teardown() method to use
try/finally (or try/catch with addSuppressed) around jwksProvider.stop() and
jwtValidator.stop() to guarantee jwtValidator.stop() runs even when
jwksProvider.stop() fails.
- Line 125: The HttpClient created in buildAuthorizationService via
authService.setHttpClient(new HttpClient()) is never closed; change
buildAuthorizationService to assign that HttpClient instance to a static (or
otherwise shared) field (e.g., a private static HttpClient testHttpClient) so it
can be referenced later, and then close that stored HttpClient in teardown()
(invoke close() on testHttpClient and null it) to ensure the connection pool is
released; keep references to HttpClient, buildAuthorizationService,
authService.setHttpClient(...), and teardown() to locate the changes.

---

Duplicate comments:
In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`:
- Around line 35-36: The test currently uses hardcoded port constants
PROVIDER_PORT and AUTH_INTERCEPTOR_PORT in JwksRefreshTest which can cause
collisions in parallel or CI runs; change these to dynamically allocated/free
ports (e.g., via a test utility like getFreePort()/PortUtil or by binding
ServerSocket(0) and using its assigned port) and update any places that
reference PROVIDER_PORT/AUTH_INTERCEPTOR_PORT to use the allocated values so
tests no longer rely on fixed ports.

---

Nitpick comments:
In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`:
- Around line 150-151: Replace the fragile Thread.sleep-based wait (where
currentJwkSet.set(...) is followed by Thread.sleep(JWKS_REFRESH_INTERVAL * 1_000
* 2)) with a polling loop that repeatedly issues the same request used in the
test (use authInterceptorUrl and createJwt(privateKey2) to build the request and
call hc.call on the Exchange) until the response status becomes 200 or a hard
deadline is exceeded; poll with a short delay (e.g., 200ms) between attempts and
assert after the loop that the final status is 200 with a helpful message like
"JWKS refresh did not propagate in time" so scheduler jitter/GC pauses don’t
cause flaky failures.
- Around line 154-167: In JwksRefreshTest.java swap or rename the misordered
Exchange variables so names reflect execution order: change the first-created
"exc3" to "exc2" (or "excNewKey") and the subsequently-created "exc2" to "exc3"
(or "excOldKey"), and update all usages (the Request.Builder(), hc.call(...),
and assertEquals(...).getResponse() checks) to use the new identifiers so the
newer-key request is declared/used first and the old-key request second.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (1)

177-183: ⚠️ Potential issue | 🟠 Major

Harden JWKS URI parsing and close the response stream.

loadJwks() expects a non-null list (Line 154 stream use), and this parse path should also close InputStream to avoid leaks during periodic refresh.

🔧 Proposed fix
     private JwkListByUri parseJwksUriIntoList(ResolverMap resolverMap, String baseLocation, ObjectMapper mapper, String uri, boolean suppressExceptions) {
         try {
-            InputStream resolve = authorizationService != null ?
-                    authorizationService.resolve(resolverMap, baseLocation, uri) :
-                    resolverMap.resolve(ResolverMap.combine(baseLocation, uri));
-            return new JwkListByUri(uri, mapper.convertValue(mapper.readTree(resolve).path("keys"), List.class));
+            try (InputStream resolve = authorizationService != null
+                    ? authorizationService.resolve(resolverMap, baseLocation, uri)
+                    : resolverMap.resolve(ResolverMap.combine(baseLocation, uri))) {
+                var keysNode = mapper.readTree(resolve).path("keys");
+                var keys = keysNode.isArray() ? mapper.convertValue(keysNode, List.class) : emptyList();
+                return new JwkListByUri(uri, keys);
+            }
         } catch (JsonProcessingException e) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 177 - 183, parseJwksUriIntoList currently opens an InputStream via
authorizationService.resolve / resolverMap.resolve but doesn't close it and may
return a null keys list; change the implementation to use a try-with-resources
around the InputStream obtained from authorizationService.resolve or
resolverMap.resolve so the stream is always closed, catch
JsonProcessingException/IOException as before, and ensure the JwkListByUri is
constructed with a non-null List (use Collections.emptyList() as a fallback if
mapper.convertValue or readTree.path("keys") yields null). Refer to
parseJwksUriIntoList, JwkListByUri, authorizationService.resolve,
resolverMap.resolve and mapper.readTree when making the change.
core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java (1)

125-132: ⚠️ Potential issue | 🟡 Minor

Ensure test resources are always released in teardown.

Line 125 creates a HttpClient with no owning close path, and Lines 130-132 can skip jwtValidator.stop() if the first stop throws.

🛠️ Suggested cleanup
 public class JwksRefreshTest {
+    static HttpClient authServiceHttpClient;
@@
     private static `@NotNull` AuthorizationService buildAuthorizationService(int jwksRefreshInterval) {
@@
-        authService.setHttpClient(new HttpClient());
+        authServiceHttpClient = new HttpClient();
+        authService.setHttpClient(authServiceHttpClient);
         return authService;
     }
@@
     `@AfterAll`
     public static void teardown() throws IOException {
-        jwksProvider.stop();
-        jwtValidator.stop();
+        IOException thrown = null;
+        try {
+            jwksProvider.stop();
+        } catch (IOException e) {
+            thrown = e;
+        } finally {
+            try {
+                jwtValidator.stop();
+            } catch (IOException e) {
+                if (thrown != null) thrown.addSuppressed(e);
+                else thrown = e;
+            }
+            if (authServiceHttpClient != null) {
+                authServiceHttpClient.close();
+                authServiceHttpClient = null;
+            }
+        }
+        if (thrown != null) throw thrown;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`
around lines 125 - 132, The test creates a new HttpClient via
authService.setHttpClient(new HttpClient()) without closing it and the `@AfterAll`
teardown calls jwksProvider.stop() then jwtValidator.stop() so an exception from
jwksProvider.stop() can skip jwtValidator.stop(); fix by retaining the
HttpClient instance you create (e.g., a field or getter from authService) and
close it in teardown, and make teardown robust by ensuring both stops run (wrap
jwksProvider.stop() in try/catch or use try { jwksProvider.stop(); } finally {
jwtValidator.stop(); } so jwtValidator.stop() always executes), updating
JwksRefreshTest accordingly to reference authService.setHttpClient/new
HttpClient, jwksProvider.stop(), and jwtValidator.stop().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 270-272: The stream filter in Jwks.java risks a NPE by calling
m.get("kid").toString(); change the predicate to safely handle missing kid
values (e.g., retrieve Object k = m.get("kid"); return k != null &&
k.toString().equals(kid)) or use Objects.equals(String.valueOf(m.get("kid")),
kid) to avoid nullable dereference; apply the same safe-check pattern to the
other occurrence that compares kid (the map variable m in the stream and its
subsequent map/flatMap usage).

---

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 177-183: parseJwksUriIntoList currently opens an InputStream via
authorizationService.resolve / resolverMap.resolve but doesn't close it and may
return a null keys list; change the implementation to use a try-with-resources
around the InputStream obtained from authorizationService.resolve or
resolverMap.resolve so the stream is always closed, catch
JsonProcessingException/IOException as before, and ensure the JwkListByUri is
constructed with a non-null List (use Collections.emptyList() as a fallback if
mapper.convertValue or readTree.path("keys") yields null). Refer to
parseJwksUriIntoList, JwkListByUri, authorizationService.resolve,
resolverMap.resolve and mapper.readTree when making the change.

In
`@core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java`:
- Around line 125-132: The test creates a new HttpClient via
authService.setHttpClient(new HttpClient()) without closing it and the `@AfterAll`
teardown calls jwksProvider.stop() then jwtValidator.stop() so an exception from
jwksProvider.stop() can skip jwtValidator.stop(); fix by retaining the
HttpClient instance you create (e.g., a field or getter from authService) and
close it in teardown, and make teardown robust by ensuring both stops run (wrap
jwksProvider.stop() in try/catch or use try { jwksProvider.stop(); } finally {
jwtValidator.stop(); } so jwtValidator.stop() always executes), updating
JwksRefreshTest accordingly to reference authService.setHttpClient/new
HttpClient, jwksProvider.stop(), and jwtValidator.stop().

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bfb09aa and 8597a2e.

📒 Files selected for processing (8)
  • core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java
  • core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java
  • core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/authorizationservice/AuthorizationService.java
  • core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwksRefreshTest.java
  • core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptorTest.java
  • core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptorUnitTests.java
  • core/src/test/java/com/predic8/membrane/core/openapi/validators/security/JWTInterceptorAndSecurityValidatorTest.java
  • core/src/test/java/com/predic8/membrane/core/security/JWTSecuritySchemeTest.java

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (2)

99-101: ⚠️ Potential issue | 🟡 Minor

Validate before mutating internal state in setJwks.

Line 99 assigns this.jwks before the null check, so a failing call can still leave internal state as null. Move the validation ahead of assignment.

Suggested fix
 public Jwks setJwks(List<Jwk> jwks) {
-    this.jwks = jwks;  // unnecessary, mainly for consistency when debugging
     if (jwks == null)
         throw new ConfigurationException("JWKs list must not be null.");
+    this.jwks = jwks;  // unnecessary, mainly for consistency when debugging
     if (router != null)  // set in init, so we can't update prior to that call
         this.keysByKid = buildKeyMap(jwks);
     return this;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 99 - 101, In setJwks, perform the null validation on the parameter
jwks before mutating internal state: check if jwks == null and throw
ConfigurationException if so, then assign this.jwks = jwks; this ensures the
object's state is not changed when validation fails (references: setJwks method,
this.jwks, parameter jwks).

178-180: ⚠️ Potential issue | 🟠 Major

Guard missing/non-array "keys" to keep jwks() non-null.

Line 180 can still produce a null or invalid list shape for "keys", and downstream Line 155 streams l.jwks() directly. Normalize to an always non-null list before constructing JwkListByUri.

Suggested fix
     private JwkListByUri parseJwksUriIntoList(ResolverMap resolverMap, String baseLocation, ObjectMapper mapper, String uri, boolean suppressExceptions) {
         try (InputStream jwksResponse = resolveToken(resolverMap, baseLocation, uri)) {
-            return new JwkListByUri(uri, mapper.convertValue(mapper.readTree(jwksResponse).path("keys"), List.class));
+            var root = mapper.readTree(jwksResponse);
+            var keysNode = root.path("keys");
+            List<?> keys = keysNode.isArray()
+                    ? mapper.convertValue(keysNode, List.class)
+                    : emptyList();
+            return new JwkListByUri(uri, keys == null ? emptyList() : keys);
         } catch (Exception e) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 178 - 180, The parseJwksUriIntoList method can pass a null or
non-list value for the "keys" field into the JwkListByUri constructor; update
parseJwksUriIntoList so it reads the "keys" node via
mapper.readTree(jwksResponse).path("keys"), checks if it is an array (or
non-null), and if not replaces it with an empty List before calling new
JwkListByUri(uri, ...); ensure the normalized non-null List result is what
JwkListByUri and its jwks() accessor receive so downstream streaming over
l.jwks() never gets a null.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 153-154: The tokenization of jwksUris currently uses split(" ")
which yields empty strings for consecutive spaces and misses other whitespace;
change the splitting to use a whitespace regex (e.g. "\\s+") and filter out
blank/empty entries before mapping to parseJwksUriIntoList so only valid URIs
are passed to parseJwksUriIntoList(router.getResolverMap(),
router.getConfiguration().getBaseLocation(), mapper, uri, suppressExceptions);
ensure each token is trimmed and skipped if blank.
- Around line 271-272: In handleJwks (class Jwks) avoid the unchecked cast by
first validating that mapped.get("keys") is a List and that each element is a
Map before using it; if the value is missing or not the expected shape,
throw/return a clear error (e.g., IllegalArgumentException with a descriptive
message) instead of casting directly, then convert the validated List<Map> and
continue the stream/filter by comparing the "kid" value as before.

---

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 99-101: In setJwks, perform the null validation on the parameter
jwks before mutating internal state: check if jwks == null and throw
ConfigurationException if so, then assign this.jwks = jwks; this ensures the
object's state is not changed when validation fails (references: setJwks method,
this.jwks, parameter jwks).
- Around line 178-180: The parseJwksUriIntoList method can pass a null or
non-list value for the "keys" field into the JwkListByUri constructor; update
parseJwksUriIntoList so it reads the "keys" node via
mapper.readTree(jwksResponse).path("keys"), checks if it is an array (or
non-null), and if not replaces it with an empty List before calling new
JwkListByUri(uri, ...); ensure the normalized non-null List result is what
JwkListByUri and its jwks() accessor receive so downstream streaming over
l.jwks() never gets a null.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 04b0659 and 2939442.

📒 Files selected for processing (3)
  • core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java
  • core/src/main/java/com/predic8/membrane/core/transport/http/HttpEndpointListener.java
  • core/src/main/java/com/predic8/membrane/core/util/TimerTaskUtil.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/src/main/java/com/predic8/membrane/core/util/TimerTaskUtil.java

@bridgerdier bridgerdier requested a review from predic8 March 3, 2026 09:31
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (2)

178-181: ⚠️ Potential issue | 🟠 Major

NPE if JWKS response does not contain a "keys" field.

mapper.readTree(jwksResponse).path("keys") returns a MissingNode when the field is absent. When convertValue is called on MissingNode targeting List.class, it returns null. This causes an NPE later when l.jwks().stream() is called in loadJwks().

🐛 Proposed fix: handle missing or null "keys"
     private JwkListByUri parseJwksUriIntoList(ResolverMap resolverMap, String baseLocation, ObjectMapper mapper, String uri, boolean suppressExceptions) {
         try (InputStream jwksResponse = resolveToken(resolverMap, baseLocation, uri)) {
-            return new JwkListByUri(uri, mapper.convertValue(mapper.readTree(jwksResponse).path("keys"), List.class));
+            List<?> keys = mapper.convertValue(mapper.readTree(jwksResponse).path("keys"), List.class);
+            return new JwkListByUri(uri, keys != null ? keys : emptyList());
         } catch (Exception e) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 178 - 181, parseJwksUriIntoList can produce a JwkListByUri with a
null keys list because mapper.readTree(...).path("keys") yields a MissingNode
and mapper.convertValue returns null; update parseJwksUriIntoList to inspect the
JsonNode returned from mapper.readTree(...).path("keys") and if it is missing or
null, substitute an empty List before constructing the JwkListByUri (so
downstream loadJwks() can safely call l.jwks().stream()); specifically check the
node with JsonNode methods (e.g., isMissingNode/isNull) and only call
mapper.convertValue when a real node exists, otherwise use
Collections.emptyList().

98-105: ⚠️ Potential issue | 🟠 Major

Null check occurs after assignment, corrupting state on invalid input.

Line 99 assigns this.jwks = jwks before the null check on lines 100-101. If jwks is null, the field gets assigned null, and then the exception is thrown—leaving the object in a corrupted state.

🐛 Proposed fix: check null before assignment
 `@MCChildElement`
 public Jwks setJwks(List<Jwk> jwks) {
-    this.jwks = jwks;  // unnecessary, mainly for consistency when debugging
     if (jwks == null)
         throw new ConfigurationException("JWKs list must not be null.");
+    this.jwks = jwks;  // unnecessary, mainly for consistency when debugging
     if (router != null)  // set in init, so we can't update prior to that call
         this.keysByKid = buildKeyMap(jwks);
     return this;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 98 - 105, In Jwks.setJwks, the method currently assigns this.jwks
before validating the input which can corrupt object state; change the logic so
you validate jwks for null first (throw ConfigurationException if null) and only
then assign this.jwks and, if router != null, update this.keysByKid via
buildKeyMap(jwks); ensure references are to the existing method Jwks.setJwks,
the field this.jwks, the router check, and the keysByKid = buildKeyMap(jwks)
call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 182-192: In Jwks exception handling, avoid double-logging when
suppressExceptions is true by removing the log.error(e.toString()) inside the
switch default branch and either (a) set message = e.getMessage() in the default
branch and let the later conditional log.error(message) handle logging once, or
(b) prefer a single detailed log.error("Could not retrieve JWK keys from %s:
%s", uri, e) in the later suppressExceptions block to include the full
exception; update the switch over `e` in class Jwks so only one log.error call
happens (refer to variables/methods: e, suppressExceptions, message, log.error,
JwkListByUri).

---

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 178-181: parseJwksUriIntoList can produce a JwkListByUri with a
null keys list because mapper.readTree(...).path("keys") yields a MissingNode
and mapper.convertValue returns null; update parseJwksUriIntoList to inspect the
JsonNode returned from mapper.readTree(...).path("keys") and if it is missing or
null, substitute an empty List before constructing the JwkListByUri (so
downstream loadJwks() can safely call l.jwks().stream()); specifically check the
node with JsonNode methods (e.g., isMissingNode/isNull) and only call
mapper.convertValue when a real node exists, otherwise use
Collections.emptyList().
- Around line 98-105: In Jwks.setJwks, the method currently assigns this.jwks
before validating the input which can corrupt object state; change the logic so
you validate jwks for null first (throw ConfigurationException if null) and only
then assign this.jwks and, if router != null, update this.keysByKid via
buildKeyMap(jwks); ensure references are to the existing method Jwks.setJwks,
the field this.jwks, the router check, and the keysByKid = buildKeyMap(jwks)
call.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2939442 and 2e2e23c.

📒 Files selected for processing (3)
  • core/src/main/java/com/predic8/membrane/core/exchangestore/FileExchangeStore.java
  • core/src/main/java/com/predic8/membrane/core/interceptor/balancer/BalancerHealthMonitor.java
  • core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • core/src/main/java/com/predic8/membrane/core/interceptor/balancer/BalancerHealthMonitor.java
  • core/src/main/java/com/predic8/membrane/core/exchangestore/FileExchangeStore.java

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (3)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (3)

183-193: ⚠️ Potential issue | 🟡 Minor

Consolidate suppressed-exception logging to a single entry.

In the default branch, suppressed mode logs e.toString() and then logs message again, creating duplicate error logs for one failure.

🔧 Suggested fix
                 default -> {
-                    if (suppressExceptions) log.error(e.toString());
                     yield e.getMessage();
                 }
             };
             if (suppressExceptions) {
-                log.error(message);
+                log.error(message, e);
                 return new JwkListByUri(uri, emptyList());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 183 - 193, The default branch in Jwks error handling currently logs
e.toString() and then logs message again when suppressExceptions is true,
producing duplicate logs; remove the early log.error(e.toString()) and instead
log once (e.g., call log.error(message, e) or log.error(message) depending on
desired detail) where suppressExceptions is checked before returning the new
JwkListByUri(uri, emptyList()); update the default switch branch to only set
message (or include e details in message) and let the later suppressExceptions
block perform the single logging action.

271-272: ⚠️ Potential issue | 🟡 Minor

Validate JWKS keys shape before unchecked cast.

(List<Map>) mapped.get("keys") can throw ClassCastException for malformed payloads and loses a clear config/error message.

🔧 Suggested fix
         private String handleJwks(ObjectMapper mapper, Map<String, Object> mapped) {
-            return ((List<Map>) mapped.get("keys")).stream()
+            Object keysObj = mapped.get("keys");
+            if (!(keysObj instanceof List<?> keys))
+                throw new RuntimeException("Invalid JWKS: 'keys' must be an array.");
+
+            return keys.stream()
+                    .filter(Map.class::isInstance)
+                    .map(Map.class::cast)
                     .filter(m -> Objects.equals(m.get("kid"), kid))
                     .map(m -> {
                         try {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 271 - 272, The cast (List<Map>) mapped.get("keys") in Jwks.java is
unsafe and can throw ClassCastException; before casting, check that
mapped.get("keys") is non-null and an instance of List, and that each element is
a Map (or Map<?,?>). If the shape is invalid, throw a clear, descriptive
exception (e.g., IllegalArgumentException or a custom JWKS parsing exception)
mentioning the bad "keys" payload and include the actual type/value for
diagnostics; then safely cast and continue with the existing
.stream().filter(...) logic that matches on "kid".

99-101: ⚠️ Potential issue | 🟡 Minor

Validate jwks before assigning to field.

this.jwks is set before the null check. If callers catch the exception, the object can remain in an invalid state (jwks == null).

🔧 Suggested fix
     public Jwks setJwks(List<Jwk> jwks) {
-        this.jwks = jwks;  // unnecessary, mainly for consistency when debugging
         if (jwks == null)
             throw new ConfigurationException("JWKs list must not be null.");
+        this.jwks = jwks;  // unnecessary, mainly for consistency when debugging
         if (router != null)  // set in init, so we can't update prior to that call
             this.keysByKid = buildKeyMap(jwks);
         return this;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 99 - 101, The constructor (or setter) in class Jwks currently
assigns this.jwks before validating the parameter; change the order so you
validate jwks first (if (jwks == null) throw new ConfigurationException(...))
and only then assign this.jwks = jwks, ensuring the Jwks instance cannot be left
with a null jwks field if an exception is thrown.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 79-80: The check treating jwksUris as unset should also consider
whitespace-only values; update the condition in the Jwks class where jwksUris is
tested (the if that currently uses jwksUris == null || jwksUris.isEmpty()) to
treat strings like "   " as unset by trimming or using isBlank (e.g., jwksUris
== null || jwksUris.trim().isEmpty() or jwksUris == null || jwksUris.isBlank()),
so subsequent logic that relies on a configured JWKS URI does not receive
whitespace-only input.

---

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 183-193: The default branch in Jwks error handling currently logs
e.toString() and then logs message again when suppressExceptions is true,
producing duplicate logs; remove the early log.error(e.toString()) and instead
log once (e.g., call log.error(message, e) or log.error(message) depending on
desired detail) where suppressExceptions is checked before returning the new
JwkListByUri(uri, emptyList()); update the default switch branch to only set
message (or include e details in message) and let the later suppressExceptions
block perform the single logging action.
- Around line 271-272: The cast (List<Map>) mapped.get("keys") in Jwks.java is
unsafe and can throw ClassCastException; before casting, check that
mapped.get("keys") is non-null and an instance of List, and that each element is
a Map (or Map<?,?>). If the shape is invalid, throw a clear, descriptive
exception (e.g., IllegalArgumentException or a custom JWKS parsing exception)
mentioning the bad "keys" payload and include the actual type/value for
diagnostics; then safely cast and continue with the existing
.stream().filter(...) logic that matches on "kid".
- Around line 99-101: The constructor (or setter) in class Jwks currently
assigns this.jwks before validating the parameter; change the order so you
validate jwks first (if (jwks == null) throw new ConfigurationException(...))
and only then assign this.jwks = jwks, ensuring the Jwks instance cannot be left
with a null jwks field if an exception is thrown.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2e2e23c and 5f87392.

📒 Files selected for processing (1)
  • core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (1)

184-194: ⚠️ Potential issue | 🟡 Minor

Consolidate suppress-path logging to a single entry.

In the default case, Line 188 logs e.toString(), and Line 193 logs message again for the same failure. This duplicates error logs.

🔧 Proposed fix
 String message = switch (e) {
     case JsonProcessingException ignored -> "Could not parse JWK keys retrieved from %s.".formatted(uri);
     case ResourceRetrievalException ignored -> "Could not retrieve JWK keys from %s.".formatted(uri);
     default -> {
-        if (suppressExceptions) log.error(e.toString());
         yield e.getMessage();
     }
 };
 if (suppressExceptions) {
-    log.error(message);
+    log.error(message, e);
     return new JwkListByUri(uri, emptyList());
 } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 184 - 194, The code in Jwks that handles exception logging
duplicates logs when suppressExceptions is true by calling
log.error(e.toString()) in the switch default and then log.error(message)
afterward; update the flow so only a single log call occurs: remove the
log.error(e.toString()) from the switch default, build the message as before
(including e.getMessage() or formatted URI messages), and then, if
suppressExceptions is true, call log.error(...) once with a combined message
that includes both the human-readable message and the exception details (e.g.,
e.toString() or the throwable) before returning new JwkListByUri(uri,
emptyList()); ensure references: e, suppressExceptions, log, JwkListByUri, uri,
and the switch block in class Jwks are updated accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 291-294: The getLongDescription() method incorrectly uses jwksUris
!= null to detect URI mode but jwksUris is initialized to an empty list, so
inline JWK configs still hit the URI branch; update the branch condition in
getLongDescription() to check that jwksUris contains elements (e.g., jwksUris !=
null && !jwksUris.isEmpty() or simply !jwksUris.isEmpty()) so only non-empty URI
lists produce the "JWKs from ..." description; reference the
getLongDescription() method and the jwksUris field when making the change.
- Around line 102-106: In Jwks.setJwks, validate the incoming jwks parameter
before mutating object state: perform the null check (and any router-related
checks like the router != null condition) and throw ConfigurationException if
invalid, then assign this.jwks = jwks only after validation succeeds; update the
method to avoid assigning to this.jwks prior to the null check to prevent
leaving the object in an inconsistent state.
- Around line 116-120: The setter Jwks.setJwksUris currently calls
jwksUris.trim() unconditionally which throws NPE for null input; update
setJwksUris to guard against null by checking if jwksUris == null (or blank)
first and assign an empty list (e.g. List.of()) in that case, otherwise run the
existing
Arrays.stream(jwksUris.trim().split("\\s+")).map(StringUtils::strip).filter(StringUtils::isNotBlank).toList();
ensure the method still sets this.jwksUris and returns this.

---

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 184-194: The code in Jwks that handles exception logging
duplicates logs when suppressExceptions is true by calling
log.error(e.toString()) in the switch default and then log.error(message)
afterward; update the flow so only a single log call occurs: remove the
log.error(e.toString()) from the switch default, build the message as before
(including e.getMessage() or formatted URI messages), and then, if
suppressExceptions is true, call log.error(...) once with a combined message
that includes both the human-readable message and the exception details (e.g.,
e.toString() or the throwable) before returning new JwkListByUri(uri,
emptyList()); ensure references: e, suppressExceptions, log, JwkListByUri, uri,
and the switch block in class Jwks are updated accordingly.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5f87392 and 3ed1742.

📒 Files selected for processing (1)
  • core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (3)

188-198: ⚠️ Potential issue | 🟡 Minor

Suppressed exception path still logs the same failure twice.

When suppressExceptions is true and execution hits the default branch, it logs once in the switch and again in the suppress block.

🔧 Proposed fix
 String message = switch (e) {
     case JsonProcessingException ignored -> "Could not parse JWK keys retrieved from %s.".formatted(uri);
     case ResourceRetrievalException ignored -> "Could not retrieve JWK keys from %s.".formatted(uri);
-    default -> {
-        if (suppressExceptions) log.error(e.toString());
-        yield e.getMessage();
-    }
+    default -> e.getMessage() != null ? e.getMessage() : e.toString();
 };
 if (suppressExceptions) {
-    log.error(message);
+    log.error(message, e);
     return new JwkListByUri(uri, emptyList());
 } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 188 - 198, The suppress-exceptions path in Jwks currently logs the
same error twice: once inside the switch default branch and again just after
when suppressExceptions is true; update the switch expression in class Jwks so
the default branch does not call log.error(e.toString()) — just set the message
to e.getMessage() (or a formatted fallback) and let the existing
suppressExceptions block perform the single log.error(message) and return new
JwkListByUri(uri, emptyList()); this removes the duplicate logging while
preserving the existing behavior for other switch cases (JsonProcessingException
and ResourceRetrievalException) and the suppressExceptions conditional.

103-107: ⚠️ Potential issue | 🔴 Critical

Validate jwks unconditionally in setJwks to avoid pre-init NPE state.

setJwks(null) before init() still sets this.jwks = null, then init() dereferences jwks.isEmpty() (Line 84). This can crash startup.

🔧 Proposed fix
 `@MCChildElement`
 public Jwks setJwks(List<Jwk> jwks) {
+    if (jwks == null)
+        throw new ConfigurationException("JWKs list must not be null.");
     if (router != null) {  // set in init, so we can't update prior to that call
-        if (jwks == null) throw new ConfigurationException("JWKs list must not be null.");
         this.keysByKid = buildKeyMap(jwks);
     }
     this.jwks = jwks;  // unnecessary, mainly for consistency when debugging
     return this;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 103 - 107, setJwks currently allows jwks to be set to null before
init(), which leads to a NPE in init() when it dereferences jwks; fix by
validating jwks != null at the start of setJwks (throw ConfigurationException
immediately if null), then proceed with the existing router check and call
keysByKid = buildKeyMap(jwks) only after that validation; also remove or make
conditional the trailing assignment this.jwks = jwks so you don't overwrite the
field with null prior to init; refer to the setJwks method, the jwks field,
keysByKid field, init method, and buildKeyMap function when making the change.

132-137: ⚠️ Potential issue | 🟠 Major

Do not silently overwrite duplicate kid values in buildKeyMap.

Current merge strategy (second entry wins) hides configuration errors and can resolve the wrong verification key. Also reject null/blank kid explicitly.

🛡️ Proposed fix
 private Map<String, RsaJsonWebKey> buildKeyMap(List<Jwk> jwks) {
-    var keyMap = jwks.stream()
-            .map(this::extractRsaJsonWebKey)
-            .collect(toMap(RsaJsonWebKey::getKeyId, identity(),
-                    (k1, k2) -> k2  // second entry wins
-            ));
+    var keyMap = new HashMap<String, RsaJsonWebKey>();
+    for (RsaJsonWebKey key : jwks.stream().map(this::extractRsaJsonWebKey).toList()) {
+        String keyId = key.getKeyId();
+        if (StringUtils.isBlank(keyId))
+            throw new ConfigurationException("Each JWK must provide a non-empty 'kid'.");
+        if (keyMap.putIfAbsent(keyId, key) != null)
+            throw new ConfigurationException("Duplicate JWK kid '%s'.".formatted(keyId));
+    }
     if (keyMap.isEmpty())
         throw new RuntimeException("No JWKs given or none resolvable - please specify at least one resolvable JWK");
     return keyMap;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 132 - 137, In buildKeyMap, stop silently overwriting duplicate key
IDs and reject null/blank kids: validate each RsaJsonWebKey returned by
extractRsaJsonWebKey to ensure getKeyId() is non-null/non-blank and change the
collector merge function to throw an IllegalStateException (or custom exception)
when a duplicate kid is encountered (include both conflicting kid values in the
message). This ensures buildKeyMap fails fast on invalid or duplicated JWKs
instead of picking the second entry silently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 188-198: The suppress-exceptions path in Jwks currently logs the
same error twice: once inside the switch default branch and again just after
when suppressExceptions is true; update the switch expression in class Jwks so
the default branch does not call log.error(e.toString()) — just set the message
to e.getMessage() (or a formatted fallback) and let the existing
suppressExceptions block perform the single log.error(message) and return new
JwkListByUri(uri, emptyList()); this removes the duplicate logging while
preserving the existing behavior for other switch cases (JsonProcessingException
and ResourceRetrievalException) and the suppressExceptions conditional.
- Around line 103-107: setJwks currently allows jwks to be set to null before
init(), which leads to a NPE in init() when it dereferences jwks; fix by
validating jwks != null at the start of setJwks (throw ConfigurationException
immediately if null), then proceed with the existing router check and call
keysByKid = buildKeyMap(jwks) only after that validation; also remove or make
conditional the trailing assignment this.jwks = jwks so you don't overwrite the
field with null prior to init; refer to the setJwks method, the jwks field,
keysByKid field, init method, and buildKeyMap function when making the change.
- Around line 132-137: In buildKeyMap, stop silently overwriting duplicate key
IDs and reject null/blank kids: validate each RsaJsonWebKey returned by
extractRsaJsonWebKey to ensure getKeyId() is non-null/non-blank and change the
collector merge function to throw an IllegalStateException (or custom exception)
when a duplicate kid is encountered (include both conflicting kid values in the
message). This ensures buildKeyMap fails fast on invalid or duplicated JWKs
instead of picking the second entry silently.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3ed1742 and d1958b8.

📒 Files selected for processing (1)
  • core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (2)

132-140: ⚠️ Potential issue | 🟠 Major

Reject duplicate or blank kid values in buildKeyMap.

Line 136 currently makes “second entry wins”, which silently overwrites keys with the same kid. That can make key resolution ambiguous and unsafe.

Suggested fix
 private Map<String, RsaJsonWebKey> buildKeyMap(List<Jwk> jwks) {
     var keyMap = jwks.stream()
             .map(this::extractRsaJsonWebKey)
+            .peek(k -> {
+                if (StringUtils.isBlank(k.getKeyId()))
+                    throw new ConfigurationException("Each JWK must provide a non-empty 'kid'.");
+            })
             .collect(toMap(RsaJsonWebKey::getKeyId, identity(),
-                    (k1, k2) -> k2  // second entry wins
+                    (k1, k2) -> {
+                        throw new ConfigurationException("Duplicate JWK kid: " + k1.getKeyId());
+                    },
+                    HashMap::new
             ));
     if (keyMap.isEmpty())
         throw new RuntimeException("No JWKs given or none resolvable - please specify at least one resolvable JWK");
     return keyMap;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 132 - 140, In buildKeyMap(List<Jwk> jwks) validate that each
RsaJsonWebKey returned by extractRsaJsonWebKey has a non-null, non-blank key id
(RsaJsonWebKey::getKeyId) and reject duplicates instead of silently keeping the
second entry: during collection, detect empty/blank keyIds and throw a
RuntimeException, and change the merge behavior so that when two entries share
the same kid you throw a descriptive exception (e.g., "Duplicate JWK kid:
{kid}") rather than returning k2; ensure the exception is thrown before
returning keyMap.

188-198: ⚠️ Potential issue | 🟡 Minor

Avoid duplicate error logs in suppressed exception path.

Line 192 and Line 197 log the same failure twice when suppressExceptions is true.

Suggested fix
             String message = switch (e) {
                 case JsonProcessingException ignored -> "Could not parse JWK keys retrieved from %s.".formatted(uri);
                 case ResourceRetrievalException ignored -> "Could not retrieve JWK keys from %s.".formatted(uri);
-                default -> {
-                    if (suppressExceptions) log.error(e.toString());
-                    yield e.getMessage();
-                }
+                default -> Optional.ofNullable(e.getMessage()).orElse(e.toString());
             };
             if (suppressExceptions) {
-                log.error(message);
+                log.error(message, e);
                 return new JwkListByUri(uri, emptyList());
             } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 188 - 198, In Jwks where exceptions are handled (building the
`message` and returning a `JwkListByUri`), avoid logging the same error twice:
remove the inner conditional `log.error(e.toString())` inside the switch default
branch and only call `log.error(message)` (or vice versa) so when
`suppressExceptions` is true the error is logged exactly once; update the
switch/default branch in the method to only set `message` (no logging) and let
the existing `if (suppressExceptions) log.error(message);` perform the single
log call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 132-140: In buildKeyMap(List<Jwk> jwks) validate that each
RsaJsonWebKey returned by extractRsaJsonWebKey has a non-null, non-blank key id
(RsaJsonWebKey::getKeyId) and reject duplicates instead of silently keeping the
second entry: during collection, detect empty/blank keyIds and throw a
RuntimeException, and change the merge behavior so that when two entries share
the same kid you throw a descriptive exception (e.g., "Duplicate JWK kid:
{kid}") rather than returning k2; ensure the exception is thrown before
returning keyMap.
- Around line 188-198: In Jwks where exceptions are handled (building the
`message` and returning a `JwkListByUri`), avoid logging the same error twice:
remove the inner conditional `log.error(e.toString())` inside the switch default
branch and only call `log.error(message)` (or vice versa) so when
`suppressExceptions` is true the error is logged exactly once; update the
switch/default branch in the method to only set `message` (no logging) and let
the existing `if (suppressExceptions) log.error(message);` perform the single
log call.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3ed1742 and aec425f.

📒 Files selected for processing (1)
  • core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (4)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (2)

185-195: ⚠️ Potential issue | 🟡 Minor

Avoid double logging in suppressed-exception branch.

For the default exception case, the code logs e.toString() and then logs message, producing duplicate error records for the same failure.

Suggested log consolidation
             String message = switch (e) {
                 case JsonProcessingException ignored -> "Could not parse JWK keys retrieved from %s.".formatted(uri);
                 case ResourceRetrievalException ignored -> "Could not retrieve JWK keys from %s.".formatted(uri);
-                default -> {
-                    if (suppressExceptions) log.error(e.toString());
-                    yield e.getMessage();
-                }
+                default -> e.getMessage();
             };
             if (suppressExceptions) {
-                log.error(message);
+                log.error(message, e);
                 return new JwkListByUri(uri, emptyList());
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 185 - 195, In Jwks change the default-exception branch to avoid
logging twice: stop calling log.error(e.toString()) and instead log a single
message when suppressExceptions is true (preferably include the exception as
payload via log.error(message, e) or append e.getMessage() into message); update
the switch default branch so it only prepares the message (and does not log),
and let the subsequent suppressExceptions block perform the single log and
return the new JwkListByUri(uri, emptyList()).

130-135: ⚠️ Potential issue | 🟠 Major

Reject duplicate/blank kid values instead of silently overwriting.

The current merge strategy ((k1, k2) -> k2) lets later keys silently replace earlier ones for the same kid. That makes key resolution ambiguous and can mask bad JWKS documents.

Suggested validation-focused implementation
 private Map<String, RsaJsonWebKey> buildKeyMap(List<Jwk> jwks) {
-    var keyMap = jwks.stream()
-            .map(this::extractRsaJsonWebKey)
-            .collect(toMap(RsaJsonWebKey::getKeyId, identity(),
-                    (k1, k2) -> k2  // second entry wins
-            ));
+    var keyMap = new HashMap<String, RsaJsonWebKey>();
+    for (RsaJsonWebKey key : jwks.stream().map(this::extractRsaJsonWebKey).toList()) {
+        String kid = key.getKeyId();
+        if (kid == null || kid.isBlank()) {
+            throw new ConfigurationException("Each JWK must provide a non-empty 'kid'.");
+        }
+        if (keyMap.putIfAbsent(kid, key) != null) {
+            throw new ConfigurationException("Duplicate JWK kid '" + kid + "'.");
+        }
+    }
     if (keyMap.isEmpty())
         throw new RuntimeException("No JWKs given or none resolvable - please specify at least one resolvable JWK");
     return keyMap;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 130 - 135, The buildKeyMap method currently silently overwrites
duplicate key IDs; change it to reject blank/null and duplicate kids by
validating each RsaJsonWebKey from extractRsaJsonWebKey: if getKeyId() is
null/blank throw an IllegalArgumentException (or a specific runtime exception
used in this module) and when collecting into the map use a merge handler that
throws on duplicate instead of (k1,k2)->k2 (e.g. (a,b)->{ throw new
IllegalArgumentException("Duplicate kid: "+a.getKeyId()); }). Ensure the
exception type matches existing error handling so callers see a clear failure
when JWKS contains blank or duplicate kid values.
core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/JwksRefreshTest.java (2)

126-130: ⚠️ Potential issue | 🟡 Minor

Ensure all resources are stopped/closed even if one teardown step fails.

If jwksProvider.stop() throws, jwtValidator.stop() and httpClient.close() are skipped. That can leak test resources and cause follow-up port conflicts.

Suggested teardown hardening
 `@AfterAll`
 public static void teardown() throws IOException {
-    jwksProvider.stop();
-    jwtValidator.stop();
-    httpClient.close();
+    IOException first = null;
+    try {
+        if (jwksProvider != null) jwksProvider.stop();
+    } catch (IOException e) {
+        first = e;
+    } finally {
+        try {
+            if (jwtValidator != null) jwtValidator.stop();
+        } catch (IOException e) {
+            if (first == null) first = e; else first.addSuppressed(e);
+        }
+        try {
+            if (httpClient != null) httpClient.close();
+        } catch (IOException e) {
+            if (first == null) first = e; else first.addSuppressed(e);
+        }
+    }
+    if (first != null) throw first;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/JwksRefreshTest.java`
around lines 126 - 130, The AfterAll teardown (method teardown) currently calls
jwksProvider.stop(), jwtValidator.stop(), and httpClient.close() sequentially so
an exception from jwksProvider.stop() can skip the others; change teardown to
attempt each resource shutdown independently by wrapping each call
(jwksProvider.stop, jwtValidator.stop, httpClient.close) in its own try/catch,
record the first thrown exception (or add others as suppressed) and after all
attempts rethrow the recorded exception so failures are reported but all
resources are still stopped/closed.

70-79: ⚠️ Potential issue | 🟡 Minor

Replace fixed sleeps with explicit readiness/wait conditions.

Thread.sleep(...) here makes the test timing-sensitive, and there is still no explicit readiness check after jwtValidator.start() before the first call (Line 144). This can create intermittent CI failures.

Suggested stabilization patch
@@
     `@BeforeAll`
     public static void setup() throws Exception {
@@
         jwksProvider.start();
-
-        // Wait for jwksProvider to start
-        Thread.sleep(1000);
+        waitUntilReachable(PROVIDER_PORT, 5000);
@@
         jwtValidator.start();
+        waitUntilReachable(AUTH_INTERCEPTOR_PORT, 5000);
     }
@@
     public void testRefresh() throws Exception {
@@
             currentJwkSet.set(new JsonWebKeySet(publicKey2));
-            Thread.sleep(JWKS_REFRESH_INTERVAL * 1_000 * 2); // wait for refresh
+            waitForStatus(hc, authInterceptorUrl, createJwt(privateKey2), 200, JWKS_REFRESH_INTERVAL * 4_000L);
@@
     }
+
+    private static void waitUntilReachable(int port, long timeoutMs) throws InterruptedException {
+        long deadline = System.currentTimeMillis() + timeoutMs;
+        while (System.currentTimeMillis() < deadline) {
+            try (java.net.Socket s = new java.net.Socket("localhost", port)) {
+                return;
+            } catch (IOException ignored) {
+                Thread.sleep(100);
+            }
+        }
+        throw new RuntimeException("Timeout waiting for localhost:" + port);
+    }
+
+    private static void waitForStatus(HttpClient hc, String url, String jwt, int expectedStatus, long timeoutMs) throws Exception {
+        long deadline = System.currentTimeMillis() + timeoutMs;
+        while (System.currentTimeMillis() < deadline) {
+            Exchange exc = new Request.Builder()
+                    .get(url)
+                    .header("Authorization", "Bearer " + jwt)
+                    .buildExchange();
+            hc.call(exc);
+            if (exc.getResponse().getStatusCode() == expectedStatus) return;
+            Thread.sleep(100);
+        }
+        throw new RuntimeException("Timeout waiting for status " + expectedStatus);
+    }

Also applies to: 149-149

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/JwksRefreshTest.java`
around lines 70 - 79, The Thread.sleep(1000) in JwksRefreshTest makes the test
flaky; replace the fixed sleep with an explicit readiness/wait: wait for the
jwks provider and the jwtValidator to be ready instead of sleeping (e.g., poll a
health/readiness endpoint or use a CountDownLatch/awaitStartup mechanism), then
only create HttpClient and proceed; also add the same explicit wait after
jwtValidator.start() before the first request to ensure jwtValidator.isStarted()
or the test HTTP port is accepting connections. Use identifiers jwksProvider,
Thread.sleep, jwtValidator, jwtValidator.start() and the test’s first request
location to find where to add the polling/await logic.
🧹 Nitpick comments (1)
core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsUtil.java (1)

41-42: Consider renaming splitBySpace to match broader delimiter support.

StringList.parseToSet(...) handles commas and whitespace, so the current method name is now narrower than behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsUtil.java`
around lines 41 - 42, The method name splitBySpace in CorsUtil is misleading
because StringList.parseToSet accepts commas and whitespace; rename splitBySpace
to a clearer name such as splitByWhitespaceOrComma (or
splitByDelimiters/parseOrigins) and update its Javadoc to reflect
comma+whitespace support, then update all call sites to the new name; to avoid
breakage, add a deprecated splitBySpace wrapper that delegates to the new method
(keeping the same signature) and mark it `@Deprecated` so callers can migrate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 185-195: In Jwks change the default-exception branch to avoid
logging twice: stop calling log.error(e.toString()) and instead log a single
message when suppressExceptions is true (preferably include the exception as
payload via log.error(message, e) or append e.getMessage() into message); update
the switch default branch so it only prepares the message (and does not log),
and let the subsequent suppressExceptions block perform the single log and
return the new JwkListByUri(uri, emptyList()).
- Around line 130-135: The buildKeyMap method currently silently overwrites
duplicate key IDs; change it to reject blank/null and duplicate kids by
validating each RsaJsonWebKey from extractRsaJsonWebKey: if getKeyId() is
null/blank throw an IllegalArgumentException (or a specific runtime exception
used in this module) and when collecting into the map use a merge handler that
throws on duplicate instead of (k1,k2)->k2 (e.g. (a,b)->{ throw new
IllegalArgumentException("Duplicate kid: "+a.getKeyId()); }). Ensure the
exception type matches existing error handling so callers see a clear failure
when JWKS contains blank or duplicate kid values.

In
`@core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/JwksRefreshTest.java`:
- Around line 126-130: The AfterAll teardown (method teardown) currently calls
jwksProvider.stop(), jwtValidator.stop(), and httpClient.close() sequentially so
an exception from jwksProvider.stop() can skip the others; change teardown to
attempt each resource shutdown independently by wrapping each call
(jwksProvider.stop, jwtValidator.stop, httpClient.close) in its own try/catch,
record the first thrown exception (or add others as suppressed) and after all
attempts rethrow the recorded exception so failures are reported but all
resources are still stopped/closed.
- Around line 70-79: The Thread.sleep(1000) in JwksRefreshTest makes the test
flaky; replace the fixed sleep with an explicit readiness/wait: wait for the
jwks provider and the jwtValidator to be ready instead of sleeping (e.g., poll a
health/readiness endpoint or use a CountDownLatch/awaitStartup mechanism), then
only create HttpClient and proceed; also add the same explicit wait after
jwtValidator.start() before the first request to ensure jwtValidator.isStarted()
or the test HTTP port is accepting connections. Use identifiers jwksProvider,
Thread.sleep, jwtValidator, jwtValidator.start() and the test’s first request
location to find where to add the polling/await logic.

---

Nitpick comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsUtil.java`:
- Around line 41-42: The method name splitBySpace in CorsUtil is misleading
because StringList.parseToSet accepts commas and whitespace; rename splitBySpace
to a clearer name such as splitByWhitespaceOrComma (or
splitByDelimiters/parseOrigins) and update its Javadoc to reflect
comma+whitespace support, then update all call sites to the new name; to avoid
breakage, add a deprecated splitBySpace wrapper that delegates to the new method
(keeping the same signature) and mark it `@Deprecated` so callers can migrate.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between aec425f and 533094f.

📒 Files selected for processing (3)
  • core/src/main/java/com/predic8/membrane/core/interceptor/cors/CorsUtil.java
  • core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java
  • core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/JwksRefreshTest.java

@bridgerdier bridgerdier requested a review from predic8 March 4, 2026 13:45
predic8
predic8 previously requested changes Mar 4, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java (3)

117-122: ⚠️ Potential issue | 🟠 Major

Fix null handling in setJwksUris (null branch is currently overwritten).

When jwksUris == null, Line 119 sets emptyList(), but Line 121 still executes and reprocesses the null value. This can reintroduce null-path failures depending on StringList.parseToList behavior.

Proposed fix
 `@MCAttribute`
 public void setJwksUris(String jwksUris) {
     if (jwksUris == null) {
         this.jwksUris = emptyList();
+        return;
     }
     this.jwksUris = StringList.parseToList(jwksUris);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 117 - 122, The null branch in setJwksUris currently sets
this.jwksUris = emptyList() then continues to call
StringList.parseToList(jwksUris), potentially reintroducing a null; change the
method so that when jwksUris == null it assigns emptyList() and returns (or use
an else) and only call StringList.parseToList(jwksUris) when jwksUris is
non-null; update setJwksUris and keep references to the jwksUris field and
StringList.parseToList to locate the fix.

128-133: ⚠️ Potential issue | 🟠 Major

Do not silently overwrite duplicate kid values in keysByKid.

The current merge function ((k1, k2) -> k2) makes the “last key wins”, which hides configuration errors and can make key selection ambiguous. Reject duplicates (and blank/null kids) instead.

Proposed fix
 private Map<String, RsaJsonWebKey> buildKeyMap(List<Jwk> jwks) {
-    var keyMap = jwks.stream()
-            .map(this::extractRsaJsonWebKey)
-            .collect(toMap(RsaJsonWebKey::getKeyId, identity(),
-                    (k1, k2) -> k2  // second entry wins
-            ));
+    var keyMap = jwks.stream()
+            .map(this::extractRsaJsonWebKey)
+            .peek(k -> {
+                String kid = k.getKeyId();
+                if (kid == null || kid.isBlank()) {
+                    throw new IllegalArgumentException("Each JWK must provide a non-empty 'kid'.");
+                }
+            })
+            .collect(toMap(
+                    RsaJsonWebKey::getKeyId,
+                    identity(),
+                    (k1, k2) -> { throw new IllegalArgumentException("Duplicate JWK kid: " + k1.getKeyId()); }
+            ));
     if (keyMap.isEmpty())
         throw new RuntimeException("No JWKs given or none resolvable - please specify at least one resolvable JWK");
     return keyMap;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 128 - 133, The key-building logic in buildKeyMap currently silently
overwrites duplicate or blank key IDs by using a "last wins" merge; change it to
validate and reject invalid keys instead: in buildKeyMap (and where
extractRsaJsonWebKey produces a RsaJsonWebKey) ensure that the keyId
(RsaJsonWebKey::getKeyId) is non-null/non-blank and when collecting to a map,
replace the merge function with logic that throws an IllegalArgumentException
(or a custom exception) on duplicate keyIds so duplicates are rejected rather
than overwritten; include a clear error message mentioning the offending kid
value and the source JWK to aid debugging.

186-193: ⚠️ Potential issue | 🟡 Minor

Avoid double logging for one suppressed exception path.

In the default branch, log.error(e.toString()) and the later log.error(message) produce duplicate entries for the same failure.

Proposed fix
             String message = switch (e) {
                 case JsonProcessingException ignored -> "Could not parse JWK keys retrieved from %s.".formatted(uri);
                 case ResourceRetrievalException ignored -> "Could not retrieve JWK keys from %s.".formatted(uri);
                 default -> {
-                    if (suppressExceptions) log.error(e.toString());
                     yield e.getMessage();
                 }
             };
             if (suppressExceptions) {
-                log.error(message);
+                log.error(message, e);
                 return new JwkListByUri(uri, emptyList());
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`
around lines 186 - 193, The code in Jwks (default branch handling exceptions)
currently logs the same suppressed exception twice: once in the default -> block
via log.error(e.toString()) and again later when suppressExceptions is true via
log.error(message); remove the redundant log call in the default branch so that
logging happens only once at the later central point that constructs and returns
new JwkListByUri(uri, emptyList()); ensure you keep the unique variables
(suppressExceptions, message, e, JwkListByUri, uri) intact and only eliminate
the duplicate log.error(e.toString()) in the default branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java`:
- Around line 117-122: The null branch in setJwksUris currently sets
this.jwksUris = emptyList() then continues to call
StringList.parseToList(jwksUris), potentially reintroducing a null; change the
method so that when jwksUris == null it assigns emptyList() and returns (or use
an else) and only call StringList.parseToList(jwksUris) when jwksUris is
non-null; update setJwksUris and keep references to the jwksUris field and
StringList.parseToList to locate the fix.
- Around line 128-133: The key-building logic in buildKeyMap currently silently
overwrites duplicate or blank key IDs by using a "last wins" merge; change it to
validate and reject invalid keys instead: in buildKeyMap (and where
extractRsaJsonWebKey produces a RsaJsonWebKey) ensure that the keyId
(RsaJsonWebKey::getKeyId) is non-null/non-blank and when collecting to a map,
replace the merge function with logic that throws an IllegalArgumentException
(or a custom exception) on duplicate keyIds so duplicates are rejected rather
than overwritten; include a clear error message mentioning the offending kid
value and the source JWK to aid debugging.
- Around line 186-193: The code in Jwks (default branch handling exceptions)
currently logs the same suppressed exception twice: once in the default -> block
via log.error(e.toString()) and again later when suppressExceptions is true via
log.error(message); remove the redundant log call in the default branch so that
logging happens only once at the later central point that constructs and returns
new JwkListByUri(uri, emptyList()); ensure you keep the unique variables
(suppressExceptions, message, e, JwkListByUri, uri) intact and only eliminate
the duplicate log.error(e.toString()) in the default branch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f2c6db95-60dd-4ce2-bc2a-619a2d810ec4

📥 Commits

Reviewing files that changed from the base of the PR and between 533094f and 3babfd8.

📒 Files selected for processing (1)
  • core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java

@rrayst rrayst requested a review from predic8 March 6, 2026 16:08
@rrayst rrayst merged commit 74b3f10 into master Mar 6, 2026
5 checks passed
@rrayst rrayst deleted the jwks-auto-refresh branch March 6, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants