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);
+ }
}