This is an automated email from the ASF dual-hosted git repository.
acosentino 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 ce5adc2bff23 CAMEL-22582 - Camel-Keycloak: Keycloak Introspector
should provide di… (#19662)
ce5adc2bff23 is described below
commit ce5adc2bff2383a19630f942532c04f86c6e70da
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Oct 22 11:08:46 2025 +0200
CAMEL-22582 - Camel-Keycloak: Keycloak Introspector should provide di…
(#19662)
* CAMEL-22582 - Camel-Keycloak: Keycloak Introspector should provide
different pluggable cache support
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-22582 - Camel-Keycloak: Keycloak Introspector should provide
different pluggable cache support
Signed-off-by: Andrea Cosentino <[email protected]>
---------
Signed-off-by: Andrea Cosentino <[email protected]>
---
components/camel-keycloak/pom.xml | 4 +
.../src/main/docs/keycloak-component.adoc | 217 +++++++++++++++++++++
.../security/KeycloakTokenIntrospector.java | 131 ++++++++-----
.../security/cache/CaffeineTokenCache.java | 129 ++++++++++++
.../security/cache/ConcurrentMapTokenCache.java | 133 +++++++++++++
.../keycloak/security/cache/TokenCache.java | 117 +++++++++++
.../keycloak/security/cache/TokenCacheFactory.java | 126 ++++++++++++
.../keycloak/security/cache/TokenCacheType.java | 41 ++++
.../security/KeycloakTokenIntrospectionIT.java | 208 ++++++++++++++++++++
.../security/cache/CaffeineTokenCacheTest.java | 202 +++++++++++++++++++
.../cache/ConcurrentMapTokenCacheTest.java | 175 +++++++++++++++++
.../security/cache/TokenCacheFactoryTest.java | 98 ++++++++++
12 files changed, 1530 insertions(+), 51 deletions(-)
diff --git a/components/camel-keycloak/pom.xml
b/components/camel-keycloak/pom.xml
index a8f52cc64316..611ff46f7519 100644
--- a/components/camel-keycloak/pom.xml
+++ b/components/camel-keycloak/pom.xml
@@ -64,6 +64,10 @@
<artifactId>httpclient5</artifactId>
<version>${httpclient-version}</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-caffeine</artifactId>
+ </dependency>
<!-- test dependencies -->
<dependency>
diff --git a/components/camel-keycloak/src/main/docs/keycloak-component.adoc
b/components/camel-keycloak/src/main/docs/keycloak-component.adoc
index 3a82e07540fc..6de8ae48707a 100644
--- a/components/camel-keycloak/src/main/docs/keycloak-component.adoc
+++ b/components/camel-keycloak/src/main/docs/keycloak-component.adoc
@@ -3021,6 +3021,223 @@ policy.setIntrospectionCacheTtl(30); // 30 seconds
NOTE: When a token is introspected and cached, subsequent requests with the
same token will use the cached result until the TTL expires. Balance security
requirements with performance needs.
+==== Pluggable Cache Implementation
+
+The token introspection feature supports pluggable cache implementations for
flexible caching strategies. This allows you to choose the best caching
solution for your performance and scalability requirements.
+
+===== Available Cache Types
+
+The component provides three cache implementations:
+
+1. **ConcurrentMap Cache** (default) - Simple in-memory cache using
`ConcurrentHashMap`
+ - Time-based expiration (TTL)
+ - No external dependencies
+ - Suitable for basic use cases and backward compatibility
+
+2. **Caffeine Cache** (recommended for production) - High-performance cache
with advanced features
+ - Time-based expiration
+ - Size-based eviction
+ - Detailed statistics (hit rate, miss rate, evictions)
+ - Optimized for high throughput
+
+3. **No Cache** - Disables caching completely
+ - Every token is introspected on each request
+ - Useful for testing or strict security requirements
+
+===== Using Caffeine Cache
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+import org.apache.camel.component.keycloak.security.cache.TokenCacheType;
+
+// Create introspector with Caffeine cache for high performance
+KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+ serverUrl,
+ realm,
+ clientId,
+ clientSecret,
+ TokenCacheType.CAFFEINE,
+ 300, // TTL in seconds (5 minutes)
+ 10000, // max cache size (0 for unlimited)
+ true // record statistics
+);
+
+// Check cache statistics
+TokenCache.CacheStats stats = introspector.getCacheStats();
+if (stats != null) {
+ System.out.println("Cache hit rate: " + stats.getHitRate());
+ System.out.println("Total hits: " + stats.getHitCount());
+ System.out.println("Total misses: " + stats.getMissCount());
+ System.out.println("Evictions: " + stats.getEvictionCount());
+}
+
+// Get current cache size
+long size = introspector.getCacheSize();
+System.out.println("Cached tokens: " + size);
+----
+====
+
+===== Custom Cache Configuration
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+import org.apache.camel.component.keycloak.security.cache.TokenCache;
+import org.apache.camel.component.keycloak.security.cache.CaffeineTokenCache;
+
+// Create a custom cache with specific settings
+TokenCache customCache = new CaffeineTokenCache(
+ 600, // 10 minutes TTL
+ 50000, // max 50k entries
+ true // enable stats
+);
+
+// Use the custom cache instance
+KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+ serverUrl,
+ realm,
+ clientId,
+ clientSecret,
+ customCache
+);
+----
+====
+
+===== Cache Configuration in Security Policy
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+// High-performance policy with Caffeine cache
+KeycloakSecurityPolicy highPerfPolicy = new KeycloakSecurityPolicy();
+highPerfPolicy.setServerUrl("http://localhost:8080");
+highPerfPolicy.setRealm("production-realm");
+highPerfPolicy.setClientId("api-service");
+highPerfPolicy.setClientSecret("api-secret");
+highPerfPolicy.setRequiredRoles("user");
+
+// Enable introspection with Caffeine cache
+highPerfPolicy.setUseTokenIntrospection(true);
+highPerfPolicy.setIntrospectionCacheType(TokenCacheType.CAFFEINE);
+highPerfPolicy.setIntrospectionCacheTtl(120); // 2 minutes
+highPerfPolicy.setIntrospectionCacheMaxSize(5000); // max 5k tokens
+highPerfPolicy.setIntrospectionCacheStats(true); // enable statistics
+
+from("rest:get:/api/data")
+ .policy(highPerfPolicy)
+ .to("bean:dataService?method=getData");
+----
+
+YAML::
++
+[source,yaml]
+----
+# Bean definition with Caffeine cache
+beans:
+ - name: highPerfPolicy
+ type: org.apache.camel.component.keycloak.security.KeycloakSecurityPolicy
+ properties:
+ serverUrl: "http://localhost:8080"
+ realm: "production-realm"
+ clientId: "api-service"
+ clientSecret: "api-secret"
+ requiredRoles: "user"
+ useTokenIntrospection: true
+ introspectionCacheType: "CAFFEINE"
+ introspectionCacheTtl: 120
+ introspectionCacheMaxSize: 5000
+ introspectionCacheStats: true
+----
+====
+
+===== Cache Performance Monitoring
+
+For production systems, monitor cache performance to optimize TTL and size
settings:
+
+[source,java]
+----
+// Periodically check cache performance
+TokenCache.CacheStats stats = introspector.getCacheStats();
+
+if (stats != null) {
+ logger.info("Token cache performance: " + stats.toString());
+ // Output: CacheStats{hits=1000, misses=100, hitRate=90.91%, evictions=50}
+
+ // Alert if hit rate is too low
+ if (stats.getHitRate() < 0.7) {
+ logger.warn("Low cache hit rate: " + stats.getHitRate());
+ logger.warn("Consider increasing cache TTL or size");
+ }
+
+ // Monitor evictions
+ if (stats.getEvictionCount() > threshold) {
+ logger.info("High eviction count: " + stats.getEvictionCount());
+ logger.info("Consider increasing cache max size");
+ }
+}
+----
+
+===== Cache Selection Guidelines
+
+[width="100%",cols="30%,35%,35%",options="header"]
+|===
+| Use Case | Recommended Cache | Configuration
+
+| **Development/Testing** | ConcurrentMap | TTL: 60s, Default settings
+| **Production (Low-Medium Traffic)** | ConcurrentMap | TTL: 120s, Monitor size
+| **Production (High Traffic)** | Caffeine | TTL: 300s, maxSize: 10000+, stats
enabled
+| **Very High Traffic** | Caffeine | TTL: 300s, maxSize: 50000+, stats enabled
+| **Strict Security** | NONE or low TTL | TTL: 30s or disable caching
+| **Testing/Debugging** | NONE | Disable caching for fresh validation
+|===
+
+===== Best Practices
+
+1. **TTL Configuration**: Set TTL slightly shorter than your token expiration
time to balance security and performance
+
+2. **Cache Size**: For Caffeine, set `maxSize` based on expected concurrent
users:
+ - Small deployments (< 1000 users): 1000-5000 entries
+ - Medium deployments (1000-10000 users): 10000-25000 entries
+ - Large deployments (> 10000 users): 50000+ entries
+
+3. **Statistics**: Enable statistics in production environments for monitoring
and tuning
+
+4. **Cleanup**: Always call `introspector.close()` when shutting down to
release resources properly
+
+5. **Different Policies for Different Endpoints**: Use Caffeine cache for
high-traffic endpoints and stricter settings for security-critical endpoints
+
+[source,java]
+----
+// High-traffic endpoint - use Caffeine with longer TTL
+KeycloakSecurityPolicy highTrafficPolicy = new KeycloakSecurityPolicy();
+highTrafficPolicy.setUseTokenIntrospection(true);
+highTrafficPolicy.setIntrospectionCacheType(TokenCacheType.CAFFEINE);
+highTrafficPolicy.setIntrospectionCacheTtl(300); // 5 minutes
+
+// Security-critical endpoint - shorter TTL or no cache
+KeycloakSecurityPolicy criticalPolicy = new KeycloakSecurityPolicy();
+criticalPolicy.setUseTokenIntrospection(true);
+criticalPolicy.setIntrospectionCacheType(TokenCacheType.NONE); // No caching
+
+from("rest:get:/api/users")
+ .policy(highTrafficPolicy) // Cached validation
+ .to("bean:userService");
+
+from("rest:post:/payments")
+ .policy(criticalPolicy) // Real-time validation
+ .to("bean:paymentService");
+----
+
==== Token Revocation Workflow
When using token introspection, you can implement a complete token revocation
workflow:
diff --git
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakTokenIntrospector.java
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakTokenIntrospector.java
index da1696fd1346..25f473c1fac2 100644
---
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakTokenIntrospector.java
+++
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakTokenIntrospector.java
@@ -19,9 +19,11 @@ package org.apache.camel.component.keycloak.security;
import java.io.IOException;
import java.util.Base64;
import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.camel.component.keycloak.security.cache.TokenCache;
+import org.apache.camel.component.keycloak.security.cache.TokenCacheFactory;
+import org.apache.camel.component.keycloak.security.cache.TokenCacheType;
import org.apache.camel.util.ObjectHelper;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
@@ -37,7 +39,7 @@ import org.slf4j.LoggerFactory;
/**
* Token introspection client for Keycloak OAuth 2.0 Token Introspection (RFC
7662). This class handles communication
* with Keycloak's introspection endpoint to validate tokens in real-time,
including detecting revoked tokens before
- * their expiration.
+ * their expiration. Supports pluggable cache implementations for flexible
caching strategies.
*/
public class KeycloakTokenIntrospector {
private static final Logger LOG =
LoggerFactory.getLogger(KeycloakTokenIntrospector.class);
@@ -48,21 +50,62 @@ public class KeycloakTokenIntrospector {
private final String clientId;
private final String clientSecret;
private final CloseableHttpClient httpClient;
- private final Map<String, CachedIntrospectionResult> cache;
- private final boolean cacheEnabled;
- private final long cacheTtlMillis;
+ private final TokenCache cache;
+ /**
+ * Creates a new token introspector with the specified cache configuration.
+ *
+ * @param serverUrl the Keycloak server URL
+ * @param realm the realm name
+ * @param clientId the client ID
+ * @param clientSecret the client secret
+ * @param cacheEnabled whether to enable caching
+ * @param cacheTtlSeconds cache time-to-live in seconds
+ */
public KeycloakTokenIntrospector(
String serverUrl, String realm, String
clientId, String clientSecret,
boolean cacheEnabled, long
cacheTtlSeconds) {
+ this(serverUrl, realm, clientId, clientSecret,
+ cacheEnabled ? TokenCacheFactory.createCache(cacheTtlSeconds) :
null);
+ }
+
+ /**
+ * Creates a new token introspector with advanced cache configuration.
+ *
+ * @param serverUrl the Keycloak server URL
+ * @param realm the realm name
+ * @param clientId the client ID
+ * @param clientSecret the client secret
+ * @param cacheType the type of cache to use
+ * @param cacheTtlSeconds cache time-to-live in seconds
+ * @param maxCacheSize maximum cache size (only for CAFFEINE type, 0
for unlimited)
+ * @param recordStats whether to record cache statistics (only for
CAFFEINE type)
+ */
+ public KeycloakTokenIntrospector(
+ String serverUrl, String realm, String
clientId, String clientSecret,
+ TokenCacheType cacheType, long
cacheTtlSeconds, long maxCacheSize, boolean recordStats) {
+ this(serverUrl, realm, clientId, clientSecret,
+ TokenCacheFactory.createCache(cacheType, cacheTtlSeconds,
maxCacheSize, recordStats));
+ }
+
+ /**
+ * Creates a new token introspector with a custom cache implementation.
+ *
+ * @param serverUrl the Keycloak server URL
+ * @param realm the realm name
+ * @param clientId the client ID
+ * @param clientSecret the client secret
+ * @param cache the cache implementation to use (null to disable
caching)
+ */
+ public KeycloakTokenIntrospector(
+ String serverUrl, String realm, String
clientId, String clientSecret,
+ TokenCache cache) {
this.serverUrl = serverUrl;
this.realm = realm;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.httpClient = HttpClients.createDefault();
- this.cacheEnabled = cacheEnabled;
- this.cacheTtlMillis = cacheTtlSeconds * 1000;
- this.cache = cacheEnabled ? new ConcurrentHashMap<>() : null;
+ this.cache = cache;
}
/**
@@ -78,13 +121,11 @@ public class KeycloakTokenIntrospector {
}
// Check cache first
- if (cacheEnabled) {
- CachedIntrospectionResult cached = cache.get(token);
- if (cached != null && !cached.isExpired()) {
+ if (cache != null) {
+ IntrospectionResult cached = cache.get(token);
+ if (cached != null) {
LOG.debug("Returning cached introspection result for token");
- return cached.result;
- } else if (cached != null) {
- cache.remove(token);
+ return cached;
}
}
@@ -113,9 +154,8 @@ public class KeycloakTokenIntrospector {
});
// Cache the result
- if (cacheEnabled && result != null) {
- cache.put(token, new CachedIntrospectionResult(result,
cacheTtlMillis));
- cleanupExpiredCacheEntries();
+ if (cache != null && result != null) {
+ cache.put(token, result);
}
return result;
@@ -148,33 +188,36 @@ public class KeycloakTokenIntrospector {
}
}
- private void cleanupExpiredCacheEntries() {
- if (!cacheEnabled || cache.isEmpty()) {
- return;
- }
-
- // Cleanup expired entries (max 100 at a time to avoid performance
issues)
- cache.entrySet().removeIf(entry -> {
- if (entry.getValue().isExpired()) {
- LOG.trace("Removing expired cache entry");
- return true;
- }
- return false;
- });
- }
-
/**
* Clears the introspection cache.
*/
public void clearCache() {
- if (cacheEnabled) {
+ if (cache != null) {
cache.clear();
LOG.debug("Introspection cache cleared");
}
}
/**
- * Closes the HTTP client.
+ * Returns cache statistics if available.
+ *
+ * @return cache statistics, or null if not available
+ */
+ public TokenCache.CacheStats getCacheStats() {
+ return cache != null ? cache.getStats() : null;
+ }
+
+ /**
+ * Returns the current cache size.
+ *
+ * @return the number of entries in the cache, or 0 if caching is disabled
+ */
+ public long getCacheSize() {
+ return cache != null ? cache.size() : 0;
+ }
+
+ /**
+ * Closes the HTTP client and cache.
*/
public void close() {
try {
@@ -184,6 +227,9 @@ public class KeycloakTokenIntrospector {
} catch (IOException e) {
LOG.warn("Error closing HTTP client", e);
}
+ if (cache != null) {
+ cache.close();
+ }
}
/**
@@ -296,21 +342,4 @@ public class KeycloakTokenIntrospector {
return claims.get(claimName);
}
}
-
- /**
- * Internal class to hold cached introspection results with expiration.
- */
- private static class CachedIntrospectionResult {
- private final IntrospectionResult result;
- private final long expirationTime;
-
- public CachedIntrospectionResult(IntrospectionResult result, long
ttlMillis) {
- this.result = result;
- this.expirationTime = System.currentTimeMillis() + ttlMillis;
- }
-
- public boolean isExpired() {
- return System.currentTimeMillis() >= expirationTime;
- }
- }
}
diff --git
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/CaffeineTokenCache.java
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/CaffeineTokenCache.java
new file mode 100644
index 000000000000..b735fdc92ca8
--- /dev/null
+++
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/CaffeineTokenCache.java
@@ -0,0 +1,129 @@
+/*
+ * 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.cache;
+
+import java.util.concurrent.TimeUnit;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.apache.camel.component.keycloak.security.KeycloakTokenIntrospector;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * High-performance token cache implementation using Caffeine. This cache
provides advanced features including automatic
+ * expiration, size-based eviction, and detailed statistics. This
implementation is recommended for production use with
+ * high throughput requirements.
+ */
+public class CaffeineTokenCache implements TokenCache {
+ private static final Logger LOG =
LoggerFactory.getLogger(CaffeineTokenCache.class);
+
+ private final Cache<String, KeycloakTokenIntrospector.IntrospectionResult>
cache;
+
+ /**
+ * Creates a new Caffeine-based token cache with the specified
configuration.
+ *
+ * @param ttlSeconds time-to-live for cache entries in seconds
+ * @param maxSize maximum number of entries to cache (0 or negative
for unlimited)
+ * @param recordStats whether to record cache statistics
+ */
+ public CaffeineTokenCache(long ttlSeconds, long maxSize, boolean
recordStats) {
+ Caffeine<Object, Object> builder = Caffeine.newBuilder()
+ .expireAfterWrite(ttlSeconds, TimeUnit.SECONDS);
+
+ if (maxSize > 0) {
+ builder.maximumSize(maxSize);
+ }
+
+ if (recordStats) {
+ builder.recordStats();
+ }
+
+ this.cache = builder.build();
+ LOG.debug("Initialized Caffeine token cache with TTL={}s, maxSize={},
stats={}",
+ ttlSeconds, maxSize > 0 ? maxSize : "unlimited", recordStats);
+ }
+
+ /**
+ * Creates a new Caffeine-based token cache with default settings (no size
limit, stats enabled).
+ *
+ * @param ttlSeconds time-to-live for cache entries in seconds
+ */
+ public CaffeineTokenCache(long ttlSeconds) {
+ this(ttlSeconds, 0, true);
+ }
+
+ @Override
+ public KeycloakTokenIntrospector.IntrospectionResult get(String token) {
+ KeycloakTokenIntrospector.IntrospectionResult result =
cache.getIfPresent(token);
+ if (result != null) {
+ LOG.trace("Cache hit for token");
+ } else {
+ LOG.trace("Cache miss for token");
+ }
+ return result;
+ }
+
+ @Override
+ public void put(String token,
KeycloakTokenIntrospector.IntrospectionResult result) {
+ cache.put(token, result);
+ LOG.trace("Token introspection result cached");
+ }
+
+ @Override
+ public void remove(String token) {
+ cache.invalidate(token);
+ LOG.trace("Token removed from cache");
+ }
+
+ @Override
+ public void clear() {
+ cache.invalidateAll();
+ LOG.debug("Cache cleared");
+ }
+
+ @Override
+ public long size() {
+ return cache.estimatedSize();
+ }
+
+ @Override
+ public void close() {
+ cache.invalidateAll();
+ cache.cleanUp();
+ LOG.debug("Cache closed and cleaned up");
+ }
+
+ @Override
+ public CacheStats getStats() {
+ com.github.benmanes.caffeine.cache.stats.CacheStats caffeineStats =
cache.stats();
+
+ return new CacheStats(
+ caffeineStats.hitCount(),
+ caffeineStats.missCount(),
+ caffeineStats.evictionCount());
+ }
+
+ /**
+ * Returns the underlying Caffeine cache for advanced usage.
+ *
+ * @return the Caffeine cache instance
+ */
+ public Cache<String, KeycloakTokenIntrospector.IntrospectionResult>
getCaffeineCache() {
+ return cache;
+ }
+}
diff --git
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/ConcurrentMapTokenCache.java
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/ConcurrentMapTokenCache.java
new file mode 100644
index 000000000000..aa2ebb06ec4b
--- /dev/null
+++
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/ConcurrentMapTokenCache.java
@@ -0,0 +1,133 @@
+/*
+ * 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.cache;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.camel.component.keycloak.security.KeycloakTokenIntrospector;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Simple in-memory token cache implementation using ConcurrentHashMap with
time-based expiration. This is the default
+ * cache implementation that provides basic caching without external
dependencies.
+ */
+public class ConcurrentMapTokenCache implements TokenCache {
+ private static final Logger LOG =
LoggerFactory.getLogger(ConcurrentMapTokenCache.class);
+
+ private final Map<String, CachedEntry> cache;
+ private final long ttlMillis;
+ private final AtomicLong hitCount = new AtomicLong(0);
+ private final AtomicLong missCount = new AtomicLong(0);
+ private final AtomicLong evictionCount = new AtomicLong(0);
+
+ public ConcurrentMapTokenCache(long ttlSeconds) {
+ this.cache = new ConcurrentHashMap<>();
+ this.ttlMillis = ttlSeconds * 1000;
+ }
+
+ @Override
+ public KeycloakTokenIntrospector.IntrospectionResult get(String token) {
+ CachedEntry entry = cache.get(token);
+ if (entry != null) {
+ if (!entry.isExpired()) {
+ hitCount.incrementAndGet();
+ LOG.trace("Cache hit for token");
+ return entry.result;
+ } else {
+ // Remove expired entry
+ cache.remove(token);
+ evictionCount.incrementAndGet();
+ LOG.trace("Cache entry expired and removed");
+ }
+ }
+ missCount.incrementAndGet();
+ LOG.trace("Cache miss for token");
+ return null;
+ }
+
+ @Override
+ public void put(String token,
KeycloakTokenIntrospector.IntrospectionResult result) {
+ cache.put(token, new CachedEntry(result, ttlMillis));
+ LOG.trace("Token introspection result cached");
+ cleanupExpiredEntries();
+ }
+
+ @Override
+ public void remove(String token) {
+ cache.remove(token);
+ LOG.trace("Token removed from cache");
+ }
+
+ @Override
+ public void clear() {
+ cache.clear();
+ hitCount.set(0);
+ missCount.set(0);
+ evictionCount.set(0);
+ LOG.debug("Cache cleared");
+ }
+
+ @Override
+ public long size() {
+ return cache.size();
+ }
+
+ @Override
+ public CacheStats getStats() {
+ return new CacheStats(hitCount.get(), missCount.get(),
evictionCount.get());
+ }
+
+ /**
+ * Cleanup expired cache entries to prevent memory leaks. This method is
called periodically and removes entries in
+ * batches to avoid performance issues.
+ */
+ private void cleanupExpiredEntries() {
+ if (cache.isEmpty()) {
+ return;
+ }
+
+ // Remove expired entries
+ cache.entrySet().removeIf(entry -> {
+ if (entry.getValue().isExpired()) {
+ evictionCount.incrementAndGet();
+ LOG.trace("Removing expired cache entry during cleanup");
+ return true;
+ }
+ return false;
+ });
+ }
+
+ /**
+ * Internal class to hold cached introspection results with expiration
time.
+ */
+ private static class CachedEntry {
+ private final KeycloakTokenIntrospector.IntrospectionResult result;
+ private final long expirationTime;
+
+ CachedEntry(KeycloakTokenIntrospector.IntrospectionResult result, long
ttlMillis) {
+ this.result = result;
+ this.expirationTime = System.currentTimeMillis() + ttlMillis;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() >= expirationTime;
+ }
+ }
+}
diff --git
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/TokenCache.java
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/TokenCache.java
new file mode 100644
index 000000000000..363d42b4955c
--- /dev/null
+++
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/TokenCache.java
@@ -0,0 +1,117 @@
+/*
+ * 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.cache;
+
+import org.apache.camel.component.keycloak.security.KeycloakTokenIntrospector;
+
+/**
+ * Interface for pluggable token cache implementations. This abstraction
allows for different caching strategies and
+ * backends to be used for storing token introspection results.
+ */
+public interface TokenCache {
+
+ /**
+ * Retrieves a cached introspection result for the given token.
+ *
+ * @param token the access token to look up
+ * @return the cached introspection result, or null if not found or
expired
+ */
+ KeycloakTokenIntrospector.IntrospectionResult get(String token);
+
+ /**
+ * Stores an introspection result in the cache.
+ *
+ * @param token the access token used as the cache key
+ * @param result the introspection result to cache
+ */
+ void put(String token, KeycloakTokenIntrospector.IntrospectionResult
result);
+
+ /**
+ * Removes a token from the cache.
+ *
+ * @param token the access token to remove
+ */
+ void remove(String token);
+
+ /**
+ * Clears all entries from the cache.
+ */
+ void clear();
+
+ /**
+ * Returns the approximate number of entries in the cache.
+ *
+ * @return the cache size, or -1 if unknown
+ */
+ long size();
+
+ /**
+ * Closes the cache and releases any resources.
+ */
+ default void close() {
+ // Default implementation does nothing
+ }
+
+ /**
+ * Returns statistics about the cache performance.
+ *
+ * @return cache statistics, or null if not supported
+ */
+ default CacheStats getStats() {
+ return null;
+ }
+
+ /**
+ * Cache statistics holder.
+ */
+ class CacheStats {
+ private final long hitCount;
+ private final long missCount;
+ private final long evictionCount;
+ private final double hitRate;
+
+ public CacheStats(long hitCount, long missCount, long evictionCount) {
+ this.hitCount = hitCount;
+ this.missCount = missCount;
+ this.evictionCount = evictionCount;
+ long totalRequests = hitCount + missCount;
+ this.hitRate = totalRequests == 0 ? 0.0 : (double) hitCount /
totalRequests;
+ }
+
+ public long getHitCount() {
+ return hitCount;
+ }
+
+ public long getMissCount() {
+ return missCount;
+ }
+
+ public long getEvictionCount() {
+ return evictionCount;
+ }
+
+ public double getHitRate() {
+ return hitRate;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("CacheStats{hits=%d, misses=%d,
hitRate=%.2f%%, evictions=%d}",
+ hitCount, missCount, hitRate * 100, evictionCount);
+ }
+ }
+}
diff --git
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/TokenCacheFactory.java
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/TokenCacheFactory.java
new file mode 100644
index 000000000000..4bcc2d48fbae
--- /dev/null
+++
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/TokenCacheFactory.java
@@ -0,0 +1,126 @@
+/*
+ * 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.cache;
+
+import org.apache.camel.component.keycloak.security.KeycloakTokenIntrospector;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Factory for creating token cache instances based on the specified cache
type and configuration.
+ */
+public final class TokenCacheFactory {
+ private static final Logger LOG =
LoggerFactory.getLogger(TokenCacheFactory.class);
+
+ private TokenCacheFactory() {
+ // Utility class
+ }
+
+ /**
+ * Creates a token cache instance based on the specified type and
configuration.
+ *
+ * @param cacheType the type of cache to create
+ * @param ttlSeconds time-to-live for cache entries in
seconds
+ * @param maxSize maximum number of entries (only for
CAFFEINE type, 0 for unlimited)
+ * @param recordStats whether to record cache statistics
(only for CAFFEINE type)
+ * @return a TokenCache instance, or null if
caching is disabled
+ * @throws IllegalArgumentException if the cache type is not supported or
dependencies are missing
+ */
+ public static TokenCache createCache(
+ TokenCacheType cacheType, long ttlSeconds, long maxSize, boolean
recordStats) {
+
+ if (cacheType == null) {
+ cacheType = TokenCacheType.CONCURRENT_MAP;
+ }
+
+ switch (cacheType) {
+ case CONCURRENT_MAP:
+ LOG.debug("Creating ConcurrentMap token cache");
+ return new ConcurrentMapTokenCache(ttlSeconds);
+
+ case CAFFEINE:
+ LOG.debug("Creating Caffeine token cache");
+ return createCaffeineCache(ttlSeconds, maxSize, recordStats);
+
+ case NONE:
+ LOG.debug("Token caching is disabled");
+ return new NoOpTokenCache();
+
+ default:
+ throw new IllegalArgumentException("Unsupported cache type: "
+ cacheType);
+ }
+ }
+
+ /**
+ * Creates a token cache instance with default settings (ConcurrentMap
type).
+ *
+ * @param ttlSeconds time-to-live for cache entries in seconds
+ * @return a TokenCache instance
+ */
+ public static TokenCache createCache(long ttlSeconds) {
+ return createCache(TokenCacheType.CONCURRENT_MAP, ttlSeconds, 0,
false);
+ }
+
+ private static TokenCache createCaffeineCache(long ttlSeconds, long
maxSize, boolean recordStats) {
+ try {
+ // Check if Caffeine is available on the classpath
+ Class.forName("com.github.benmanes.caffeine.cache.Caffeine");
+ return new CaffeineTokenCache(ttlSeconds, maxSize, recordStats);
+ } catch (ClassNotFoundException e) {
+ throw new IllegalArgumentException(
+ "Caffeine cache type selected but caffeine dependency is
not available. "
+ + "Add caffeine to your
dependencies or use CONCURRENT_MAP cache type.",
+ e);
+ }
+ }
+
+ /**
+ * No-op cache implementation that doesn't cache anything.
+ */
+ private static class NoOpTokenCache implements TokenCache {
+
+ @Override
+ public KeycloakTokenIntrospector.IntrospectionResult get(String token)
{
+ return null;
+ }
+
+ @Override
+ public void put(String token,
KeycloakTokenIntrospector.IntrospectionResult result) {
+ // No-op
+ }
+
+ @Override
+ public void remove(String token) {
+ // No-op
+ }
+
+ @Override
+ public void clear() {
+ // No-op
+ }
+
+ @Override
+ public long size() {
+ return 0;
+ }
+
+ @Override
+ public CacheStats getStats() {
+ return new CacheStats(0, 0, 0);
+ }
+ }
+}
diff --git
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/TokenCacheType.java
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/TokenCacheType.java
new file mode 100644
index 000000000000..b11f4885f5fd
--- /dev/null
+++
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/cache/TokenCacheType.java
@@ -0,0 +1,41 @@
+/*
+ * 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.cache;
+
+/**
+ * Enumeration of supported token cache implementations.
+ */
+public enum TokenCacheType {
+ /**
+ * Simple in-memory cache using ConcurrentHashMap. This is the default
cache type with no external dependencies.
+ * Suitable for basic use cases and backward compatibility.
+ */
+ CONCURRENT_MAP,
+
+ /**
+ * High-performance cache using Caffeine library. Provides advanced
features including size-based eviction,
+ * statistics, and optimized concurrent access. Requires caffeine
dependency. Recommended for production use with
+ * high throughput.
+ */
+ CAFFEINE,
+
+ /**
+ * No caching - every token will be introspected on each request. Use this
for testing or when caching is not
+ * desired.
+ */
+ NONE
+}
diff --git
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakTokenIntrospectionIT.java
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakTokenIntrospectionIT.java
index 4da153069417..3cd43d13fee2 100644
---
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakTokenIntrospectionIT.java
+++
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakTokenIntrospectionIT.java
@@ -33,6 +33,9 @@ import org.apache.camel.CamelAuthorizationException;
import org.apache.camel.CamelExecutionException;
import org.apache.camel.RoutesBuilder;
import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.keycloak.security.cache.CaffeineTokenCache;
+import org.apache.camel.component.keycloak.security.cache.TokenCache;
+import org.apache.camel.component.keycloak.security.cache.TokenCacheType;
import org.apache.camel.test.infra.keycloak.services.KeycloakService;
import org.apache.camel.test.infra.keycloak.services.KeycloakServiceFactory;
import org.apache.camel.test.junit5.CamelTestSupport;
@@ -306,6 +309,27 @@ public class KeycloakTokenIntrospectionIT extends
CamelTestSupport {
.policy(noCachePolicy)
.transform().constant("No cache access granted")
.to("mock:no-cache-result");
+
+ // Policy with Caffeine cache
+ KeycloakSecurityPolicy caffeinePolicy = new
KeycloakSecurityPolicy();
+
caffeinePolicy.setServerUrl(keycloakService.getKeycloakServerUrl());
+ caffeinePolicy.setRealm(TEST_REALM_NAME);
+ caffeinePolicy.setClientId(TEST_CLIENT_ID);
+ caffeinePolicy.setClientSecret(TEST_CLIENT_SECRET);
+ caffeinePolicy.setRequiredRoles(Arrays.asList(ADMIN_ROLE));
+ caffeinePolicy.setUseTokenIntrospection(true);
+ caffeinePolicy.setIntrospectionCacheEnabled(true);
+ caffeinePolicy.setIntrospectionCacheTtl(60);
+ // Use Caffeine cache by creating custom cache
+ TokenCache customCaffeineCache = new CaffeineTokenCache(60,
100, true);
+
+ // Store for later retrieval in tests
+ context.getRegistry().bind("caffeineCache",
customCaffeineCache);
+
+ from("direct:caffeine-protected")
+ .policy(caffeinePolicy)
+ .transform().constant("Caffeine cache access granted")
+ .to("mock:caffeine-result");
}
};
}
@@ -512,6 +536,190 @@ public class KeycloakTokenIntrospectionIT extends
CamelTestSupport {
introspector.close();
}
+ @Test
+ @Order(30)
+ void testCaffeineCacheWithValidToken() throws Exception {
+ // Test Caffeine cache implementation with valid token
+ String adminToken = getAccessToken(ADMIN_USER, ADMIN_PASSWORD);
+ assertNotNull(adminToken);
+
+ // Create introspector with Caffeine cache
+ KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+ keycloakService.getKeycloakServerUrl(), TEST_REALM_NAME,
TEST_CLIENT_ID, TEST_CLIENT_SECRET,
+ TokenCacheType.CAFFEINE,
+ 60, // TTL in seconds
+ 100, // max size
+ true // record stats
+ );
+
+ // First introspection
+ KeycloakTokenIntrospector.IntrospectionResult result1 =
introspector.introspect(adminToken);
+ assertTrue(result1.isActive());
+
+ // Second introspection (should be cached)
+ KeycloakTokenIntrospector.IntrospectionResult result2 =
introspector.introspect(adminToken);
+ assertTrue(result2.isActive());
+
+ // Verify cache statistics
+ TokenCache.CacheStats stats = introspector.getCacheStats();
+ assertNotNull(stats);
+ assertTrue(stats.getHitCount() >= 1, "Should have at least one cache
hit");
+ LOG.info("Caffeine cache stats: {}", stats.toString());
+
+ // Verify cache size
+ long cacheSize = introspector.getCacheSize();
+ assertTrue(cacheSize > 0, "Cache should contain at least one entry");
+ LOG.info("Caffeine cache size: {}", cacheSize);
+
+ introspector.close();
+ }
+
+ @Test
+ @Order(31)
+ void testCaffeineCachePerformance() throws Exception {
+ // Test that Caffeine cache improves performance
+ String adminToken = getAccessToken(ADMIN_USER, ADMIN_PASSWORD);
+ assertNotNull(adminToken);
+
+ KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+ keycloakService.getKeycloakServerUrl(), TEST_REALM_NAME,
TEST_CLIENT_ID, TEST_CLIENT_SECRET,
+ TokenCacheType.CAFFEINE,
+ 120, // 2 minute TTL
+ 1000, // max 1000 entries
+ true // record stats
+ );
+
+ // First introspection - will hit Keycloak
+ long start1 = System.currentTimeMillis();
+ KeycloakTokenIntrospector.IntrospectionResult result1 =
introspector.introspect(adminToken);
+ long duration1 = System.currentTimeMillis() - start1;
+
+ // Second introspection - should hit cache
+ long start2 = System.currentTimeMillis();
+ KeycloakTokenIntrospector.IntrospectionResult result2 =
introspector.introspect(adminToken);
+ long duration2 = System.currentTimeMillis() - start2;
+
+ assertTrue(result1.isActive());
+ assertTrue(result2.isActive());
+
+ // Cached result should be faster
+ LOG.info("First introspection (Keycloak): {}ms, Cached introspection
(Caffeine): {}ms", duration1, duration2);
+ assertTrue(duration2 <= duration1, "Cached introspection should be
faster or equal");
+
+ // Verify cache statistics
+ TokenCache.CacheStats stats = introspector.getCacheStats();
+ assertNotNull(stats);
+ assertEquals(1, stats.getHitCount(), "Should have exactly one cache
hit");
+ assertEquals(1, stats.getMissCount(), "Should have exactly one cache
miss");
+ assertEquals(0.5, stats.getHitRate(), 0.01, "Hit rate should be 50%");
+ LOG.info("Caffeine cache performance stats: {}", stats);
+
+ introspector.close();
+ }
+
+ @Test
+ @Order(32)
+ void testCaffeineCacheEviction() throws Exception {
+ // Test that Caffeine cache evicts entries based on size
+ String adminToken = getAccessToken(ADMIN_USER, ADMIN_PASSWORD);
+ assertNotNull(adminToken);
+
+ // Create cache with very small max size
+ KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+ keycloakService.getKeycloakServerUrl(), TEST_REALM_NAME,
TEST_CLIENT_ID, TEST_CLIENT_SECRET,
+ TokenCacheType.CAFFEINE,
+ 300, // 5 minute TTL
+ 2, // max 2 entries (small for testing eviction)
+ true // record stats
+ );
+
+ // Introspect the same token multiple times
+ for (int i = 0; i < 5; i++) {
+ introspector.introspect(adminToken);
+ }
+
+ // Verify cache stats
+ TokenCache.CacheStats stats = introspector.getCacheStats();
+ assertNotNull(stats);
+ assertTrue(stats.getHitCount() > 0, "Should have cache hits");
+ LOG.info("Cache stats after multiple introspections: {}", stats);
+
+ // Verify cache size is within limits
+ long size = introspector.getCacheSize();
+ assertTrue(size <= 2, "Cache size should not exceed max size of 2");
+
+ introspector.close();
+ }
+
+ @Test
+ @Order(33)
+ void testCaffeineCacheWithMultipleTokens() throws Exception {
+ // Test Caffeine cache with multiple different tokens
+ String adminToken = getAccessToken(ADMIN_USER, ADMIN_PASSWORD);
+ String userToken = getAccessToken(USER_USER, USER_PASSWORD);
+ assertNotNull(adminToken);
+ assertNotNull(userToken);
+
+ KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+ keycloakService.getKeycloakServerUrl(), TEST_REALM_NAME,
TEST_CLIENT_ID, TEST_CLIENT_SECRET,
+ TokenCacheType.CAFFEINE,
+ 60, // 1 minute TTL
+ 100, // max 100 entries
+ true // record stats
+ );
+
+ // Introspect both tokens
+ introspector.introspect(adminToken);
+ introspector.introspect(userToken);
+
+ // Verify both are cached
+ long cacheSize = introspector.getCacheSize();
+ assertEquals(2, cacheSize, "Cache should contain both tokens");
+
+ // Introspect again (should hit cache)
+ introspector.introspect(adminToken);
+ introspector.introspect(userToken);
+
+ TokenCache.CacheStats stats = introspector.getCacheStats();
+ assertNotNull(stats);
+ assertEquals(2, stats.getHitCount(), "Should have 2 cache hits");
+ assertEquals(2, stats.getMissCount(), "Should have 2 cache misses");
+ LOG.info("Cache stats with multiple tokens: {}", stats);
+
+ introspector.close();
+ }
+
+ @Test
+ @Order(34)
+ void testCaffeineCacheClearOperation() throws Exception {
+ // Test that cache can be cleared
+ String adminToken = getAccessToken(ADMIN_USER, ADMIN_PASSWORD);
+ assertNotNull(adminToken);
+
+ KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+ keycloakService.getKeycloakServerUrl(), TEST_REALM_NAME,
TEST_CLIENT_ID, TEST_CLIENT_SECRET,
+ TokenCacheType.CAFFEINE,
+ 60, 100, true);
+
+ // Introspect and cache
+ introspector.introspect(adminToken);
+ assertTrue(introspector.getCacheSize() > 0, "Cache should have
entries");
+
+ // Clear cache
+ introspector.clearCache();
+ assertEquals(0, introspector.getCacheSize(), "Cache should be empty
after clear");
+
+ // Introspect again (should miss cache)
+ introspector.introspect(adminToken);
+
+ TokenCache.CacheStats stats = introspector.getCacheStats();
+ assertNotNull(stats);
+ // After clear, stats should be reset
+ LOG.info("Cache stats after clear: {}", stats);
+
+ introspector.close();
+ }
+
/**
* Helper method to obtain access token from Keycloak using resource owner
password flow.
*/
diff --git
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/cache/CaffeineTokenCacheTest.java
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/cache/CaffeineTokenCacheTest.java
new file mode 100644
index 000000000000..8f9d9ddee58f
--- /dev/null
+++
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/cache/CaffeineTokenCacheTest.java
@@ -0,0 +1,202 @@
+/*
+ * 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.cache;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.camel.component.keycloak.security.KeycloakTokenIntrospector;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class CaffeineTokenCacheTest {
+
+ private CaffeineTokenCache cache;
+ private KeycloakTokenIntrospector.IntrospectionResult testResult;
+
+ @BeforeEach
+ void setUp() {
+ cache = new CaffeineTokenCache(60, 100, true); // 60 seconds TTL, max
100 entries, stats enabled
+
+ Map<String, Object> claims = new HashMap<>();
+ claims.put("active", true);
+ claims.put("sub", "test-user");
+ claims.put("scope", "openid profile");
+ testResult = new KeycloakTokenIntrospector.IntrospectionResult(claims);
+ }
+
+ @Test
+ void testPutAndGet() {
+ String token = "test-token-123";
+
+ cache.put(token, testResult);
+
+ KeycloakTokenIntrospector.IntrospectionResult retrieved =
cache.get(token);
+ assertNotNull(retrieved);
+ assertTrue(retrieved.isActive());
+ assertEquals("test-user", retrieved.getSubject());
+ }
+
+ @Test
+ void testGetNonExistent() {
+ KeycloakTokenIntrospector.IntrospectionResult retrieved =
cache.get("non-existent");
+ assertNull(retrieved);
+ }
+
+ @Test
+ void testRemove() {
+ String token = "test-token-456";
+
+ cache.put(token, testResult);
+ assertNotNull(cache.get(token));
+
+ cache.remove(token);
+ assertNull(cache.get(token));
+ }
+
+ @Test
+ void testClear() {
+ cache.put("token1", testResult);
+ cache.put("token2", testResult);
+
+ cache.clear();
+
+ assertEquals(0, cache.size());
+ assertNull(cache.get("token1"));
+ assertNull(cache.get("token2"));
+ }
+
+ @Test
+ void testExpiration() throws InterruptedException {
+ CaffeineTokenCache shortCache = new CaffeineTokenCache(1); // 1 second
TTL
+ String token = "expiring-token";
+
+ shortCache.put(token, testResult);
+ assertNotNull(shortCache.get(token));
+
+ // Wait for expiration
+ Thread.sleep(1100);
+
+ assertNull(shortCache.get(token));
+ shortCache.close();
+ }
+
+ @Test
+ void testMaxSize() {
+ CaffeineTokenCache limitedCache = new CaffeineTokenCache(60, 5, true);
// Max 5 entries
+
+ // Add 10 entries, only 5 should remain
+ for (int i = 0; i < 10; i++) {
+ limitedCache.put("token-" + i, testResult);
+ }
+
+ // Allow time for eviction
+ limitedCache.getCaffeineCache().cleanUp();
+
+ // Size should be at most 5
+ assertTrue(limitedCache.size() <= 5);
+ limitedCache.close();
+ }
+
+ @Test
+ void testStats() {
+ TokenCache.CacheStats stats = cache.getStats();
+ assertNotNull(stats);
+
+ // Put and get (hit)
+ cache.put("token1", testResult);
+ cache.get("token1");
+
+ stats = cache.getStats();
+ assertEquals(1, stats.getHitCount());
+ assertEquals(0, stats.getMissCount());
+
+ // Get non-existent (miss)
+ cache.get("non-existent");
+
+ stats = cache.getStats();
+ assertEquals(1, stats.getHitCount());
+ assertEquals(1, stats.getMissCount());
+ assertEquals(0.5, stats.getHitRate(), 0.01);
+ }
+
+ @Test
+ void testStatsToString() {
+ cache.put("token1", testResult);
+ cache.get("token1");
+ cache.get("non-existent");
+
+ TokenCache.CacheStats stats = cache.getStats();
+ String statsString = stats.toString();
+
+ assertNotNull(statsString);
+ assertTrue(statsString.contains("hits="));
+ assertTrue(statsString.contains("misses="));
+ assertTrue(statsString.contains("hitRate="));
+ }
+
+ @Test
+ void testClose() {
+ cache.put("token1", testResult);
+ cache.put("token2", testResult);
+
+ cache.close();
+
+ // After close, cache should be empty
+ assertEquals(0, cache.size());
+ }
+
+ @Test
+ void testConcurrentAccess() throws InterruptedException {
+ int threadCount = 20;
+ Thread[] threads = new Thread[threadCount];
+
+ for (int i = 0; i < threadCount; i++) {
+ final int index = i;
+ threads[i] = new Thread(() -> {
+ String token = "token-" + index;
+ cache.put(token, testResult);
+ assertNotNull(cache.get(token));
+ });
+ threads[i].start();
+ }
+
+ for (Thread thread : threads) {
+ thread.join();
+ }
+
+ assertTrue(cache.size() > 0);
+ }
+
+ @Test
+ void testGetCaffeineCache() {
+ assertNotNull(cache.getCaffeineCache());
+ }
+
+ @Test
+ void testDefaultConstructor() {
+ CaffeineTokenCache defaultCache = new CaffeineTokenCache(30);
+ assertNotNull(defaultCache);
+
+ defaultCache.put("token", testResult);
+ assertNotNull(defaultCache.get("token"));
+
+ defaultCache.close();
+ }
+}
diff --git
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/cache/ConcurrentMapTokenCacheTest.java
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/cache/ConcurrentMapTokenCacheTest.java
new file mode 100644
index 000000000000..d08fbeee74f9
--- /dev/null
+++
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/cache/ConcurrentMapTokenCacheTest.java
@@ -0,0 +1,175 @@
+/*
+ * 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.cache;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.camel.component.keycloak.security.KeycloakTokenIntrospector;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ConcurrentMapTokenCacheTest {
+
+ private ConcurrentMapTokenCache cache;
+ private KeycloakTokenIntrospector.IntrospectionResult testResult;
+
+ @BeforeEach
+ void setUp() {
+ cache = new ConcurrentMapTokenCache(60); // 60 seconds TTL
+
+ Map<String, Object> claims = new HashMap<>();
+ claims.put("active", true);
+ claims.put("sub", "test-user");
+ claims.put("scope", "openid profile");
+ testResult = new KeycloakTokenIntrospector.IntrospectionResult(claims);
+ }
+
+ @Test
+ void testPutAndGet() {
+ String token = "test-token-123";
+
+ cache.put(token, testResult);
+
+ KeycloakTokenIntrospector.IntrospectionResult retrieved =
cache.get(token);
+ assertNotNull(retrieved);
+ assertTrue(retrieved.isActive());
+ assertEquals("test-user", retrieved.getSubject());
+ }
+
+ @Test
+ void testGetNonExistent() {
+ KeycloakTokenIntrospector.IntrospectionResult retrieved =
cache.get("non-existent");
+ assertNull(retrieved);
+ }
+
+ @Test
+ void testRemove() {
+ String token = "test-token-456";
+
+ cache.put(token, testResult);
+ assertNotNull(cache.get(token));
+
+ cache.remove(token);
+ assertNull(cache.get(token));
+ }
+
+ @Test
+ void testClear() {
+ cache.put("token1", testResult);
+ cache.put("token2", testResult);
+
+ assertEquals(2, cache.size());
+
+ cache.clear();
+
+ assertEquals(0, cache.size());
+ assertNull(cache.get("token1"));
+ assertNull(cache.get("token2"));
+ }
+
+ @Test
+ void testExpiration() throws InterruptedException {
+ ConcurrentMapTokenCache shortCache = new ConcurrentMapTokenCache(1);
// 1 second TTL
+ String token = "expiring-token";
+
+ shortCache.put(token, testResult);
+ assertNotNull(shortCache.get(token));
+
+ // Wait for expiration
+ Thread.sleep(1100);
+
+ assertNull(shortCache.get(token));
+ }
+
+ @Test
+ void testSize() {
+ assertEquals(0, cache.size());
+
+ cache.put("token1", testResult);
+ assertEquals(1, cache.size());
+
+ cache.put("token2", testResult);
+ assertEquals(2, cache.size());
+
+ cache.remove("token1");
+ assertEquals(1, cache.size());
+ }
+
+ @Test
+ void testStats() {
+ TokenCache.CacheStats stats = cache.getStats();
+ assertNotNull(stats);
+ assertEquals(0, stats.getHitCount());
+ assertEquals(0, stats.getMissCount());
+
+ // Put and get (hit)
+ cache.put("token1", testResult);
+ cache.get("token1");
+
+ stats = cache.getStats();
+ assertEquals(1, stats.getHitCount());
+ assertEquals(0, stats.getMissCount());
+
+ // Get non-existent (miss)
+ cache.get("non-existent");
+
+ stats = cache.getStats();
+ assertEquals(1, stats.getHitCount());
+ assertEquals(1, stats.getMissCount());
+ assertEquals(0.5, stats.getHitRate(), 0.01);
+ }
+
+ @Test
+ void testStatsAfterClear() {
+ cache.put("token1", testResult);
+ cache.get("token1");
+
+ TokenCache.CacheStats stats = cache.getStats();
+ assertTrue(stats.getHitCount() > 0);
+
+ cache.clear();
+
+ stats = cache.getStats();
+ assertEquals(0, stats.getHitCount());
+ assertEquals(0, stats.getMissCount());
+ }
+
+ @Test
+ void testConcurrentAccess() throws InterruptedException {
+ int threadCount = 10;
+ Thread[] threads = new Thread[threadCount];
+
+ for (int i = 0; i < threadCount; i++) {
+ final int index = i;
+ threads[i] = new Thread(() -> {
+ String token = "token-" + index;
+ cache.put(token, testResult);
+ assertNotNull(cache.get(token));
+ });
+ threads[i].start();
+ }
+
+ for (Thread thread : threads) {
+ thread.join();
+ }
+
+ assertEquals(threadCount, cache.size());
+ }
+}
diff --git
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/cache/TokenCacheFactoryTest.java
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/cache/TokenCacheFactoryTest.java
new file mode 100644
index 000000000000..5b080ab21124
--- /dev/null
+++
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/cache/TokenCacheFactoryTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.cache;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.camel.component.keycloak.security.KeycloakTokenIntrospector;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TokenCacheFactoryTest {
+
+ @Test
+ void testCreateConcurrentMapCache() {
+ TokenCache cache =
TokenCacheFactory.createCache(TokenCacheType.CONCURRENT_MAP, 60, 0, false);
+
+ assertNotNull(cache);
+ assertInstanceOf(ConcurrentMapTokenCache.class, cache);
+ }
+
+ @Test
+ void testCreateCaffeineCache() {
+ TokenCache cache =
TokenCacheFactory.createCache(TokenCacheType.CAFFEINE, 60, 100, true);
+
+ assertNotNull(cache);
+ assertInstanceOf(CaffeineTokenCache.class, cache);
+ }
+
+ @Test
+ void testCreateNoOpCache() {
+ TokenCache cache = TokenCacheFactory.createCache(TokenCacheType.NONE,
60, 0, false);
+
+ assertNotNull(cache);
+
+ Map<String, Object> claims = new HashMap<>();
+ claims.put("active", true);
+ KeycloakTokenIntrospector.IntrospectionResult result = new
KeycloakTokenIntrospector.IntrospectionResult(claims);
+
+ // NoOp cache should not store anything
+ cache.put("token", result);
+ assertNull(cache.get("token"));
+ assertEquals(0, cache.size());
+ }
+
+ @Test
+ void testCreateCacheWithDefaultType() {
+ TokenCache cache = TokenCacheFactory.createCache(60);
+
+ assertNotNull(cache);
+ assertInstanceOf(ConcurrentMapTokenCache.class, cache);
+ }
+
+ @Test
+ void testCreateCacheWithNullType() {
+ TokenCache cache = TokenCacheFactory.createCache(null, 60, 0, false);
+
+ assertNotNull(cache);
+ assertInstanceOf(ConcurrentMapTokenCache.class, cache);
+ }
+
+ @Test
+ void testNoOpCacheStats() {
+ TokenCache cache = TokenCacheFactory.createCache(TokenCacheType.NONE,
60, 0, false);
+
+ TokenCache.CacheStats stats = cache.getStats();
+ assertNotNull(stats);
+ assertEquals(0, stats.getHitCount());
+ assertEquals(0, stats.getMissCount());
+ assertEquals(0, stats.getEvictionCount());
+ assertEquals(0.0, stats.getHitRate());
+ }
+
+ @Test
+ void testNoOpCacheClear() {
+ TokenCache cache = TokenCacheFactory.createCache(TokenCacheType.NONE,
60, 0, false);
+
+ // Should not throw exception
+ cache.clear();
+ cache.remove("any-token");
+ cache.close();
+ }
+}