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

coheigea pushed a commit to branch 4.1.x-fixes
in repository https://gitbox.apache.org/repos/asf/cxf.git


The following commit(s) were added to refs/heads/4.1.x-fixes by this push:
     new 3b4c38908ac Reject SAML assertions that outlive the TokenReplayCache 
(#3696)
3b4c38908ac is described below

commit 3b4c38908ac57ad5fa494825fce1ac8898561672
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Mon Nov 9 16:30:48 2026 +0000

    Reject SAML assertions that outlive the TokenReplayCache (#3696)
    
    * Reject SAML assertions that outlive the TokenReplayCache
    
    * Read the max TTL of JCacheTokenReplayCache from its expiry policy
    
    (cherry picked from commit 6fa7ae7b22d7d7915d3129afac5fdc392a7077e3)
---
 .../security/oauth2/saml/SamlOAuthValidator.java   | 18 +++++
 .../oauth2/saml/SamlOAuthValidatorTest.java        | 81 ++++++++++++++++++-
 .../AbstractRequestAssertionConsumerHandler.java   | 11 +++
 .../security/saml/sso/EHCacheTokenReplayCache.java | 14 ++++
 .../saml/sso/SAMLSSOResponseValidator.java         | 19 +++++
 .../saml/sso/SAMLSSOResponseValidatorTest.java     | 54 +++++++++++++
 .../rs/security/saml/sso/TokenReplayCacheTest.java | 13 +++
 .../cxf/rs/security/common/TokenReplayCache.java   | 12 +++
 .../common/jcache/JCacheTokenReplayCache.java      | 91 ++++++++++++++++++---
 .../rs/security/common/TokenReplayCacheTest.java   | 58 ++++++++++++++
 .../common/jcache/JCacheTokenReplayCacheTest.java  | 93 ++++++++++++++++++++++
 .../src/test/resources/custom-replay-jcache.xml    | 43 ++++++++++
 12 files changed, 496 insertions(+), 11 deletions(-)

diff --git 
a/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidator.java
 
b/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidator.java
index 95f326cafa7..58512238eab 100644
--- 
a/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidator.java
+++ 
b/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidator.java
@@ -19,6 +19,7 @@
 
 package org.apache.cxf.rs.security.oauth2.saml;
 
+import java.time.Duration;
 import java.time.Instant;
 import java.util.List;
 
@@ -43,6 +44,7 @@ public class SamlOAuthValidator {
     private String clientAddress;
     private boolean subjectConfirmationDataRequired;
     private TokenReplayCache<String> replayCache;
+    private boolean allowAssertionsBeyondReplayCacheTTL;
     public SamlOAuthValidator() {
     }
 
@@ -76,6 +78,15 @@ public class SamlOAuthValidator {
         return replayCache;
     }
 
+    /**
+     * Whether to accept an assertion that is valid for longer than the replay 
cache can hold its ID (see
+     * TokenReplayCache#getMaxTTL()). Such an assertion can be replayed once 
its ID has expired from the
+     * cache, so the default is false.
+     */
+    public void setAllowAssertionsBeyondReplayCacheTTL(boolean 
allowAssertionsBeyondReplayCacheTTL) {
+        this.allowAssertionsBeyondReplayCacheTTL = 
allowAssertionsBeyondReplayCacheTTL;
+    }
+
     public void validate(Message message, SamlAssertionWrapper wrapper) {
         validateSAMLVersion(wrapper);
 
@@ -107,6 +118,13 @@ public class SamlOAuthValidator {
             return;
         }
         Instant expiry = getReplayCacheExpiry(cs, 
wrapper.getSaml2().getSubject());
+        // The cache only holds the ID for so long. An assertion that is valid 
for longer could be
+        // replayed once its ID has expired from the cache, so reject it
+        Duration maxTTL = replayCache.getMaxTTL();
+        if (!allowAssertionsBeyondReplayCacheTTL && maxTTL != null && expiry 
!= null
+            && expiry.isAfter(Instant.now().plus(maxTTL))) {
+            throw ExceptionUtils.toNotAuthorizedException(null, null);
+        }
         if (!replayCache.putIdIfAbsent(wrapper.getId(), expiry)) {
             throw ExceptionUtils.toNotAuthorizedException(null, null);
         }
diff --git 
a/rt/rs/security/oauth-parent/oauth2-saml/src/test/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidatorTest.java
 
b/rt/rs/security/oauth-parent/oauth2-saml/src/test/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidatorTest.java
index 46187b27935..ab8ef3fe15f 100644
--- 
a/rt/rs/security/oauth-parent/oauth2-saml/src/test/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidatorTest.java
+++ 
b/rt/rs/security/oauth-parent/oauth2-saml/src/test/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidatorTest.java
@@ -136,15 +136,78 @@ public class SamlOAuthValidatorTest {
         validator.validate(null, wrapper);
     }
 
+    // An assertion that is valid for longer than the replay cache can hold 
its ID could be replayed once
+    // the ID has expired from the cache, so it is rejected
+    @Test
+    public void testAssertionValidBeyondReplayCacheTTLIsRejected() throws 
Exception {
+        String tokenServiceAddress = "https://service.example.org/token";;
+
+        SamlOAuthValidator validator = new SamlOAuthValidator();
+        validator.setAccessTokenServiceAddress(tokenServiceAddress);
+        TestTokenReplayCache replayCache = new 
TestTokenReplayCache(Duration.ofHours(12));
+        validator.setReplayCache(replayCache);
+
+        SamlAssertionWrapper wrapper = createAssertion(tokenServiceAddress, 
null, Duration.ofHours(24));
+        try {
+            validator.validate(null, wrapper);
+            fail("Expected failure as the assertion is valid for longer than 
the replay cache holds it");
+        } catch (NotAuthorizedException ex) {
+            // expected
+        }
+        assertFalse(replayCache.contains(wrapper.getId()));
+
+        // An assertion that the cache can hold is accepted
+        validator.validate(null, createAssertion(tokenServiceAddress, null));
+    }
+
+    @Test
+    public void testAssertionValidBeyondReplayCacheTTLIsAllowed() throws 
Exception {
+        String tokenServiceAddress = "https://service.example.org/token";;
+
+        SamlOAuthValidator validator = new SamlOAuthValidator();
+        validator.setAccessTokenServiceAddress(tokenServiceAddress);
+        validator.setReplayCache(new 
TestTokenReplayCache(Duration.ofHours(12)));
+        validator.setAllowAssertionsBeyondReplayCacheTTL(true);
+
+        SamlAssertionWrapper wrapper = createAssertion(tokenServiceAddress, 
null, Duration.ofHours(24));
+        validator.validate(null, wrapper);
+
+        // A replay is still detected
+        try {
+            validator.validate(null, wrapper);
+            fail("Expected failure on replayed assertion");
+        } catch (NotAuthorizedException ex) {
+            // expected
+        }
+    }
+
+    // A replay cache that does not say how long it holds an ID for is not 
checked
+    @Test
+    public void testAssertionValidBeyondUnknownReplayCacheTTL() throws 
Exception {
+        String tokenServiceAddress = "https://service.example.org/token";;
+
+        SamlOAuthValidator validator = new SamlOAuthValidator();
+        validator.setAccessTokenServiceAddress(tokenServiceAddress);
+        validator.setReplayCache(new TestTokenReplayCache());
+
+        validator.validate(null, createAssertion(tokenServiceAddress, null, 
Duration.ofHours(24)));
+    }
+
     private SamlAssertionWrapper createAssertion(String recipient, String 
clientAddress) throws Exception {
+        return createAssertion(recipient, clientAddress, 
Duration.ofMinutes(5));
+    }
+
+    private SamlAssertionWrapper createAssertion(
+        String recipient, String clientAddress, Duration validity
+    ) throws Exception {
         SubjectConfirmationDataBean confirmationData = new 
SubjectConfirmationDataBean();
         confirmationData.setRecipient(recipient);
         confirmationData.setAddress(clientAddress);
-        
confirmationData.setNotAfter(Instant.now().plus(Duration.ofMinutes(5)));
+        confirmationData.setNotAfter(Instant.now().plus(validity));
 
         ConditionsBean conditions = new ConditionsBean();
         conditions.setNotBefore(Instant.now().minusSeconds(30));
-        conditions.setNotAfter(Instant.now().plus(Duration.ofMinutes(5)));
+        conditions.setNotAfter(Instant.now().plus(validity));
         AudienceRestrictionBean audienceRestriction = new 
AudienceRestrictionBean();
         
audienceRestriction.setAudienceURIs(Collections.singletonList(recipient));
         
conditions.setAudienceRestrictions(Collections.singletonList(audienceRestriction));
@@ -156,6 +219,20 @@ public class SamlOAuthValidatorTest {
 
     private static final class TestTokenReplayCache implements 
TokenReplayCache<String> {
         private final Set<String> ids = ConcurrentHashMap.newKeySet();
+        private final Duration maxTTL;
+
+        TestTokenReplayCache() {
+            this(null);
+        }
+
+        TestTokenReplayCache(Duration maxTTL) {
+            this.maxTTL = maxTTL;
+        }
+
+        @Override
+        public Duration getMaxTTL() {
+            return maxTTL;
+        }
 
         @Override
         public boolean contains(String id) {
diff --git 
a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/AbstractRequestAssertionConsumerHandler.java
 
b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/AbstractRequestAssertionConsumerHandler.java
index aa0fde871e7..643aac9795c 100644
--- 
a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/AbstractRequestAssertionConsumerHandler.java
+++ 
b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/AbstractRequestAssertionConsumerHandler.java
@@ -70,6 +70,7 @@ public abstract class AbstractRequestAssertionConsumerHandler 
extends AbstractSS
     private boolean keyInfoMustBeAvailable = true;
     private boolean checkClientAddress = true;
     private boolean enforceResponseSigned;
+    private boolean allowAssertionsBeyondReplayCacheTTL;
     private TokenReplayCache<String> replayCache;
 
     private MessageContext messageContext;
@@ -116,6 +117,15 @@ public abstract class 
AbstractRequestAssertionConsumerHandler extends AbstractSS
         this.enforceKnownIssuer = enforceKnownIssuer;
     }
 
+    /**
+     * Whether to accept an Assertion that is valid for longer than the replay 
cache can hold its
+     * identifier (see TokenReplayCache#getMaxTTL()). Such an Assertion can be 
replayed once its identifier
+     * has expired from the cache, so the default is false.
+     */
+    public void setAllowAssertionsBeyondReplayCacheTTL(boolean 
allowAssertionsBeyondReplayCacheTTL) {
+        this.allowAssertionsBeyondReplayCacheTTL = 
allowAssertionsBeyondReplayCacheTTL;
+    }
+
     public void setSupportBase64Encoding(boolean supportBase64Encoding) {
         this.supportBase64Encoding = supportBase64Encoding;
     }
@@ -357,6 +367,7 @@ public abstract class 
AbstractRequestAssertionConsumerHandler extends AbstractSS
             
ssoResponseValidator.setEnforceResponseSigned(enforceResponseSigned);
             ssoResponseValidator.setEnforceKnownIssuer(enforceKnownIssuer);
             ssoResponseValidator.setReplayCache(getReplayCache());
+            
ssoResponseValidator.setAllowAssertionsBeyondReplayCacheTTL(allowAssertionsBeyondReplayCacheTTL);
 
             return ssoResponseValidator.validateSamlResponse(samlResponse, 
postBinding);
         } catch (Exception ex) {
diff --git 
a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/EHCacheTokenReplayCache.java
 
b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/EHCacheTokenReplayCache.java
index c7a51872f14..69225a4946b 100644
--- 
a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/EHCacheTokenReplayCache.java
+++ 
b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/EHCacheTokenReplayCache.java
@@ -22,12 +22,14 @@ package org.apache.cxf.rs.security.saml.sso;
 import java.io.File;
 import java.io.IOException;
 import java.net.URL;
+import java.time.Duration;
 import java.time.Instant;
 import java.util.Random;
 
 import org.apache.cxf.Bus;
 import org.apache.cxf.BusFactory;
 import org.apache.cxf.jaxrs.utils.ResourceUtils;
+import org.apache.wss4j.common.cache.EHCacheExpiry;
 import org.apache.wss4j.common.cache.EHCacheReplayCache;
 import org.apache.wss4j.common.cache.EHCacheValue;
 import org.apache.wss4j.common.util.Loader;
@@ -162,6 +164,18 @@ public class EHCacheTokenReplayCache implements 
TokenReplayCache<String> {
         return element != null;
     }
 
+    /**
+     * Return the maximum time for which this cache holds an identifier: the 
max TTL of EHCacheExpiry if
+     * the cache uses it (as the default configuration does), or null (not 
known) otherwise
+     */
+    @Override
+    public Duration getMaxTTL() {
+        if (cache.getRuntimeConfiguration().getExpiryPolicy() instanceof 
EHCacheExpiry) {
+            return Duration.ofSeconds(EHCacheExpiry.MAX_TTL);
+        }
+        return null;
+    }
+
     public synchronized void close() {
         if (cacheManager.getStatus() == Status.AVAILABLE) {
             cacheManager.removeCache(CACHE_KEY);
diff --git 
a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java
 
b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java
index 889539e1aa0..1830abf97d0 100644
--- 
a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java
+++ 
b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java
@@ -19,6 +19,7 @@
 package org.apache.cxf.rs.security.saml.sso;
 
 import java.net.URI;
+import java.time.Duration;
 import java.time.Instant;
 import java.util.List;
 import java.util.logging.Logger;
@@ -50,6 +51,7 @@ public class SAMLSSOResponseValidator {
     private boolean enforceKnownIssuer = true;
     private boolean enforceStrictIssuerMatch;
     private TokenReplayCache<String> replayCache;
+    private boolean allowAssertionsBeyondReplayCacheTTL;
 
     /**
      * Enforce that Assertions contained in the Response must be signed (if 
the Response itself is not
@@ -303,6 +305,14 @@ public class SAMLSSOResponseValidator {
         // applies to every binding, not just POST, as a Response received via 
GET can be replayed too
         if (replayCache != null) {
             Instant expires = subjectConfData.getNotOnOrAfter();
+            // The cache only holds the identifier for so long. An Assertion 
that is valid for longer
+            // could be replayed once its identifier has expired from the 
cache, so reject it
+            Duration maxTTL = replayCache.getMaxTTL();
+            if (!allowAssertionsBeyondReplayCacheTTL && maxTTL != null
+                && expires.isAfter(Instant.now().plus(maxTTL))) {
+                LOG.warning("The Assertion " + id + " is valid for longer than 
the replay cache can hold it");
+                throw new 
WSSecurityException(WSSecurityException.ErrorCode.FAILURE, 
"invalidSAMLsecurity");
+            }
             if (!replayCache.putIdIfAbsent(id, expires)) {
                 LOG.warning("Replay attack with token id: " + id);
                 throw new 
WSSecurityException(WSSecurityException.ErrorCode.FAILURE, 
"invalidSAMLsecurity");
@@ -419,6 +429,15 @@ public class SAMLSSOResponseValidator {
         this.replayCache = replayCache;
     }
 
+    /**
+     * Whether to accept an Assertion that is valid for longer than the replay 
cache can hold its
+     * identifier (see TokenReplayCache#getMaxTTL()). Such an Assertion can be 
replayed once its identifier
+     * has expired from the cache, so the default is false.
+     */
+    public void setAllowAssertionsBeyondReplayCacheTTL(boolean 
allowAssertionsBeyondReplayCacheTTL) {
+        this.allowAssertionsBeyondReplayCacheTTL = 
allowAssertionsBeyondReplayCacheTTL;
+    }
+
     public boolean isEnforceResponseSigned() {
         return enforceResponseSigned;
     }
diff --git 
a/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidatorTest.java
 
b/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidatorTest.java
index 5a033a1e8f3..398f5fa3c9b 100644
--- 
a/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidatorTest.java
+++ 
b/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidatorTest.java
@@ -781,6 +781,60 @@ public class SAMLSSOResponseValidatorTest {
         }
     }
 
+    // An assertion that is valid for longer than the replay cache can hold 
its id could be replayed once
+    // the id has expired from the cache, so it is rejected
+    @org.junit.Test
+    public void testAssertionValidBeyondReplayCacheTTLIsRejected() throws 
Exception {
+        Response response = 
createResponse(createLongLivedSubjectConfirmationData());
+
+        TokenReplayCache<String> replayCache = new EHCacheTokenReplayCache();
+        try {
+            SAMLSSOResponseValidator validator = 
createReplayValidator(replayCache);
+            try {
+                validator.validateSamlResponse(response, true);
+                fail("Expected failure as the assertion is valid for longer 
than the replay cache holds it");
+            } catch (WSSecurityException ex) {
+                // expected
+            }
+        } finally {
+            replayCache.close();
+        }
+    }
+
+    @org.junit.Test
+    public void testAssertionValidBeyondReplayCacheTTLIsAllowed() throws 
Exception {
+        Response response = 
createResponse(createLongLivedSubjectConfirmationData());
+
+        TokenReplayCache<String> replayCache = new EHCacheTokenReplayCache();
+        try {
+            SAMLSSOResponseValidator validator = 
createReplayValidator(replayCache);
+            validator.setAllowAssertionsBeyondReplayCacheTTL(true);
+
+            SSOValidatorResponse validateSamlResponse = 
validator.validateSamlResponse(response, true);
+            assertEquals(response.getID(), 
validateSamlResponse.getResponseId());
+
+            // A replay is still detected
+            try {
+                validator.validateSamlResponse(copyResponse(response), true);
+                fail("Expected failure on replayed response");
+            } catch (WSSecurityException ex) {
+                // expected
+            }
+        } finally {
+            replayCache.close();
+        }
+    }
+
+    // Valid for 24 hours, longer than the max TTL of 12 hours of the default 
replay cache
+    private static SubjectConfirmationDataBean 
createLongLivedSubjectConfirmationData() {
+        SubjectConfirmationDataBean subjectConfirmationData = new 
SubjectConfirmationDataBean();
+        subjectConfirmationData.setAddress("http://apache.org";);
+        subjectConfirmationData.setInResponseTo("12345");
+        
subjectConfirmationData.setNotAfter(Instant.now().plus(Duration.ofHours(24)));
+        subjectConfirmationData.setRecipient("http://recipient.apache.org";);
+        return subjectConfirmationData;
+    }
+
     private SAMLSSOResponseValidator 
createReplayValidator(TokenReplayCache<String> replayCache) {
         SAMLSSOResponseValidator validator = new SAMLSSOResponseValidator();
         validator.setEnforceAssertionsSigned(false);
diff --git 
a/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/TokenReplayCacheTest.java
 
b/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/TokenReplayCacheTest.java
index 8850fe5a803..b98fdc10408 100644
--- 
a/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/TokenReplayCacheTest.java
+++ 
b/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/TokenReplayCacheTest.java
@@ -19,6 +19,7 @@
 
 package org.apache.cxf.rs.security.saml.sso;
 
+import java.time.Duration;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
@@ -34,6 +35,7 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicIntegerArray;
 
 import org.apache.cxf.rs.security.saml.sso.jcache.JCacheTokenReplayCache;
+import org.apache.wss4j.common.cache.EHCacheExpiry;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -179,6 +181,17 @@ public class TokenReplayCacheTest {
         }
     }
 
+    // The default configuration holds an identifier for at most the max TTL 
of EHCacheExpiry
+    @Test
+    public void testMaxTTL() throws Exception {
+        TokenReplayCache<String> replayCache = createCache();
+        try {
+            assertEquals(Duration.ofSeconds(EHCacheExpiry.MAX_TTL), 
replayCache.getMaxTTL());
+        } finally {
+            replayCache.close();
+        }
+    }
+
     private void testTokenReplayCacheInstance(TokenReplayCache<String> 
replayCache) throws InterruptedException {
 
         // Test default TTL caches OK
diff --git 
a/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/TokenReplayCache.java
 
b/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/TokenReplayCache.java
index 94c9cb80648..06cd80397ff 100644
--- 
a/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/TokenReplayCache.java
+++ 
b/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/TokenReplayCache.java
@@ -21,6 +21,7 @@ package org.apache.cxf.rs.security.common;
 
 import java.io.Closeable;
 import java.io.IOException;
+import java.time.Duration;
 import java.time.Instant;
 
 /**
@@ -59,5 +60,16 @@ public interface TokenReplayCache<T> extends Closeable {
         }
     }
 
+    /**
+     * Return the maximum time for which this cache holds an identifier. An 
identifier whose token is
+     * valid for longer than this can be replayed once it has expired from the 
cache, so a replay check
+     * should reject such a token. An identifier may still be evicted sooner 
if the cache is full.
+     * @return the maximum time for which this cache holds an identifier, or 
null if this is not known, or
+     *         if the cache does not expire identifiers
+     */
+    default Duration getMaxTTL() {
+        return null;
+    }
+
     void close() throws IOException;
 }
diff --git 
a/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/jcache/JCacheTokenReplayCache.java
 
b/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/jcache/JCacheTokenReplayCache.java
index 12502eccd4c..063391b964c 100644
--- 
a/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/jcache/JCacheTokenReplayCache.java
+++ 
b/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/jcache/JCacheTokenReplayCache.java
@@ -23,14 +23,20 @@ import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
+import java.time.Duration;
 import java.time.Instant;
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
 import javax.cache.Cache;
 import javax.cache.CacheManager;
 import javax.cache.Caching;
+import javax.cache.configuration.CompleteConfiguration;
 import javax.cache.configuration.MutableConfiguration;
+import javax.cache.expiry.CreatedExpiryPolicy;
+import javax.cache.expiry.ExpiryPolicy;
 import javax.cache.spi.CachingProvider;
 
 import org.apache.cxf.Bus;
@@ -38,12 +44,15 @@ import org.apache.cxf.BusFactory;
 import org.apache.cxf.common.logging.LogUtils;
 import org.apache.cxf.jaxrs.utils.ResourceUtils;
 import org.apache.cxf.rs.security.common.TokenReplayCache;
+import org.apache.wss4j.common.cache.EHCacheExpiry;
 import org.apache.wss4j.common.cache.EHCacheValue;
 import org.apache.wss4j.common.util.Loader;
 
 /**
  * A JCache based implementation of the TokenReplayCache interface. The 
default configuration uses
- * Ehcache as the JCache provider, with a default TTL of 60 minutes and a max 
TTL of 12 hours.
+ * Ehcache as the JCache provider, with a default TTL of 60 minutes and a max 
TTL of 12 hours. If the
+ * JCache provider does not define the cache (for example, a provider other 
than Ehcache, which cannot
+ * read the default configuration), it is created with a fixed TTL of 12 hours.
  */
 public class JCacheTokenReplayCache implements TokenReplayCache<String> {
 
@@ -55,6 +64,7 @@ public class JCacheTokenReplayCache implements 
TokenReplayCache<String> {
     private final String cacheKey;
     private final Cache<String, EHCacheValue> cache;
     private final CacheManager cacheManager;
+    private final Duration maxTTL;
 
     public JCacheTokenReplayCache() throws URISyntaxException {
         this(DEFAULT_CONFIG_URL, null);
@@ -99,19 +109,71 @@ public class JCacheTokenReplayCache implements 
TokenReplayCache<String> {
             ? resolvedConfigFileURL.toURI() : cachingProvider.getDefaultURI();
         cacheManager = cachingProvider.getCacheManager(configURI, 
getClass().getClassLoader());
 
-        cache = getOrCreate(cacheManager, cacheKey, String.class, 
EHCacheValue.class);
+        Cache<String, EHCacheValue> configuredCache =
+            cacheManager.getCache(cacheKey, String.class, EHCacheValue.class);
+        cache = configuredCache != null ? configuredCache : 
createCache(cacheManager, cacheKey);
+
+        // The max TTL is read from the JCache expiry policy of the cache, if 
the provider can return it.
+        // Ehcache cannot for a cache whose expiry comes from its own 
configuration, but the default
+        // configuration is known to use EHCacheExpiry, which holds an entry 
for at most its max TTL. How
+        // long a cache defined by some other configuration holds an entry for 
is not known.
+        ExpiryPolicy expiryPolicy = getExpiryPolicy(cache);
+        if (expiryPolicy != null) {
+            maxTTL = getMaxTTL(expiryPolicy);
+        } else {
+            boolean defaultConfig = configFileURL == null || 
defaultConfigFile.equals(configFile);
+            maxTTL = defaultConfig ? Duration.ofSeconds(EHCacheExpiry.MAX_TTL) 
: null;
+        }
     }
 
-    private static <K, V> Cache<K, V> getOrCreate(CacheManager cacheManager, 
String name,
-            Class<K> kclass, Class<V> vclass) {
+    /**
+     * Return the JCache expiry policy of the cache, or null if the JCache 
provider cannot return it
+     * (Ehcache cannot, for a cache whose expiry comes from its own 
configuration)
+     */
+    private static ExpiryPolicy getExpiryPolicy(Cache<String, EHCacheValue> 
cache) {
+        try {
+            @SuppressWarnings("unchecked")
+            CompleteConfiguration<String, EHCacheValue> configuration =
+                cache.getConfiguration(CompleteConfiguration.class);
+            return configuration.getExpiryPolicyFactory().create();
+        } catch (RuntimeException ex) {
+            LOG.log(Level.FINE, "The expiry policy of the replay cache is not 
available", ex);
+            return null;
+        }
+    }
 
-        final Cache<K, V> cache = cacheManager.getCache(name, kclass, vclass);
-        if (cache != null) {
-            return cache;
+    /**
+     * Return the maximum time for which a cache with the given expiry policy 
holds an entry: the shortest
+     * of the durations the policy sets when an entry is created, accessed or 
updated (as an access or an
+     * update can shorten how long an entry is held), or null if the policy 
does not expire entries.
+     */
+    static Duration getMaxTTL(ExpiryPolicy expiryPolicy) {
+        Duration maxTTL = null;
+        for (javax.cache.expiry.Duration duration : 
Arrays.asList(expiryPolicy.getExpiryForCreation(),
+            expiryPolicy.getExpiryForAccess(), 
expiryPolicy.getExpiryForUpdate())) {
+            // A null duration (on access or update) leaves the expiry of the 
entry unchanged
+            if (duration != null && !duration.isEternal()) {
+                Duration ttl =
+                    
Duration.ofMillis(duration.getTimeUnit().toMillis(duration.getDurationAmount()));
+                if (maxTTL == null || ttl.compareTo(maxTTL) < 0) {
+                    maxTTL = ttl;
+                }
+            }
         }
+        return maxTTL;
+    }
 
-        final MutableConfiguration<K, V> cacheConfiguration = new 
MutableConfiguration<>();
-        cacheConfiguration.setTypes(kclass, vclass);
+    /**
+     * Create the cache, if the JCache provider does not define it. A 
configuration (such as the default
+     * one) can still supply its settings, including its expiry, through a 
template for the cache name.
+     * Without one, every entry is held for the max TTL (JCache has no 
per-entry expiry), rather than
+     * forever.
+     */
+    private static Cache<String, EHCacheValue> createCache(CacheManager 
cacheManager, String name) {
+        final MutableConfiguration<String, EHCacheValue> cacheConfiguration = 
new MutableConfiguration<>();
+        cacheConfiguration.setTypes(String.class, EHCacheValue.class);
+        
cacheConfiguration.setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(
+            new javax.cache.expiry.Duration(TimeUnit.SECONDS, 
EHCacheExpiry.MAX_TTL)));
 
         return cacheManager.createCache(name, cacheConfiguration);
     }
@@ -183,6 +245,17 @@ public class JCacheTokenReplayCache implements 
TokenReplayCache<String> {
         return element != null;
     }
 
+    /**
+     * Return the maximum time for which this cache holds an identifier, as 
read from its JCache expiry
+     * policy, or the max TTL of 12 hours with the default configuration 
(whose expiry Ehcache cannot
+     * return as a JCache expiry policy). It is null if this is not known, or 
if the cache does not expire
+     * identifiers.
+     */
+    @Override
+    public Duration getMaxTTL() {
+        return maxTTL;
+    }
+
     public synchronized void close() {
         if (!cacheManager.isClosed()) {
             cacheManager.destroyCache(cacheKey);
diff --git 
a/rt/rs/security/xml/src/test/java/org/apache/cxf/rs/security/common/TokenReplayCacheTest.java
 
b/rt/rs/security/xml/src/test/java/org/apache/cxf/rs/security/common/TokenReplayCacheTest.java
index f761ded111e..f8967954768 100644
--- 
a/rt/rs/security/xml/src/test/java/org/apache/cxf/rs/security/common/TokenReplayCacheTest.java
+++ 
b/rt/rs/security/xml/src/test/java/org/apache/cxf/rs/security/common/TokenReplayCacheTest.java
@@ -19,6 +19,7 @@
 
 package org.apache.cxf.rs.security.common;
 
+import java.time.Duration;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
@@ -34,6 +35,7 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicIntegerArray;
 
 import org.apache.cxf.rs.security.common.jcache.JCacheTokenReplayCache;
+import org.apache.wss4j.common.cache.EHCacheExpiry;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -41,6 +43,7 @@ import org.junit.runners.Parameterized;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
 /**
@@ -163,6 +166,61 @@ public class TokenReplayCacheTest {
         }
     }
 
+    // The default configuration holds an identifier for at most the max TTL 
of EHCacheExpiry
+    @Test
+    public void testMaxTTL() throws Exception {
+        TokenReplayCache<String> replayCache = createCache();
+        try {
+            assertEquals(Duration.ofSeconds(EHCacheExpiry.MAX_TTL), 
replayCache.getMaxTTL());
+        } finally {
+            replayCache.close();
+        }
+    }
+
+    // A cache that the configuration does not define is created with a fixed 
TTL of the max TTL
+    @Test
+    public void testMaxTTLOfCreatedCache() throws Exception {
+        TokenReplayCache<String> replayCache = new JCacheTokenReplayCache(
+            "cxf.rs.security.replay.cache.test-" + UUID.randomUUID(),
+            "/cxf-rs-security-replay-jcache.xml", 
"/cxf-rs-security-replay-jcache.xml", null) { };
+        try {
+            assertEquals(Duration.ofSeconds(EHCacheExpiry.MAX_TTL), 
replayCache.getMaxTTL());
+
+            String id = UUID.randomUUID().toString();
+            assertTrue(replayCache.putIdIfAbsent(id, null));
+            assertTrue(replayCache.contains(id));
+            assertFalse(replayCache.putIdIfAbsent(id, null));
+        } finally {
+            replayCache.close();
+        }
+    }
+
+    // The max TTL of a cache that a configuration does not define is read 
from the expiry policy it is
+    // created with, whatever the configuration
+    @Test
+    public void testMaxTTLReadFromExpiryPolicy() throws Exception {
+        TokenReplayCache<String> replayCache = new JCacheTokenReplayCache(
+            "cxf.rs.security.replay.cache.test-" + UUID.randomUUID(),
+            "/custom-replay-jcache.xml", "/cxf-rs-security-replay-jcache.xml", 
null) { };
+        try {
+            assertEquals(Duration.ofSeconds(EHCacheExpiry.MAX_TTL), 
replayCache.getMaxTTL());
+        } finally {
+            replayCache.close();
+        }
+    }
+
+    // How long a cache defined by a configuration other than the default one 
holds an entry for is not
+    // known (Ehcache cannot return its expiry as a JCache expiry policy)
+    @Test
+    public void testMaxTTLOfCustomConfiguration() throws Exception {
+        TokenReplayCache<String> replayCache = new 
JCacheTokenReplayCache("/custom-replay-jcache.xml");
+        try {
+            assertNull(replayCache.getMaxTTL());
+        } finally {
+            replayCache.close();
+        }
+    }
+
     private TokenReplayCache<String> createCache() throws Exception {
         return tokenReplayCacheClass.getDeclaredConstructor().newInstance();
     }
diff --git 
a/rt/rs/security/xml/src/test/java/org/apache/cxf/rs/security/common/jcache/JCacheTokenReplayCacheTest.java
 
b/rt/rs/security/xml/src/test/java/org/apache/cxf/rs/security/common/jcache/JCacheTokenReplayCacheTest.java
new file mode 100644
index 00000000000..8a1d55d786e
--- /dev/null
+++ 
b/rt/rs/security/xml/src/test/java/org/apache/cxf/rs/security/common/jcache/JCacheTokenReplayCacheTest.java
@@ -0,0 +1,93 @@
+/**
+ * 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.cxf.rs.security.common.jcache;
+
+import java.util.concurrent.TimeUnit;
+
+import javax.cache.expiry.AccessedExpiryPolicy;
+import javax.cache.expiry.CreatedExpiryPolicy;
+import javax.cache.expiry.Duration;
+import javax.cache.expiry.EternalExpiryPolicy;
+import javax.cache.expiry.ExpiryPolicy;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+/**
+ * Some unit tests for how JCacheTokenReplayCache works out its max TTL from a 
JCache expiry policy
+ */
+public class JCacheTokenReplayCacheTest {
+
+    @Test
+    public void testCreatedExpiryPolicy() {
+        assertEquals(java.time.Duration.ofHours(12), 
JCacheTokenReplayCache.getMaxTTL(
+            CreatedExpiryPolicy.factoryOf(new Duration(TimeUnit.HOURS, 
12)).create()));
+    }
+
+    // An access only extends how long an entry is held, so the creation 
duration is the max TTL
+    @Test
+    public void testAccessedExpiryPolicy() {
+        assertEquals(java.time.Duration.ofMinutes(5), 
JCacheTokenReplayCache.getMaxTTL(
+            AccessedExpiryPolicy.factoryOf(new Duration(TimeUnit.MINUTES, 
5)).create()));
+    }
+
+    // A cache that does not expire entries has no max TTL
+    @Test
+    public void testEternalExpiryPolicy() {
+        
assertNull(JCacheTokenReplayCache.getMaxTTL(EternalExpiryPolicy.factoryOf().create()));
+    }
+
+    // An entry can be held for less than its creation duration if an access 
or an update shortens it
+    @Test
+    public void testShortenedOnAccessOrUpdate() {
+        assertEquals(java.time.Duration.ofMinutes(10), 
JCacheTokenReplayCache.getMaxTTL(
+            new TestExpiryPolicy(new Duration(TimeUnit.HOURS, 1), null, new 
Duration(TimeUnit.MINUTES, 10))));
+        assertEquals(java.time.Duration.ZERO, JCacheTokenReplayCache.getMaxTTL(
+            new TestExpiryPolicy(new Duration(TimeUnit.HOURS, 1), 
Duration.ZERO, null)));
+    }
+
+    private static final class TestExpiryPolicy implements ExpiryPolicy {
+        private final Duration creation;
+        private final Duration access;
+        private final Duration update;
+
+        TestExpiryPolicy(Duration creation, Duration access, Duration update) {
+            this.creation = creation;
+            this.access = access;
+            this.update = update;
+        }
+
+        @Override
+        public Duration getExpiryForCreation() {
+            return creation;
+        }
+
+        @Override
+        public Duration getExpiryForAccess() {
+            return access;
+        }
+
+        @Override
+        public Duration getExpiryForUpdate() {
+            return update;
+        }
+    }
+}
diff --git a/rt/rs/security/xml/src/test/resources/custom-replay-jcache.xml 
b/rt/rs/security/xml/src/test/resources/custom-replay-jcache.xml
new file mode 100644
index 00000000000..c80469a8728
--- /dev/null
+++ b/rt/rs/security/xml/src/test/resources/custom-replay-jcache.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0"?>
+<!--
+  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.
+-->
+<config
+        xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
+        xmlns='http://www.ehcache.org/v3'
+        xmlns:jsr107='http://www.ehcache.org/v3/jsr107'
+        xsi:schemaLocation="http://www.ehcache.org/v3 
http://www.ehcache.org/schema/ehcache-core-3.8.xsd
+            http://www.ehcache.org/v3/jsr107 
http://www.ehcache.org/schema/ehcache-107-ext-3.0.xsd";>
+    <service>
+        <jsr107:defaults>
+            <jsr107:cache name="cxf.rs.security.replay.cache" 
template="cxf.rs.security.replay.cache.template"/>
+        </jsr107:defaults>
+    </service>
+    <persistence 
directory="${java.io.tmpdir}/ehcache-cxf-rs-security-replay-custom/" />
+    <cache-template name="cxf.rs.security.replay.cache.template">
+        <key-type>java.lang.String</key-type>
+        <value-type>org.apache.wss4j.common.cache.EHCacheValue</value-type>
+        <expiry>
+            <ttl unit="seconds">300</ttl>
+        </expiry>
+        <resources>
+            <heap unit="entries">5000</heap>
+            <disk unit="MB" persistent="false">10</disk>
+        </resources>
+    </cache-template>
+</config>

Reply via email to