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