This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 9aa8be6db7be CAMEL-26532: camel-keycloak - rate limit remote JWKS 
refresh on unknown kid (#29058)
9aa8be6db7be is described below

commit 9aa8be6db7be8465eb80f6bf46abdc7f2c3535d0
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Nov 4 19:53:28 2026 +0100

    CAMEL-26532: camel-keycloak - rate limit remote JWKS refresh on unknown kid 
(#29058)
    
    A token with an unknown kid no longer triggers a remote JWKS fetch every 
time: the refresh is rate limited by a cooldown, concurrent requests share a 
single fetch, and a failed attempt counts as an attempt. Upgrade guide and 
keycloak-security docs updated.
    
    AI-generated by Antigravity on behalf of Andrea Cosentino.
    Co-authored-by: Antigravity <[email protected]>
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
---
 .../camel/catalog/docs/keycloak-security.adoc      |  71 +++++
 .../src/main/docs/keycloak-security.adoc           |  71 +++++
 .../security/KeycloakPublicKeyResolver.java        |  84 ++++--
 .../keycloak/security/KeycloakSecurityPolicy.java  |  49 +++-
 .../security/KeycloakJwksCacheStampedeTest.java    | 287 +++++++++++++++++++++
 .../security/KeycloakSecurityPolicyTest.java       |  13 +
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  14 +
 7 files changed, 569 insertions(+), 20 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-security.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-security.adoc
index eebdd1767291..977d988dcda6 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-security.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-security.adoc
@@ -144,6 +144,77 @@ from("direct:protected")
     .to("mock:result");
 ----
 
+=== JWKS Public Key Resolution and Cache Options
+
+When using local JWT signature verification (the default when token 
introspection is not enabled), `KeycloakSecurityPolicy` automatically resolves 
and caches public keys from Keycloak's JWKS endpoint 
(`+{serverUrl}/realms/{realm}/protocol/openid-connect/certs+`).
+
+Keys are cached in memory and refreshed periodically or on demand when key 
rotation occurs. To protect the Keycloak authorization server against cache 
stampedes and excessive load caused by requests with unknown or forged key IDs 
(`kid`), the resolver enforces a minimum cooldown interval between consecutive 
refresh attempts.
+
+[cols="1,1,2,1",options="header"]
+|===
+| Option | Type | Description | Default
+| `autoFetchPublicKey` | `boolean` | Whether to automatically fetch public 
keys from the Keycloak JWKS endpoint. Set to `false` when providing a static 
`publicKey`. | `true`
+| `jwksRefreshInterval` | `long` | Periodic cache refresh interval in seconds. 
After this interval elapses, cached keys are refreshed from the JWKS endpoint. 
| `300` (5 minutes)
+| `minJwksRefreshInterval` | `long` | Minimum cooldown interval in seconds 
between consecutive JWKS refresh attempts. When an incoming token references an 
unknown `kid` (such as during key rotation), the resolver refreshes keys at 
most once within this window. | `10` (10 seconds)
+| `publicKey` | `PublicKey` | A static RSA public key to use for signature 
verification instead of fetching keys from the JWKS endpoint. |
+|===
+
+==== Rate Limiting on Key Rotation
+
+When a token arrives with an unknown `kid`, it may be because Keycloak has 
rotated its signing keys. `KeycloakSecurityPolicy` triggers an on-demand 
refresh of the JWKS keys. However, if multiple requests arrive concurrently or 
if tokens carry unknown or invalid `kid` headers, refreshing on every miss 
would overload the Keycloak server.
+
+The `minJwksRefreshInterval` parameter guarantees that:
+
+* Consecutive refresh attempts are rate-limited to at most once per cooldown 
period (10 seconds by default).
+* Failed refresh attempts also reset the cooldown timer, preventing hammering 
of a temporarily unreachable Keycloak server.
+* Concurrent requests for unknown keys re-check cached keys under lock instead 
of triggering multiple simultaneous network calls.
+* A token whose `kid` is still unknown while the cooldown suppresses the 
refresh is rejected; it is not validated against the cached keys. It is 
accepted once a refresh has run and found the key.
+* The refresh runs on the request that needs it. There is no background 
refresh: the keys are also re-fetched on a request once they are older than 
`jwksRefreshInterval`.
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy(
+    "http://localhost:8080";, "my-realm", "my-client", "client-secret");
+
+// Configure periodic refresh every 10 minutes, with a 15-second cooldown for 
unknown keys
+policy.setJwksRefreshInterval(600);
+policy.setMinJwksRefreshInterval(15);
+
+from("direct:protected")
+    .policy(policy)
+    .to("mock:result");
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+    from:
+      uri: direct:protected
+      steps:
+        - policy:
+            ref: keycloakPolicy
+        - to:
+            uri: mock:result
+
+- beans:
+  - name: keycloakPolicy
+    type: org.apache.camel.component.keycloak.security.KeycloakSecurityPolicy
+    properties:
+      serverUrl: "http://localhost:8080";
+      realm: "my-realm"
+      clientId: "my-client"
+      clientSecret: "client-secret"
+      jwksRefreshInterval: 600
+      minJwksRefreshInterval: 15
+----
+====
+
 === Role-based Authorization
 
 [tabs]
diff --git a/components/camel-keycloak/src/main/docs/keycloak-security.adoc 
b/components/camel-keycloak/src/main/docs/keycloak-security.adoc
index eebdd1767291..977d988dcda6 100644
--- a/components/camel-keycloak/src/main/docs/keycloak-security.adoc
+++ b/components/camel-keycloak/src/main/docs/keycloak-security.adoc
@@ -144,6 +144,77 @@ from("direct:protected")
     .to("mock:result");
 ----
 
+=== JWKS Public Key Resolution and Cache Options
+
+When using local JWT signature verification (the default when token 
introspection is not enabled), `KeycloakSecurityPolicy` automatically resolves 
and caches public keys from Keycloak's JWKS endpoint 
(`+{serverUrl}/realms/{realm}/protocol/openid-connect/certs+`).
+
+Keys are cached in memory and refreshed periodically or on demand when key 
rotation occurs. To protect the Keycloak authorization server against cache 
stampedes and excessive load caused by requests with unknown or forged key IDs 
(`kid`), the resolver enforces a minimum cooldown interval between consecutive 
refresh attempts.
+
+[cols="1,1,2,1",options="header"]
+|===
+| Option | Type | Description | Default
+| `autoFetchPublicKey` | `boolean` | Whether to automatically fetch public 
keys from the Keycloak JWKS endpoint. Set to `false` when providing a static 
`publicKey`. | `true`
+| `jwksRefreshInterval` | `long` | Periodic cache refresh interval in seconds. 
After this interval elapses, cached keys are refreshed from the JWKS endpoint. 
| `300` (5 minutes)
+| `minJwksRefreshInterval` | `long` | Minimum cooldown interval in seconds 
between consecutive JWKS refresh attempts. When an incoming token references an 
unknown `kid` (such as during key rotation), the resolver refreshes keys at 
most once within this window. | `10` (10 seconds)
+| `publicKey` | `PublicKey` | A static RSA public key to use for signature 
verification instead of fetching keys from the JWKS endpoint. |
+|===
+
+==== Rate Limiting on Key Rotation
+
+When a token arrives with an unknown `kid`, it may be because Keycloak has 
rotated its signing keys. `KeycloakSecurityPolicy` triggers an on-demand 
refresh of the JWKS keys. However, if multiple requests arrive concurrently or 
if tokens carry unknown or invalid `kid` headers, refreshing on every miss 
would overload the Keycloak server.
+
+The `minJwksRefreshInterval` parameter guarantees that:
+
+* Consecutive refresh attempts are rate-limited to at most once per cooldown 
period (10 seconds by default).
+* Failed refresh attempts also reset the cooldown timer, preventing hammering 
of a temporarily unreachable Keycloak server.
+* Concurrent requests for unknown keys re-check cached keys under lock instead 
of triggering multiple simultaneous network calls.
+* A token whose `kid` is still unknown while the cooldown suppresses the 
refresh is rejected; it is not validated against the cached keys. It is 
accepted once a refresh has run and found the key.
+* The refresh runs on the request that needs it. There is no background 
refresh: the keys are also re-fetched on a request once they are older than 
`jwksRefreshInterval`.
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy(
+    "http://localhost:8080";, "my-realm", "my-client", "client-secret");
+
+// Configure periodic refresh every 10 minutes, with a 15-second cooldown for 
unknown keys
+policy.setJwksRefreshInterval(600);
+policy.setMinJwksRefreshInterval(15);
+
+from("direct:protected")
+    .policy(policy)
+    .to("mock:result");
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+    from:
+      uri: direct:protected
+      steps:
+        - policy:
+            ref: keycloakPolicy
+        - to:
+            uri: mock:result
+
+- beans:
+  - name: keycloakPolicy
+    type: org.apache.camel.component.keycloak.security.KeycloakSecurityPolicy
+    properties:
+      serverUrl: "http://localhost:8080";
+      realm: "my-realm"
+      clientId: "my-client"
+      clientSecret: "client-secret"
+      jwksRefreshInterval: 600
+      minJwksRefreshInterval: 15
+----
+====
+
 === Role-based Authorization
 
 [tabs]
diff --git 
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakPublicKeyResolver.java
 
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakPublicKeyResolver.java
index 745a92b0c69b..8d15ca3275cb 100644
--- 
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakPublicKeyResolver.java
+++ 
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakPublicKeyResolver.java
@@ -24,9 +24,10 @@ import java.security.PublicKey;
 import java.security.spec.InvalidKeySpecException;
 import java.security.spec.RSAPublicKeySpec;
 import java.util.Base64;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.ReentrantLock;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -44,16 +45,36 @@ public class KeycloakPublicKeyResolver {
     private static final Logger LOG = 
LoggerFactory.getLogger(KeycloakPublicKeyResolver.class);
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
+    public static final long DEFAULT_CACHE_REFRESH_INTERVAL_MS = 300_000; // 5 
minutes
+    public static final long DEFAULT_MIN_REFRESH_INTERVAL_MS = 10_000; // 10 
seconds
+
     private final String serverUrl;
     private final String realm;
-    private final Map<String, PublicKey> keyCache = new ConcurrentHashMap<>();
     private final ReentrantLock lock = new ReentrantLock();
-    private volatile long lastRefreshTime = 0;
-    private static final long CACHE_REFRESH_INTERVAL_MS = 300_000; // 5 minutes
+    private final long cacheRefreshIntervalNanos;
+    private final long minRefreshIntervalNanos;
+
+    private volatile Map<String, PublicKey> keyCache = Map.of();
+    private volatile long lastRefreshAttemptNanos;
 
     public KeycloakPublicKeyResolver(String serverUrl, String realm) {
+        this(serverUrl, realm, DEFAULT_CACHE_REFRESH_INTERVAL_MS, 
DEFAULT_MIN_REFRESH_INTERVAL_MS);
+    }
+
+    public KeycloakPublicKeyResolver(String serverUrl, String realm, long 
cacheRefreshIntervalMs,
+                                     long minRefreshIntervalMs) {
         this.serverUrl = serverUrl;
         this.realm = realm;
+        this.cacheRefreshIntervalNanos = 
TimeUnit.MILLISECONDS.toNanos(cacheRefreshIntervalMs);
+        this.minRefreshIntervalNanos = 
TimeUnit.MILLISECONDS.toNanos(minRefreshIntervalMs);
+    }
+
+    public long getCacheRefreshIntervalMs() {
+        return TimeUnit.NANOSECONDS.toMillis(cacheRefreshIntervalNanos);
+    }
+
+    public long getMinRefreshIntervalMs() {
+        return TimeUnit.NANOSECONDS.toMillis(minRefreshIntervalNanos);
     }
 
     /**
@@ -65,20 +86,37 @@ public class KeycloakPublicKeyResolver {
      * @throws IOException if fetching keys fails
      */
     public PublicKey getPublicKey(String kid) throws IOException {
-        // Check if we need to refresh the cache
-        long now = System.currentTimeMillis();
-        if (keyCache.isEmpty() || (now - lastRefreshTime) > 
CACHE_REFRESH_INTERVAL_MS) {
-            refreshKeys();
+        long elapsed = System.nanoTime() - lastRefreshAttemptNanos;
+        long periodicInterval = keyCache.isEmpty() ? minRefreshIntervalNanos : 
cacheRefreshIntervalNanos;
+        if (lastRefreshAttemptNanos == 0 || elapsed > periodicInterval) {
+            refreshKeysIfStale(null, periodicInterval);
         }
 
-        // If the kid is not known yet, force a refresh once to pick up 
rotated keys
+        // If the kid is not known yet, refresh only if minimum cooldown has 
elapsed to prevent cache stampede
         if (kid != null && !keyCache.containsKey(kid)) {
-            refreshKeys();
+            elapsed = System.nanoTime() - lastRefreshAttemptNanos;
+            if (lastRefreshAttemptNanos == 0 || elapsed > 
minRefreshIntervalNanos) {
+                refreshKeysIfStale(kid, minRefreshIntervalNanos);
+            }
         }
 
         return selectKey(kid);
     }
 
+    private void refreshKeysIfStale(String kid, long minIntervalNanos) throws 
IOException {
+        lock.lock();
+        try {
+            long elapsed = System.nanoTime() - lastRefreshAttemptNanos;
+            if ((kid != null && keyCache.containsKey(kid))
+                    || (lastRefreshAttemptNanos != 0 && elapsed <= 
minIntervalNanos)) {
+                return; // another thread refreshed (or attempted refresh) 
meanwhile
+            }
+            refreshKeys();
+        } finally {
+            lock.unlock();
+        }
+    }
+
     /**
      * Selects the cached key matching the given kid. Fails closed (throws) 
when a kid is provided but no key matches,
      * so a token is never verified against the wrong key during key rotation. 
Falls back to the first available key
@@ -89,8 +127,9 @@ public class KeycloakPublicKeyResolver {
      * @throws IOException if no matching key (or, for a {@code null} kid, no 
key at all) is available
      */
     PublicKey selectKey(String kid) throws IOException {
+        Map<String, PublicKey> currentCache = keyCache;
         if (kid != null) {
-            PublicKey key = keyCache.get(kid);
+            PublicKey key = currentCache.get(kid);
             if (key == null) {
                 throw new IOException("No public key found in Keycloak JWKS 
for kid: " + kid);
             }
@@ -98,8 +137,8 @@ public class KeycloakPublicKeyResolver {
         }
 
         // No kid in the token header - fall back to the first available key
-        if (!keyCache.isEmpty()) {
-            return keyCache.values().iterator().next();
+        if (!currentCache.isEmpty()) {
+            return currentCache.values().iterator().next();
         }
 
         throw new IOException("No public keys available from Keycloak JWKS 
endpoint");
@@ -111,6 +150,7 @@ public class KeycloakPublicKeyResolver {
     public void refreshKeys() throws IOException {
         lock.lock();
         try {
+            lastRefreshAttemptNanos = System.nanoTime();
             String jwksUrl = 
String.format("%s/realms/%s/protocol/openid-connect/certs", serverUrl, realm);
             LOG.debug("Fetching public keys from: {}", jwksUrl);
 
@@ -126,7 +166,6 @@ public class KeycloakPublicKeyResolver {
                 });
 
                 parseJwks(responseBody);
-                lastRefreshTime = System.currentTimeMillis();
                 LOG.debug("Successfully loaded {} public keys from JWKS 
endpoint", keyCache.size());
             }
         } finally {
@@ -143,7 +182,7 @@ public class KeycloakPublicKeyResolver {
             throw new IOException("No keys found in JWKS response");
         }
 
-        keyCache.clear();
+        Map<String, PublicKey> parsedKeys = new HashMap<>();
         for (Map<String, Object> keyData : keys) {
             String kty = (String) keyData.get("kty");
             String kid = (String) keyData.get("kid");
@@ -154,7 +193,7 @@ public class KeycloakPublicKeyResolver {
                 try {
                     PublicKey publicKey = parseRsaPublicKey(keyData);
                     if (kid != null) {
-                        keyCache.put(kid, publicKey);
+                        parsedKeys.put(kid, publicKey);
                     }
                 } catch (Exception e) {
                     LOG.warn("Failed to parse RSA key with kid '{}': {}", kid, 
e.getMessage());
@@ -162,9 +201,11 @@ public class KeycloakPublicKeyResolver {
             }
         }
 
-        if (keyCache.isEmpty()) {
+        if (parsedKeys.isEmpty()) {
             throw new IOException("No valid RSA signature keys found in JWKS 
response");
         }
+
+        keyCache = Map.copyOf(parsedKeys);
     }
 
     private PublicKey parseRsaPublicKey(Map<String, Object> keyData)
@@ -197,7 +238,12 @@ public class KeycloakPublicKeyResolver {
      * Clears the key cache.
      */
     public void clearCache() {
-        keyCache.clear();
-        lastRefreshTime = 0;
+        lock.lock();
+        try {
+            keyCache = Map.of();
+            lastRefreshAttemptNanos = 0;
+        } finally {
+            lock.unlock();
+        }
     }
 }
diff --git 
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicy.java
 
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicy.java
index 55d76ea17b9c..3bd318de6a7c 100644
--- 
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicy.java
+++ 
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicy.java
@@ -98,6 +98,15 @@ public class KeycloakSecurityPolicy implements 
AuthorizationPolicy {
      * {serverUrl}/realms/{realm}/protocol/openid-connect/certs. This ensures 
token signatures are properly verified.
      */
     private boolean autoFetchPublicKey = true;
+    /**
+     * Cache refresh interval for Keycloak JWKS public keys in seconds. 
Default is 300 seconds (5 minutes).
+     */
+    private long jwksRefreshInterval = 300;
+    /**
+     * Minimum cooldown interval in seconds between remote JWKS refreshes when 
an unknown key ID (kid) is encountered.
+     * This rate-limits outbound calls and prevents JWKS cache stampede. 
Default is 10 seconds.
+     */
+    private long minJwksRefreshInterval = 10;
     /**
      * Comma-separated list of expected audiences for token validation. When 
set, tokens whose "aud" claim does not
      * contain every one of the configured audiences are rejected (matching 
Keycloak's own audience verification
@@ -197,7 +206,8 @@ public class KeycloakSecurityPolicy implements 
AuthorizationPolicy {
             throw new IllegalArgumentException(
                     "Server URL and realm are required for public key 
resolution");
         }
-        publicKeyResolver = new KeycloakPublicKeyResolver(serverUrl, realm);
+        publicKeyResolver = new KeycloakPublicKeyResolver(
+                serverUrl, realm, jwksRefreshInterval * 1000L, 
minJwksRefreshInterval * 1000L);
         LOG.info("Initialized public key resolver for realm '{}' - issuer 
validation is {}",
                 realm, validateIssuer ? "enabled" : "disabled");
     }
@@ -538,4 +548,41 @@ public class KeycloakSecurityPolicy implements 
AuthorizationPolicy {
     public void setExpectedAuthorizedParty(String expectedAuthorizedParty) {
         this.expectedAuthorizedParty = expectedAuthorizedParty;
     }
+
+    /**
+     * Gets the JWKS cache refresh interval in seconds.
+     *
+     * @return the cache refresh interval in seconds
+     */
+    public long getJwksRefreshInterval() {
+        return jwksRefreshInterval;
+    }
+
+    /**
+     * Sets the JWKS cache refresh interval in seconds. Default is 300 seconds 
(5 minutes).
+     *
+     * @param jwksRefreshInterval cache refresh interval in seconds
+     */
+    public void setJwksRefreshInterval(long jwksRefreshInterval) {
+        this.jwksRefreshInterval = jwksRefreshInterval;
+    }
+
+    /**
+     * Gets the minimum cooldown interval in seconds between remote JWKS 
refreshes for unknown key IDs.
+     *
+     * @return the minimum cooldown interval in seconds
+     */
+    public long getMinJwksRefreshInterval() {
+        return minJwksRefreshInterval;
+    }
+
+    /**
+     * Sets the minimum cooldown interval in seconds between remote JWKS 
refreshes for unknown key IDs. Default is 10
+     * seconds.
+     *
+     * @param minJwksRefreshInterval minimum cooldown interval in seconds
+     */
+    public void setMinJwksRefreshInterval(long minJwksRefreshInterval) {
+        this.minJwksRefreshInterval = minJwksRefreshInterval;
+    }
 }
diff --git 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakJwksCacheStampedeTest.java
 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakJwksCacheStampedeTest.java
new file mode 100644
index 000000000000..a2092b05061f
--- /dev/null
+++ 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakJwksCacheStampedeTest.java
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.keycloak.security;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Base64;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.CamelAuthorizationException;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.keycloak.jose.jws.JWSBuilder;
+import org.keycloak.representations.AccessToken;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * Tests verifying that {@link KeycloakPublicKeyResolver} and {@link 
KeycloakSecurityProcessor} rate limit remote JWKS
+ * refreshes for unknown key IDs, preventing cache stampede.
+ */
+class KeycloakJwksCacheStampedeTest {
+
+    private HttpServer server;
+    private int serverPort;
+    private AtomicInteger jwksRequestCount;
+    private KeyPair keyPair;
+    private String jwksResponse;
+    private CamelContext context;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        context = new DefaultCamelContext();
+        context.start();
+
+        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+        keyGen.initialize(2048);
+        keyPair = keyGen.generateKeyPair();
+        RSAPublicKey rsaPub = (RSAPublicKey) keyPair.getPublic();
+
+        jwksResponse = "{\"keys\":[" + String.format(
+                
"{\"kty\":\"RSA\",\"use\":\"sig\",\"kid\":\"valid-key-1\",\"n\":\"%s\",\"e\":\"%s\"}",
+                b64url(rsaPub.getModulus()), 
b64url(rsaPub.getPublicExponent())) + "]}";
+
+        jwksRequestCount = new AtomicInteger();
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        
server.createContext("/realms/test-realm/protocol/openid-connect/certs", 
httpExchange -> {
+            jwksRequestCount.incrementAndGet();
+            byte[] bytes = jwksResponse.getBytes(StandardCharsets.UTF_8);
+            httpExchange.getResponseHeaders().set("Content-Type", 
"application/json");
+            httpExchange.sendResponseHeaders(200, bytes.length);
+            httpExchange.getResponseBody().write(bytes);
+            httpExchange.close();
+        });
+        server.start();
+        serverPort = server.getAddress().getPort();
+    }
+
+    @AfterEach
+    void tearDown() {
+        if (server != null) {
+            server.stop(0);
+        }
+        if (context != null) {
+            context.stop();
+        }
+    }
+
+    private static String b64url(BigInteger value) {
+        byte[] bytes = value.toByteArray();
+        if (bytes.length > 1 && bytes[0] == 0) {
+            byte[] unsigned = new byte[bytes.length - 1];
+            System.arraycopy(bytes, 1, unsigned, 0, unsigned.length);
+            bytes = unsigned;
+        }
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+    }
+
+    @Test
+    void testDirectResolverRateLimitsJwksRefreshOnUnknownKid() throws 
Exception {
+        String serverUrl = "http://127.0.0.1:"; + serverPort;
+        KeycloakPublicKeyResolver resolver = new 
KeycloakPublicKeyResolver(serverUrl, "test-realm");
+
+        // 1. Initial request with known kid loads the cache (1 network 
request)
+        assertThat(resolver.getPublicKey("valid-key-1")).isNotNull();
+        assertThat(jwksRequestCount.get()).isEqualTo(1);
+
+        // 2. Subsequent requests for known kid hit cache (no network requests)
+        assertThat(resolver.getPublicKey("valid-key-1")).isNotNull();
+        assertThat(jwksRequestCount.get()).isEqualTo(1);
+
+        // 3. Requests with unknown kids within the cooldown window do NOT 
trigger remote JWKS refreshes
+        int unknownKidAttempts = 5;
+        for (int i = 0; i < unknownKidAttempts; i++) {
+            final String unknownKid = "unknown-kid-" + i;
+            assertThatThrownBy(() -> resolver.getPublicKey(unknownKid))
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("No public key found in Keycloak 
JWKS for kid: " + unknownKid);
+        }
+
+        // Outbound request count must still be 1 (stampede prevented)
+        assertThat(jwksRequestCount.get())
+                .as("Unknown kids within cooldown window did not trigger 
remote JWKS fetches")
+                .isEqualTo(1);
+    }
+
+    @Test
+    void testConcurrentRequestsWithUnknownKidsTriggerSingleFetch() throws 
Exception {
+        String serverUrl = "http://127.0.0.1:"; + serverPort;
+        // No warm-up: the cache is empty and no attempt was made yet, so 
every thread passes the unlocked check in
+        // getPublicKey and the re-check under the lock is what has to keep 
the fetches down to one
+        KeycloakPublicKeyResolver resolver = new 
KeycloakPublicKeyResolver(serverUrl, "test-realm");
+
+        int threadCount = 10;
+        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+        CountDownLatch startLatch = new CountDownLatch(1);
+        CountDownLatch doneLatch = new CountDownLatch(threadCount);
+        AtomicInteger failureCount = new AtomicInteger();
+
+        for (int i = 0; i < threadCount; i++) {
+            final String unknownKid = "concurrent-unknown-kid-" + i;
+            executor.submit(() -> {
+                try {
+                    startLatch.await();
+                    resolver.getPublicKey(unknownKid);
+                } catch (IOException expected) {
+                    failureCount.incrementAndGet();
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                } finally {
+                    doneLatch.countDown();
+                }
+            });
+        }
+
+        // Release all threads simultaneously
+        startLatch.countDown();
+        assertThat(doneLatch.await(5, TimeUnit.SECONDS)).isTrue();
+        executor.shutdown();
+
+        assertThat(failureCount.get()).isEqualTo(threadCount);
+        // All concurrent threads for unknown kids must not trigger separate 
remote fetches
+        assertThat(jwksRequestCount.get()).isEqualTo(1);
+
+        // The one fetch loaded the keys: a known kid resolves from the cache 
without another fetch
+        assertThat(resolver.getPublicKey("valid-key-1")).isNotNull();
+        assertThat(jwksRequestCount.get()).isEqualTo(1);
+    }
+
+    @Test
+    void testSecurityProcessorRateLimitsJwksRefreshWithForgedTokens() throws 
Exception {
+        String serverUrl = "http://127.0.0.1:"; + serverPort;
+        KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy();
+        policy.setServerUrl(serverUrl);
+        policy.setRealm("test-realm");
+        policy.setClientId("test-client");
+        policy.setClientSecret("test-secret");
+        policy.setAutoFetchPublicKey(true);
+        policy.beforeWrap(null, null);
+
+        KeycloakSecurityProcessor processor = new KeycloakSecurityProcessor(e 
-> {
+        }, policy);
+
+        // 1. Legitimate request with a valid key: loads the cache (1 network 
request)
+        AccessToken validToken = new AccessToken();
+        validToken.issuer(serverUrl + "/realms/test-realm");
+        validToken.subject("legitimate-user");
+        validToken.exp(System.currentTimeMillis() / 1000 + 3600);
+        String validJwt = new JWSBuilder()
+                .type("JWT")
+                .kid("valid-key-1")
+                .jsonContent(validToken)
+                .rsa256(keyPair.getPrivate());
+
+        Exchange validExchange = new DefaultExchange(context);
+        validExchange.getIn().setHeader("Authorization", "Bearer " + validJwt);
+        processor.process(validExchange);
+        assertThat(jwksRequestCount.get()).isEqualTo(1);
+
+        // Subsequent valid requests hit the cache (0 additional network 
requests)
+        Exchange validExchange2 = new DefaultExchange(context);
+        validExchange2.getIn().setHeader("Authorization", "Bearer " + 
validJwt);
+        processor.process(validExchange2);
+        assertThat(jwksRequestCount.get()).isEqualTo(1);
+
+        // 2. Incoming forged tokens with unknown kid headers within the 
cooldown window
+        int forgedTokensCount = 5;
+        for (int i = 0; i < forgedTokensCount; i++) {
+            AccessToken token = new AccessToken();
+            token.issuer(serverUrl + "/realms/test-realm");
+            token.subject("attacker");
+            token.exp(System.currentTimeMillis() / 1000 + 3600);
+
+            String forgedJwt = new JWSBuilder()
+                    .type("JWT")
+                    .kid("forged-kid-" + i)
+                    .jsonContent(token)
+                    .rsa256(keyPair.getPrivate());
+
+            Exchange exchange = new DefaultExchange(context);
+            exchange.getIn().setHeader("Authorization", "Bearer " + forgedJwt);
+
+            assertThatThrownBy(() -> processor.process(exchange))
+                    .isInstanceOf(CamelAuthorizationException.class);
+        }
+
+        // Network request count must remain 1
+        assertThat(jwksRequestCount.get())
+                .as("Forged tokens with unknown kids did not force repeated 
remote JWKS fetches")
+                .isEqualTo(1);
+    }
+
+    @Test
+    void testJwksRefreshAllowedAfterCooldown() throws Exception {
+        String serverUrl = "http://127.0.0.1:"; + serverPort;
+        // Set a short cooldown of 100ms for testing key rotation
+        KeycloakPublicKeyResolver resolver = new 
KeycloakPublicKeyResolver(serverUrl, "test-realm", 300_000, 100);
+
+        // Initial request loads valid key
+        assertThat(resolver.getPublicKey("valid-key-1")).isNotNull();
+        assertThat(jwksRequestCount.get()).isEqualTo(1);
+
+        // Immediate unknown kid is rejected without refresh
+        assertThatThrownBy(() -> resolver.getPublicKey("unknown-key-1"))
+                .isInstanceOf(IOException.class);
+        assertThat(jwksRequestCount.get()).isEqualTo(1);
+
+        // Wait for cooldown to elapse using Awaitility (no Thread.sleep)
+        long startTime = System.currentTimeMillis();
+        await().atMost(2, TimeUnit.SECONDS)
+                .until(() -> System.currentTimeMillis() - startTime >= 150);
+
+        // After cooldown, an unknown kid triggers refresh to check for 
rotated keys
+        assertThatThrownBy(() -> resolver.getPublicKey("unknown-key-1"))
+                .isInstanceOf(IOException.class);
+        assertThat(jwksRequestCount.get()).isEqualTo(2);
+    }
+
+    @Test
+    void testParseJwksPreservesExistingKeysOnNonRsaKeysResponse() throws 
Exception {
+        String serverUrl = "http://127.0.0.1:"; + serverPort;
+        KeycloakPublicKeyResolver resolver = new 
KeycloakPublicKeyResolver(serverUrl, "test-realm");
+
+        // Load valid keys initially
+        assertThat(resolver.getPublicKey("valid-key-1")).isNotNull();
+
+        // Response contains only non-RSA (EC) keys or non-sig keys
+        String nonRsaJwks = 
"{\"keys\":[{\"kty\":\"EC\",\"kid\":\"ec-1\",\"use\":\"sig\"}]}";
+        assertThatThrownBy(() -> resolver.parseJwks(nonRsaJwks))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("No valid RSA signature keys found in 
JWKS response");
+
+        // Existing RSA key is still safely available in the cache (reference 
swap was atomic)
+        assertThat(resolver.selectKey("valid-key-1")).isNotNull();
+    }
+}
diff --git 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicyTest.java
 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicyTest.java
index 84b92d8da7e3..de5ecd766996 100644
--- 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicyTest.java
+++ 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicyTest.java
@@ -120,6 +120,19 @@ public class KeycloakSecurityPolicyTest extends 
CamelTestSupport {
         assertTrue(policy.isUseResourceOwnerPasswordCredentials());
     }
 
+    @Test
+    void testJwksRefreshIntervalConfiguration() {
+        KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy();
+        assertEquals(300, policy.getJwksRefreshInterval());
+        assertEquals(10, policy.getMinJwksRefreshInterval());
+
+        policy.setJwksRefreshInterval(600);
+        policy.setMinJwksRefreshInterval(30);
+
+        assertEquals(600, policy.getJwksRefreshInterval());
+        assertEquals(30, policy.getMinJwksRefreshInterval());
+    }
+
     @Override
     protected RoutesBuilder createRouteBuilder() throws Exception {
         final KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy();
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 8f1855871da2..b05c3cc968c5 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1061,6 +1061,20 @@ JSON text is mapped to it. A body that does not convert 
fails with an `InvalidPa
 did nothing and the body was kept. The elements of the `List` of 
`bulkCreateUsers` and `bulkUpdateUsers` are
 converted the same way; an element that does not convert fails the exchange 
before any call to Keycloak.
 
+=== camel-keycloak - JWKS public key resolution rate limiting
+
+`KeycloakSecurityPolicy` now rate limits the refresh of the public keys from 
the JWKS endpoint of Keycloak
+(`/protocol/openid-connect/certs`), so that a burst of tokens with unknown 
`kid` headers, or a key rotation, no longer
+contacts the endpoint once per request.
+
+The new option `minJwksRefreshInterval` is the minimum interval in seconds 
between two refresh attempts, `10` by
+default. A token with an unknown `kid` triggers a refresh only when no attempt 
was made within that interval. Otherwise
+the token is rejected, because its key is not in the cache, and the endpoint 
is contacted again only once the interval
+has elapsed. A failed attempt counts as an attempt.
+
+The cached keys are refreshed on a request once they are older than 
`jwksRefreshInterval`, in seconds and `300` by
+default. There is no background refresh.
+
 === camel-splunk-hec - bytes, stream and file bodies are sent as text
 
 The producer now sends a byte array, an `InputStream`, a `Reader` or a file 
body (`File`, `Path` or the file of the

Reply via email to