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

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


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

commit 6be02c19c0259a983db5910e28deb4b3a00c9d22
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Oct 1 17:39:38 2026 +0200

    [camel-4.18.x] CAMEL-24902: camel-keycloak - honor token expiry (exp) when 
caching introspection results (#27183)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../src/main/docs/keycloak-component.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 +++++++++++++++++++
 8 files changed, 283 insertions(+), 5 deletions(-)

diff --git a/components/camel-keycloak/src/main/docs/keycloak-component.adoc 
b/components/camel-keycloak/src/main/docs/keycloak-component.adoc
index 8b27b38bb155..5450e8bce4b5 100644
--- a/components/camel-keycloak/src/main/docs/keycloak-component.adoc
+++ b/components/camel-keycloak/src/main/docs/keycloak-component.adoc
@@ -3651,7 +3651,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).
@@ -3920,7 +3920,7 @@ policy.setIntrospectionCacheTtl(30); // 30 seconds
 | 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 38da7d5d2ce4..ae061ee8afd4 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
@@ -107,6 +107,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);
             }
@@ -127,6 +129,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);
@@ -281,6 +296,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);
@@ -506,6 +523,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 61f6f1d62b4b..d4f5df43e8d2 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
@@ -252,6 +252,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