From 17b08eb301319f70fe34d4d775001bc2460eaa62 Mon Sep 17 00:00:00 2001 From: Colm O hEigeartaigh Date: Thu, 13 Aug 2026 11:48:11 +0100 Subject: [PATCH] CXF-9237 - Add possibility to configure JCache expiry time --- .../provider/JCacheOAuthDataProvider.java | 87 ++++++++++++++++--- .../provider/JCacheOAuthDataProviderTest.java | 83 ++++++++++++++++++ 2 files changed, 159 insertions(+), 11 deletions(-) diff --git a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/JCacheOAuthDataProvider.java b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/JCacheOAuthDataProvider.java index c3e63c3c38a..cacfe124f32 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/JCacheOAuthDataProvider.java +++ b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/provider/JCacheOAuthDataProvider.java @@ -24,11 +24,14 @@ import java.util.Iterator; import java.util.List; import java.util.Set; +import java.util.concurrent.TimeUnit; import javax.cache.Cache; import javax.cache.CacheManager; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; +import javax.cache.expiry.CreatedExpiryPolicy; +import javax.cache.expiry.Duration; import javax.cache.spi.CachingProvider; import org.apache.cxf.Bus; @@ -43,6 +46,13 @@ import static org.apache.cxf.jaxrs.utils.ResourceUtils.getClasspathResourceURL; +/** + * By default the client/access-token/refresh-token JCache caches are eternal at the cache + * infrastructure level: expired tokens are only ever removed when looked up (e.g. via + * {@link #getAccessToken}/{@link #getAccessTokens}), so entries that are never queried again + * accumulate for the life of the process. Pass a {@link CacheTTLs} to one of the constructors + * to have the JCache provider itself evict expired access/refresh token entries. + */ public class JCacheOAuthDataProvider extends AbstractOAuthDataProvider { public static final String CLIENT_CACHE_KEY = "cxf.oauth2.client.cache"; public static final String ACCESS_TOKEN_CACHE_KEY = "cxf.oauth2.accesstoken.cache"; @@ -86,18 +96,65 @@ public JCacheOAuthDataProvider(String configFileURL, String accessTokenCacheKey, String refreshTokenCacheKey, boolean storeJwtTokenKeyOnly) { + this(configFileURL, bus, clientCacheKey, accessTokenCacheKey, refreshTokenCacheKey, + storeJwtTokenKeyOnly, CacheTTLs.ETERNAL); + } + + // cacheTTLs lets the access/refresh token caches be evicted by the JCache infrastructure + // itself once entries age out, independently of any application-level expiry check; + // a TTL <= 0 (CacheTTLs.ETERNAL by default) leaves the corresponding cache eternal + public JCacheOAuthDataProvider(String configFileURL, + Bus bus, + String clientCacheKey, + String accessTokenCacheKey, + String refreshTokenCacheKey, + boolean storeJwtTokenKeyOnly, + CacheTTLs cacheTTLs) { cacheManager = createCacheManager(configFileURL, bus); - clientCache = createCache(cacheManager, clientCacheKey, String.class, Client.class); + // clients are persistent configuration, not time-bound tokens, so the cache stays eternal + clientCache = createCache(cacheManager, clientCacheKey, String.class, Client.class, -1); this.storeJwtTokenKeyOnly = storeJwtTokenKeyOnly; if (storeJwtTokenKeyOnly) { - jwtAccessTokenCache = createCache(cacheManager, accessTokenCacheKey, String.class, String.class); + jwtAccessTokenCache = createCache(cacheManager, accessTokenCacheKey, String.class, String.class, + cacheTTLs.getAccessTokenSeconds()); } else { - accessTokenCache = createCache(cacheManager, accessTokenCacheKey, String.class, ServerAccessToken.class); + accessTokenCache = createCache(cacheManager, accessTokenCacheKey, String.class, ServerAccessToken.class, + cacheTTLs.getAccessTokenSeconds()); + } + + refreshTokenCache = createCache(cacheManager, refreshTokenCacheKey, String.class, RefreshToken.class, + cacheTTLs.getRefreshTokenSeconds()); + } + + // immutable holder so the access/refresh token cache TTLs can be passed as a single + // constructor argument without exceeding the parameter-count limit + /** + * Cache-infrastructure-level time-to-live for the access/refresh token caches, in seconds. + * Not enabled by default: {@link #ETERNAL} (both values {@code -1}) means neither cache + * expires entries on its own, matching the pre-existing default behavior. A value {@code <= 0} + * for either field leaves that specific cache eternal, e.g. to match an eternal-by-default + * ({@code refreshTokenLifetime == 0}) refresh token configuration. + */ + public static final class CacheTTLs { + public static final CacheTTLs ETERNAL = new CacheTTLs(-1, -1); + + private final long accessTokenSeconds; + private final long refreshTokenSeconds; + + public CacheTTLs(long accessTokenSeconds, long refreshTokenSeconds) { + this.accessTokenSeconds = accessTokenSeconds; + this.refreshTokenSeconds = refreshTokenSeconds; + } + + public long getAccessTokenSeconds() { + return accessTokenSeconds; } - refreshTokenCache = createCache(cacheManager, refreshTokenCacheKey, String.class, RefreshToken.class); + public long getRefreshTokenSeconds() { + return refreshTokenSeconds; + } } @Override @@ -299,16 +356,24 @@ protected static CacheManager createCacheManager(String configFile, Bus bus) { protected static Cache createCache(CacheManager cacheManager, String cacheKey, Class keyType, Class valueType) { + return createCache(cacheManager, cacheKey, keyType, valueType, -1); + } + + protected static Cache createCache(CacheManager cacheManager, + String cacheKey, Class keyType, Class valueType, + long ttlSeconds) { Cache cache = cacheManager.getCache(cacheKey, keyType, valueType); if (cache == null) { - cache = cacheManager.createCache( - cacheKey, - new MutableConfiguration() - .setTypes(keyType, valueType) - .setStoreByValue(true) - .setStatisticsEnabled(false) - ); + MutableConfiguration configuration = new MutableConfiguration() + .setTypes(keyType, valueType) + .setStoreByValue(true) + .setStatisticsEnabled(false); + if (ttlSeconds > 0) { + configuration.setExpiryPolicyFactory( + CreatedExpiryPolicy.factoryOf(new Duration(TimeUnit.SECONDS, ttlSeconds))); + } + cache = cacheManager.createCache(cacheKey, configuration); } return cache; diff --git a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/provider/JCacheOAuthDataProviderTest.java b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/provider/JCacheOAuthDataProviderTest.java index 1b2cfc98e2a..bbd561aacaa 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/provider/JCacheOAuthDataProviderTest.java +++ b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/provider/JCacheOAuthDataProviderTest.java @@ -21,15 +21,22 @@ import java.util.Collections; import java.util.List; +import javax.cache.Cache; +import javax.cache.configuration.CompleteConfiguration; +import javax.cache.expiry.Duration; + +import org.apache.cxf.BusFactory; import org.apache.cxf.rs.security.oauth2.common.AccessTokenRegistration; import org.apache.cxf.rs.security.oauth2.common.Client; import org.apache.cxf.rs.security.oauth2.common.ServerAccessToken; +import org.apache.cxf.rs.security.oauth2.tokens.refresh.RefreshToken; import org.junit.Before; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; public class JCacheOAuthDataProviderTest extends AbstractOAuthDataProviderTest { @@ -61,4 +68,80 @@ public void testAddGetExpiredAccessToken() throws InterruptedException { getProvider().removeClient(c.getClientId()); } + + // Without an explicitly configured CacheTTLs, the access/refresh token caches stay eternal + // at the JCache infrastructure level (backwards-compatible default). + @Test + public void testCachesAreEternalByDefault() { + JCacheOAuthDataProvider provider = createIsolatedProvider("eternal", JCacheOAuthDataProvider.CacheTTLs.ETERNAL); + + assertEquals(Duration.ETERNAL, expiryForCreation(rawAccessTokenCache(provider, "eternal"))); + assertEquals(Duration.ETERNAL, expiryForCreation(rawRefreshTokenCache(provider, "eternal"))); + } + + // An access token that is never looked up again must still be reaped by the JCache + // infrastructure once the configured cache TTL elapses, independently of any + // application-level expiry check (CWE-400: otherwise abandoned entries never get evicted). + @Test + public void testUnaccessedExpiredAccessTokenEvictedFromCacheWhenTTLConfigured() throws InterruptedException { + JCacheOAuthDataProvider provider = createIsolatedProvider( + "ttl", new JCacheOAuthDataProvider.CacheTTLs(1 /* access token cache TTL, seconds */, -1)); + + Client c = addClient(provider, "103", "bob"); + AccessTokenRegistration atr = new AccessTokenRegistration(); + atr.setClient(c); + atr.setApprovedScope(Collections.singletonList("a")); + atr.setSubject(c.getResourceOwnerSubject()); + // long-lived at the app level: proves the eviction is cache-driven, not isExpired()-driven + provider.setAccessTokenLifetime(3600); + ServerAccessToken at = provider.createAccessToken(atr); + + Thread.sleep(2000); /* entry is never touched during this window */ + + assertNull("entry should have been evicted at the cache-infrastructure level", + rawAccessTokenCache(provider, "ttl").get(at.getTokenKey())); + } + + // the CacheManager and its caches are shared per config file, so each test needs its own + // cache names; the provider is deliberately not closed, as that would also close the + // CacheManager still in use by the provider under test + private static JCacheOAuthDataProvider createIsolatedProvider(String suffix, + JCacheOAuthDataProvider.CacheTTLs ttls) { + JCacheOAuthDataProvider provider = new JCacheOAuthDataProvider( + JCacheOAuthDataProvider.DEFAULT_CONFIG_URL, BusFactory.getThreadDefaultBus(true), + JCacheOAuthDataProvider.CLIENT_CACHE_KEY + '.' + suffix, + JCacheOAuthDataProvider.ACCESS_TOKEN_CACHE_KEY + '.' + suffix, + JCacheOAuthDataProvider.REFRESH_TOKEN_CACHE_KEY + '.' + suffix, + false, ttls); + initializeProvider(provider); + return provider; + } + + @SuppressWarnings("unchecked") + private static Duration expiryForCreation(Cache cache) { + CompleteConfiguration config = cache.getConfiguration(CompleteConfiguration.class); + return config.getExpiryPolicyFactory().create().getExpiryForCreation(); + } + + private static Cache rawAccessTokenCache(JCacheOAuthDataProvider provider, + String suffix) { + return provider.cacheManager.getCache( + JCacheOAuthDataProvider.ACCESS_TOKEN_CACHE_KEY + '.' + suffix, String.class, ServerAccessToken.class); + } + + private static Cache rawRefreshTokenCache(JCacheOAuthDataProvider provider, + String suffix) { + return provider.cacheManager.getCache( + JCacheOAuthDataProvider.REFRESH_TOKEN_CACHE_KEY + '.' + suffix, String.class, RefreshToken.class); + } + + private static Client addClient(JCacheOAuthDataProvider provider, String clientId, String userLogin) { + Client c = new Client(); + c.setRedirectUris(Collections.singletonList("http://client/redirect")); + c.setClientId(clientId); + c.setClientSecret("123"); + c.setResourceOwnerSubject(new org.apache.cxf.rs.security.oauth2.common.UserSubject(userLogin)); + provider.setClient(c); + return c; + } }