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

oscerd pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/camel-4.22.x by this push:
     new 66fd7034911a [camel-4.22.x] CAMEL-24902: camel-keycloak - honor token 
expiry (exp) when caching introspection results (#27182)
66fd7034911a is described below

commit 66fd7034911aebcaffa80bae4ece265feb2895bb
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Oct 1 11:51:03 2026 +0200

    [camel-4.22.x] CAMEL-24902: camel-keycloak - honor token expiry (exp) when 
caching introspection results (#27182)
    
    Backport of #26747.
    
    Both introspection result caches expired entries purely by insertion-time 
TTL, so a cached active result could be reused after the token's own exp had 
passed, unlike the local JWT verification path.
    
    The caches now bound each entry by the token's exp as well as the 
configured TTL, and the introspection paths in KeycloakSecurityProcessor reject 
results whose exp has passed.
    
    (cherry picked from commit 7bfacb70d3300f97a130eda92b7b86ec5d8b11af)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    Signed-off-by: Andrea Cosentino <[email protected]>
---
 .../camel/catalog/docs/keycloak-security.adoc      |  4 +-
 .../src/main/docs/keycloak-security.adoc           |  4 +-
 .../security/KeycloakSecurityProcessor.java        | 19 +++++++
 .../security/KeycloakTokenIntrospector.java        | 11 ++++
 .../security/cache/CaffeineTokenCache.java         | 54 ++++++++++++++++++-
 .../security/cache/ConcurrentMapTokenCache.java    | 21 +++++++-
 .../security/KeycloakSecurityProcessorTest.java    | 63 ++++++++++++++++++++++
 .../security/cache/CaffeineTokenCacheTest.java     | 62 +++++++++++++++++++++
 .../cache/ConcurrentMapTokenCacheTest.java         | 54 +++++++++++++++++++
 9 files changed, 285 insertions(+), 7 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 1700046c008d..e46da1a9c42f 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
@@ -362,7 +362,7 @@ beans:
 
 | `useTokenIntrospection` | false | Enable OAuth 2.0 token introspection. When 
enabled, tokens are validated via Keycloak's introspection endpoint instead of 
local JWT parsing.
 | `introspectionCacheEnabled` | true | Enable caching of introspection results 
to reduce API calls to Keycloak. Highly recommended for production use.
-| `introspectionCacheTtl` | 60 | Time-to-live for cached introspection results 
in seconds. Balance between security (lower TTL) and performance (higher TTL).
+| `introspectionCacheTtl` | 60 | Time-to-live for cached introspection results 
in seconds. A cached result is additionally bounded by the token's own expiry 
(`exp`), so it is never returned after the token has expired even if the TTL 
has not elapsed. Balance between security (lower TTL) and performance (higher 
TTL).
 |===
 
 NOTE: Token introspection requires a confidential client with client 
credentials (client ID and client secret).
@@ -576,7 +576,7 @@ beans:
 | Internal Services | 300-600 seconds | Trusted environment, prioritize 
performance
 |===
 
-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.
+NOTE: When a token is introspected and cached, subsequent requests with the 
same token will use the cached result until the TTL expires or the token's own 
expiry (`exp`) is reached, whichever comes first. A cached result is never 
returned after the token has expired. Balance security requirements with 
performance needs.
 
 === Pluggable Cache Implementation
 
diff --git a/components/camel-keycloak/src/main/docs/keycloak-security.adoc 
b/components/camel-keycloak/src/main/docs/keycloak-security.adoc
index 1700046c008d..e46da1a9c42f 100644
--- a/components/camel-keycloak/src/main/docs/keycloak-security.adoc
+++ b/components/camel-keycloak/src/main/docs/keycloak-security.adoc
@@ -362,7 +362,7 @@ beans:
 
 | `useTokenIntrospection` | false | Enable OAuth 2.0 token introspection. When 
enabled, tokens are validated via Keycloak's introspection endpoint instead of 
local JWT parsing.
 | `introspectionCacheEnabled` | true | Enable caching of introspection results 
to reduce API calls to Keycloak. Highly recommended for production use.
-| `introspectionCacheTtl` | 60 | Time-to-live for cached introspection results 
in seconds. Balance between security (lower TTL) and performance (higher TTL).
+| `introspectionCacheTtl` | 60 | Time-to-live for cached introspection results 
in seconds. A cached result is additionally bounded by the token's own expiry 
(`exp`), so it is never returned after the token has expired even if the TTL 
has not elapsed. Balance between security (lower TTL) and performance (higher 
TTL).
 |===
 
 NOTE: Token introspection requires a confidential client with client 
credentials (client ID and client secret).
@@ -576,7 +576,7 @@ beans:
 | Internal Services | 300-600 seconds | Trusted environment, prioritize 
performance
 |===
 
-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.
+NOTE: When a token is introspected and cached, subsequent requests with the 
same token will use the cached result until the TTL expires or the token's own 
expiry (`exp`) is reached, whichever comes first. A cached result is never 
returned after the token has expired. Balance security requirements with 
performance needs.
 
 === Pluggable Cache Implementation
 
diff --git 
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java
 
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java
index 39f3e56a8730..5453c544d5f6 100644
--- 
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java
+++ 
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java
@@ -108,6 +108,8 @@ public class KeycloakSecurityProcessor extends 
DelegateProcessor {
                 throw new CamelAuthorizationException("Token is not active 
(may be revoked or expired)", exchange);
             }
 
+            ensureTokenNotExpired(introspectionResult, exchange);
+
             if (policy.isValidateIssuer()) {
                 validateIssuerFromIntrospection(introspectionResult, exchange);
             }
@@ -128,6 +130,19 @@ public class KeycloakSecurityProcessor extends 
DelegateProcessor {
         }
     }
 
+    /**
+     * Enforces token expiry on the introspection path. The introspection 
endpoint reports an expired token as inactive,
+     * but a cached result can outlive the token's own {@code exp}; this check 
ensures an expired token is rejected on a
+     * cache hit, consistent with the expiry enforcement performed on the 
local JWT verification path.
+     */
+    private void ensureTokenNotExpired(
+            KeycloakTokenIntrospector.IntrospectionResult introspectionResult, 
Exchange exchange)
+            throws CamelAuthorizationException {
+        if (introspectionResult.isExpired()) {
+            throw new CamelAuthorizationException("Token has expired", 
exchange);
+        }
+    }
+
     private String getAccessToken(Exchange exchange) throws Exception {
         // Get token from exchange property (application-controlled, TRUSTED)
         String propertyToken = 
exchange.getProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY, 
String.class);
@@ -282,6 +297,8 @@ public class KeycloakSecurityProcessor extends 
DelegateProcessor {
                     throw new CamelAuthorizationException("Token is not active 
(may be revoked or expired)", exchange);
                 }
 
+                ensureTokenNotExpired(introspectionResult, exchange);
+
                 // Validate issuer from introspection result if enabled
                 if (policy.isValidateIssuer()) {
                     validateIssuerFromIntrospection(introspectionResult, 
exchange);
@@ -507,6 +524,8 @@ public class KeycloakSecurityProcessor extends 
DelegateProcessor {
                     throw new CamelAuthorizationException("Token is not active 
(may be revoked or expired)", exchange);
                 }
 
+                ensureTokenNotExpired(introspectionResult, exchange);
+
                 // Validate issuer from introspection result if enabled
                 if (policy.isValidateIssuer()) {
                     validateIssuerFromIntrospection(introspectionResult, 
exchange);
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 4ca6239e0328..68d5880f2f2c 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
@@ -254,6 +254,17 @@ public class KeycloakTokenIntrospector {
             return active instanceof Boolean b && b;
         }
 
+        /**
+         * Returns whether the token has passed its expiry ({@code exp}) time. 
A result that carries no {@code exp}
+         * claim is treated as not expired, leaving expiry enforcement to the 
introspection endpoint.
+         *
+         * @return true if the {@code exp} claim is present and lies in the 
past, false otherwise
+         */
+        public boolean isExpired() {
+            Long exp = getExpiration();
+            return exp != null && exp * 1000L <= System.currentTimeMillis();
+        }
+
         /**
          * Returns the subject (user ID) of the token.
          *
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
index b735fdc92ca8..30d43cdd4c81 100644
--- 
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
@@ -20,6 +20,7 @@ import java.util.concurrent.TimeUnit;
 
 import com.github.benmanes.caffeine.cache.Cache;
 import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Expiry;
 import org.apache.camel.component.keycloak.security.KeycloakTokenIntrospector;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -42,8 +43,8 @@ public class CaffeineTokenCache implements TokenCache {
      * @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);
+        Caffeine<String, KeycloakTokenIntrospector.IntrospectionResult> 
builder = Caffeine.newBuilder()
+                .expireAfter(new 
IntrospectionExpiry(TimeUnit.SECONDS.toNanos(ttlSeconds)));
 
         if (maxSize > 0) {
             builder.maximumSize(maxSize);
@@ -126,4 +127,53 @@ public class CaffeineTokenCache implements TokenCache {
     public Cache<String, KeycloakTokenIntrospector.IntrospectionResult> 
getCaffeineCache() {
         return cache;
     }
+
+    /**
+     * Caffeine expiry policy that bounds each entry's lifetime by the smaller 
of the configured TTL and the token's own
+     * remaining validity ({@code exp}), so a cached introspection result is 
never returned after the token has expired.
+     * Reads do not extend an entry's lifetime.
+     */
+    private static final class IntrospectionExpiry
+            implements Expiry<String, 
KeycloakTokenIntrospector.IntrospectionResult> {
+
+        private final long maxTtlNanos;
+
+        IntrospectionExpiry(long maxTtlNanos) {
+            this.maxTtlNanos = maxTtlNanos;
+        }
+
+        @Override
+        public long expireAfterCreate(
+                String key, KeycloakTokenIntrospector.IntrospectionResult 
value, long currentTime) {
+            return expiryNanos(value);
+        }
+
+        @Override
+        public long expireAfterUpdate(
+                String key, KeycloakTokenIntrospector.IntrospectionResult 
value, long currentTime, long currentDuration) {
+            return expiryNanos(value);
+        }
+
+        @Override
+        public long expireAfterRead(
+                String key, KeycloakTokenIntrospector.IntrospectionResult 
value, long currentTime, long currentDuration) {
+            // Reads must not extend the cached lifetime beyond the token's 
expiry.
+            return currentDuration;
+        }
+
+        private long expiryNanos(KeycloakTokenIntrospector.IntrospectionResult 
value) {
+            Long expSeconds = value.getExpiration();
+            if (expSeconds == null) {
+                return maxTtlNanos;
+            }
+            long remainingMillis = expSeconds * 1000L - 
System.currentTimeMillis();
+            if (remainingMillis <= 0) {
+                // Already expired, or an out-of-range exp whose millisecond 
conversion overflowed to a
+                // negative value: expire immediately so the entry is not 
served.
+                return 0L;
+            }
+            // toNanos() saturates to Long.MAX_VALUE for a far-future exp, so 
min() still yields the TTL.
+            return Math.min(maxTtlNanos, 
TimeUnit.MILLISECONDS.toNanos(remainingMillis));
+        }
+    }
 }
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
index aa2ebb06ec4b..17dae7e3d84b 100644
--- 
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
@@ -64,11 +64,30 @@ public class ConcurrentMapTokenCache implements TokenCache {
 
     @Override
     public void put(String token, 
KeycloakTokenIntrospector.IntrospectionResult result) {
-        cache.put(token, new CachedEntry(result, ttlMillis));
+        if (result.isExpired()) {
+            // Never cache a result whose token has already expired: it must 
not be served on a later hit.
+            LOG.trace("Token already expired; skipping cache put");
+            return;
+        }
+        cache.put(token, new CachedEntry(result, effectiveTtlMillis(result)));
         LOG.trace("Token introspection result cached");
         cleanupExpiredEntries();
     }
 
+    /**
+     * Computes the effective time-to-live for a result, bounding the 
configured TTL by the token's own remaining
+     * validity so a cached result is never returned after the token's {@code 
exp}. Results without an {@code exp} claim
+     * keep the configured TTL.
+     */
+    private long 
effectiveTtlMillis(KeycloakTokenIntrospector.IntrospectionResult result) {
+        Long expSeconds = result.getExpiration();
+        if (expSeconds == null) {
+            return ttlMillis;
+        }
+        long remainingMillis = expSeconds * 1000L - System.currentTimeMillis();
+        return Math.min(ttlMillis, remainingMillis);
+    }
+
     @Override
     public void remove(String token) {
         cache.remove(token);
diff --git 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessorTest.java
 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessorTest.java
index 837ddd22c4d9..f657e7216126 100644
--- 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessorTest.java
+++ 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessorTest.java
@@ -518,4 +518,67 @@ class KeycloakSecurityProcessorTest {
         assertFalse(routeReached.get(),
                 "Route body must not be reached when the token has the 
required permission but the wrong authorized party");
     }
+
+    @Test
+    void testActiveButExpiredIntrospectionResultRejected() throws Exception {
+        // Simulates a cached introspection result that was active when stored 
but whose token exp has since passed:
+        // the introspection path must reject it, consistent with expiry 
enforcement on the local JWT path, instead of
+        // admitting the request until the cache TTL elapses.
+        long expiredSecondsAgo = System.currentTimeMillis() / 1000 - 60;
+        KeycloakTokenIntrospector introspector
+                = introspectorReturning(Map.of("active", true, "exp", 
expiredSecondsAgo));
+
+        KeycloakSecurityPolicy policy = introspectionPolicy(introspector);
+        policy.setValidateIssuer(false);
+
+        AtomicBoolean routeReached = new AtomicBoolean(false);
+        KeycloakSecurityProcessor processor = new KeycloakSecurityProcessor(e 
-> routeReached.set(true), policy);
+
+        CamelAuthorizationException e
+                = assertThrows(CamelAuthorizationException.class, () -> 
processor.process(bearer("x")));
+        assertTrue(e.getMessage().contains("expired"), "unexpected message: " 
+ e.getMessage());
+        assertFalse(routeReached.get(), "Route body must not be reached for an 
expired token");
+    }
+
+    @Test
+    void testActiveButExpiredIntrospectionResultRejectedOnRolesPath() throws 
Exception {
+        // With required roles configured, authentication runs through 
validateRoles(); the expiry check there
+        // must reject an active-but-expired result before the role check, so 
removing it from that path is caught.
+        long expiredSecondsAgo = System.currentTimeMillis() / 1000 - 60;
+        KeycloakTokenIntrospector introspector
+                = introspectorReturning(Map.of("active", true, "exp", 
expiredSecondsAgo));
+
+        KeycloakSecurityPolicy policy = introspectionPolicy(introspector);
+        policy.setValidateIssuer(false);
+        policy.setRequiredRoles("admin");
+
+        AtomicBoolean routeReached = new AtomicBoolean(false);
+        KeycloakSecurityProcessor processor = new KeycloakSecurityProcessor(e 
-> routeReached.set(true), policy);
+
+        CamelAuthorizationException e
+                = assertThrows(CamelAuthorizationException.class, () -> 
processor.process(bearer("x")));
+        assertTrue(e.getMessage().contains("expired"), "unexpected message: " 
+ e.getMessage());
+        assertFalse(routeReached.get(), "Route body must not be reached for an 
expired token on the roles path");
+    }
+
+    @Test
+    void testActiveButExpiredIntrospectionResultRejectedOnPermissionsPath() 
throws Exception {
+        // With required permissions configured, authentication runs through 
validatePermissions(); the expiry
+        // check there must reject an active-but-expired result before the 
permission check.
+        long expiredSecondsAgo = System.currentTimeMillis() / 1000 - 60;
+        KeycloakTokenIntrospector introspector
+                = introspectorReturning(Map.of("active", true, "exp", 
expiredSecondsAgo));
+
+        KeycloakSecurityPolicy policy = introspectionPolicy(introspector);
+        policy.setValidateIssuer(false);
+        policy.setRequiredPermissions("read");
+
+        AtomicBoolean routeReached = new AtomicBoolean(false);
+        KeycloakSecurityProcessor processor = new KeycloakSecurityProcessor(e 
-> routeReached.set(true), policy);
+
+        CamelAuthorizationException e
+                = assertThrows(CamelAuthorizationException.class, () -> 
processor.process(bearer("x")));
+        assertTrue(e.getMessage().contains("expired"), "unexpected message: " 
+ e.getMessage());
+        assertFalse(routeReached.get(), "Route body must not be reached for an 
expired token on the permissions path");
+    }
 }
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
index 8f9d9ddee58f..88d45dfe6668 100644
--- 
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
@@ -18,11 +18,13 @@ package org.apache.camel.component.keycloak.security.cache;
 
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 import org.apache.camel.component.keycloak.security.KeycloakTokenIntrospector;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import static org.awaitility.Awaitility.await;
 import static org.junit.jupiter.api.Assertions.*;
 
 class CaffeineTokenCacheTest {
@@ -199,4 +201,64 @@ class CaffeineTokenCacheTest {
 
         defaultCache.close();
     }
+
+    @Test
+    void testExpiredResultNotServed() {
+        // A result whose token has already expired is given a 0 lifetime by 
IntrospectionExpiry
+        // (expiryNanos returns 0), so it is evicted immediately rather than 
kept for the TTL. The TTL
+        // bounding for a not-yet-expired token is covered by 
testResultExpiringBeforeTtlNotServedAfterExp.
+        Map<String, Object> claims = new HashMap<>();
+        claims.put("active", true);
+        claims.put("sub", "test-user");
+        claims.put("exp", System.currentTimeMillis() / 1000 - 60); // expired 
60 seconds ago
+        KeycloakTokenIntrospector.IntrospectionResult expired
+                = new KeycloakTokenIntrospector.IntrospectionResult(claims);
+
+        cache.put("expired-token", expired);
+        cache.getCaffeineCache().cleanUp();
+
+        assertNull(cache.get("expired-token"));
+    }
+
+    @Test
+    void testResultWithFutureExpirationServed() {
+        Map<String, Object> claims = new HashMap<>();
+        claims.put("active", true);
+        claims.put("sub", "test-user");
+        claims.put("exp", System.currentTimeMillis() / 1000 + 300); // valid 
for 5 more minutes
+        KeycloakTokenIntrospector.IntrospectionResult valid
+                = new KeycloakTokenIntrospector.IntrospectionResult(claims);
+
+        cache.put("valid-token", valid);
+
+        KeycloakTokenIntrospector.IntrospectionResult retrieved = 
cache.get("valid-token");
+        assertNotNull(retrieved);
+        assertTrue(retrieved.isActive());
+    }
+
+    @Test
+    void testResultExpiringBeforeTtlNotServedAfterExp() {
+        // The token's exp lands inside the TTL window (~2s vs a 300s TTL), so 
the entry must expire at exp,
+        // not at the configured TTL. This exercises 
IntrospectionExpiry.expiryNanos()'s min(ttl, remaining):
+        // returning maxTtlNanos unconditionally would keep the entry served 
for 300s and fail this test.
+        CaffeineTokenCache longTtlCache = new CaffeineTokenCache(300, 100, 
true);
+        Map<String, Object> claims = new HashMap<>();
+        claims.put("active", true);
+        claims.put("sub", "test-user");
+        claims.put("exp", System.currentTimeMillis() / 1000 + 2); // expires 
in ~2 seconds
+        KeycloakTokenIntrospector.IntrospectionResult shortLived
+                = new KeycloakTokenIntrospector.IntrospectionResult(claims);
+
+        try {
+            longTtlCache.put("short-lived-token", shortLived);
+            assertNotNull(longTtlCache.get("short-lived-token"));
+
+            await().atMost(10, TimeUnit.SECONDS).until(() -> {
+                longTtlCache.getCaffeineCache().cleanUp();
+                return longTtlCache.get("short-lived-token") == null;
+            });
+        } finally {
+            longTtlCache.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
index d08fbeee74f9..6656995856c8 100644
--- 
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
@@ -18,11 +18,13 @@ package org.apache.camel.component.keycloak.security.cache;
 
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 import org.apache.camel.component.keycloak.security.KeycloakTokenIntrospector;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import static org.awaitility.Awaitility.await;
 import static org.junit.jupiter.api.Assertions.*;
 
 class ConcurrentMapTokenCacheTest {
@@ -172,4 +174,56 @@ class ConcurrentMapTokenCacheTest {
 
         assertEquals(threadCount, cache.size());
     }
+
+    @Test
+    void testExpiredResultNotServed() {
+        // A result whose token has already expired is not cached at all: 
put() rejects it up front via the
+        // isExpired() early-return, so get() returns null because no entry 
was ever inserted. The TTL bounding
+        // for a not-yet-expired token is covered by 
testResultExpiringBeforeTtlNotServedAfterExp.
+        Map<String, Object> claims = new HashMap<>();
+        claims.put("active", true);
+        claims.put("sub", "test-user");
+        claims.put("exp", System.currentTimeMillis() / 1000 - 60); // expired 
60 seconds ago
+        KeycloakTokenIntrospector.IntrospectionResult expired
+                = new KeycloakTokenIntrospector.IntrospectionResult(claims);
+
+        cache.put("expired-token", expired);
+
+        assertNull(cache.get("expired-token"));
+    }
+
+    @Test
+    void testResultWithFutureExpirationServed() {
+        Map<String, Object> claims = new HashMap<>();
+        claims.put("active", true);
+        claims.put("sub", "test-user");
+        claims.put("exp", System.currentTimeMillis() / 1000 + 300); // valid 
for 5 more minutes
+        KeycloakTokenIntrospector.IntrospectionResult valid
+                = new KeycloakTokenIntrospector.IntrospectionResult(claims);
+
+        cache.put("valid-token", valid);
+
+        KeycloakTokenIntrospector.IntrospectionResult retrieved = 
cache.get("valid-token");
+        assertNotNull(retrieved);
+        assertTrue(retrieved.isActive());
+    }
+
+    @Test
+    void testResultExpiringBeforeTtlNotServedAfterExp() {
+        // The token's exp lands inside the TTL window (~2s vs a 300s TTL), so 
the entry must expire at exp,
+        // not at the configured TTL. This exercises effectiveTtlMillis()'s 
min(ttl, remaining): replacing that
+        // with a plain ttlMillis would keep the entry served for 300s and 
fail this test.
+        ConcurrentMapTokenCache longTtlCache = new 
ConcurrentMapTokenCache(300);
+        Map<String, Object> claims = new HashMap<>();
+        claims.put("active", true);
+        claims.put("sub", "test-user");
+        claims.put("exp", System.currentTimeMillis() / 1000 + 2); // expires 
in ~2 seconds
+        KeycloakTokenIntrospector.IntrospectionResult shortLived
+                = new KeycloakTokenIntrospector.IntrospectionResult(claims);
+
+        longTtlCache.put("short-lived-token", shortLived);
+        assertNotNull(longTtlCache.get("short-lived-token"));
+
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
longTtlCache.get("short-lived-token") == null);
+    }
 }

Reply via email to