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

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git

commit b0c2630caaecfd205130d8c4f3bc13e706be4944
Author: James Bognar <[email protected]>
AuthorDate: Sun Aug 16 13:40:18 2026 -0400

    READY-378: Fail closed when SAML assertions lack a NotOnOrAfter bound
    
    An assertion with no NotOnOrAfter had no replay-expiry bound, so its 
replay-cache
    record could be evicted early and the assertion replayed. Reject such 
unbounded
    assertions and retain replay records until the resolved NotOnOrAfter + 
clock skew.
---
 .../server/auth/saml/SamlAssertionValidator.java   |  51 ++++--
 .../saml/SamlAssertionValidator_Branches_Test.java |  12 +-
 .../SamlAssertionValidator_ReplayExpiry_Test.java  | 202 +++++++++++++++++++++
 3 files changed, 247 insertions(+), 18 deletions(-)

diff --git 
a/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator.java
 
b/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator.java
index 70cef30352..62044f1d9c 100644
--- 
a/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator.java
+++ 
b/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator.java
@@ -79,7 +79,10 @@ import net.shibboleth.shared.resolver.*;
  *             {@code InResponseTo}/{@code Address} expectations.
  *     <li><b>One-time use</b> &mdash; each assertion ID is recorded in a 
{@link ReplayCache} and a second
  *             presentation of the same ID is rejected.  The check is 
fail-closed: if the cache cannot answer, the
- *             assertion is rejected.
+ *             assertion is rejected.  An assertion whose validity window is 
unbounded &mdash; carrying neither a
+ *             {@code Conditions/NotOnOrAfter} nor a bearer {@code 
SubjectConfirmationData/NotOnOrAfter} &mdash; is
+ *             rejected outright, and the cache record is retained until the 
resolved {@code NotOnOrAfter} (plus clock
+ *             skew) rather than a short default window that would later 
expire and re-open replay.
  *     <li><b>Encrypted assertions</b> &mdash; if the response contains
  *             {@code <EncryptedAssertion>}, a {@code decryptionCredential} 
must be configured.
  * </ul>
@@ -134,12 +137,6 @@ public class SamlAssertionValidator {
        /** XML-DSig namespace URI, used when scanning the signature DOM for 
reference digest methods. */
        private static final String XMLSIG_NS = 
"http://www.w3.org/2000/09/xmldsig#";;
 
-       /**
-        * Retention window applied to a one-time-use cache entry when the 
assertion carries no
-        * {@code Conditions/NotOnOrAfter} to bound it (5 minutes).
-        */
-       private static final Duration DEFAULT_REPLAY_RETENTION = 
Duration.ofMinutes(5);
-
        /** Default allowlist of XML signature algorithms. */
        private static final Set<String> DEFAULT_ALGORITHMS = Set.of(
                SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256,
@@ -814,7 +811,10 @@ public class SamlAssertionValidator {
                var id = assertion.getID();
                if (id == null || id.isEmpty())
                        throw rejectAssertion("assertion has no ID for 
single-use enforcement");
-               var expiresAtMs = singleUseExpiry(assertion);
+               var notOnOrAfter = resolveNotOnOrAfter(assertion);
+               if (notOnOrAfter == null)
+                       throw rejectAssertion("assertion has no NotOnOrAfter to 
bound one-time use");
+               var expiresAtMs = notOnOrAfter.plus(clockSkew).toEpochMilli();
                var firstSeen = replayCache.checkAndRecord(id, expiresAtMs, 
ReplayCache.FailMode.FAIL_CLOSED,
                        e -> LOG.log(Level.WARNING, e, () -> "SAML assertion 
single-use check failed; rejecting (fail-closed)."));
                if (! firstSeen)
@@ -822,11 +822,36 @@ public class SamlAssertionValidator {
                                .wwwAuthenticate("SAML 
error=\"assertion_replayed\"");
        }
 
-       private long singleUseExpiry(Assertion assertion) {
-               var conditions = assertion.getConditions();
-               if (conditions != null && conditions.getNotOnOrAfter() != null)
-                       return 
conditions.getNotOnOrAfter().plus(clockSkew).toEpochMilli();
-               return 
clock.instant().plus(DEFAULT_REPLAY_RETENTION).toEpochMilli();
+       /**
+        * Resolves the effective {@code NotOnOrAfter} that bounds one-time use 
of the assertion.
+        *
+        * <p>
+        * The resolved value is the latest of the assertion's {@code 
Conditions/NotOnOrAfter} and any bearer
+        * {@code SubjectConfirmationData/NotOnOrAfter}.  Returns <jk>null</jk> 
when the assertion carries neither
+        * &mdash; in that case its validity window is unbounded, so single-use 
cannot be enforced past a cache
+        * eviction and the caller rejects the assertion rather than inventing 
a short fallback that later re-opens
+        * replay.  {@code <Conditions>} itself is guaranteed non-<jk>null</jk> 
here (a null {@code <Conditions>} is
+        * rejected earlier by {@link #validateConditions(Assertion)}).
+        *
+        * @param assertion The assertion.
+        * @return The latest bounding instant, or <jk>null</jk> if the 
assertion carries no {@code NotOnOrAfter}.
+        */
+       private static Instant resolveNotOnOrAfter(Assertion assertion) {
+               var result = assertion.getConditions().getNotOnOrAfter();
+               var subject = assertion.getSubject();
+               if (subject != null) {
+                       for (var sc : subject.getSubjectConfirmations()) {
+                               if (! 
SubjectConfirmation.METHOD_BEARER.equals(sc.getMethod()))
+                                       continue;
+                               var data = sc.getSubjectConfirmationData();
+                               if (data == null)
+                                       continue;
+                               var noa = data.getNotOnOrAfter();
+                               if (noa != null && (result == null || 
noa.isAfter(result)))
+                                       result = noa;
+                       }
+               }
+               return result;
        }
 
        private static String 
extractStringValue(org.opensaml.core.xml.XMLObject av) {
diff --git 
a/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator_Branches_Test.java
 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator_Branches_Test.java
index fc45f2ce69..2a04593d3b 100644
--- 
a/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator_Branches_Test.java
+++ 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator_Branches_Test.java
@@ -73,17 +73,19 @@ class SamlAssertionValidator_Branches_Test extends TestBase 
{
        }
 
        // 
-----------------------------------------------------------------------------------------------------------------
-       // B: nbf == null and noa == null → timestamps not checked (lines 
561–564 both false branches)
+       // B: nbf == null and noa == null → the NotBefore/NotOnOrAfter checks 
are skipped in validateConditions, but
+       //    an assertion with no NotOnOrAfter anywhere has an unbounded 
validity window and is rejected at the
+       //    one-time-use stage (its lifetime cannot be bounded, so single-use 
cannot be enforced past eviction).
        // 
-----------------------------------------------------------------------------------------------------------------
 
-       @Test void b01_noNbf_noNoa_accepted() throws Exception {
+       @Test void b01_noNbf_noNoa_rejected() throws Exception {
                var pair = SamlTestSupport.generateRsaKeyPair();
                var cred = SamlTestSupport.credential(pair);
-               // conditions() with null notBefore and null notOnOrAfter → 
neither timestamp check fires
+               // conditions() with null notBefore and null notOnOrAfter → 
neither timestamp check fires, but the missing
+               // NotOnOrAfter leaves the assertion unbounded → rejected 
(fail-closed) rather than replayable.
                var assertion = SamlTestSupport.buildMinimalAssertion(ISSUER, 
AUDIENCE, "alice", null, null);
                var xml = SamlTestSupport.signAndBuildResponse(cred, ISSUER, 
assertion);
-               var principal = validator(cred).validate(xml);
-               assertEquals("alice", principal.getName());
+               assertThrows(AuthenticationException.class, () -> 
validator(cred).validate(xml));
        }
 
        // 
-----------------------------------------------------------------------------------------------------------------
diff --git 
a/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator_ReplayExpiry_Test.java
 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator_ReplayExpiry_Test.java
new file mode 100644
index 0000000000..67f12bfc6b
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator_ReplayExpiry_Test.java
@@ -0,0 +1,202 @@
+/*
+ * 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.juneau.rest.server.auth.saml;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.concurrent.*;
+import org.apache.juneau.rest.server.auth.*;
+import org.junit.jupiter.api.*;
+import org.opensaml.saml.common.*;
+import org.opensaml.saml.saml2.core.*;
+import org.opensaml.security.credential.*;
+
+/**
+ * Tests that one-time-use enforcement in {@link SamlAssertionValidator} holds 
for the assertion's full validity
+ * window, including when {@code Conditions/NotOnOrAfter} is absent.
+ *
+ * <p>
+ * An assertion with no {@code NotOnOrAfter} anywhere (neither {@code 
Conditions/NotOnOrAfter} nor a bearer
+ * {@code SubjectConfirmationData/NotOnOrAfter}) has an unbounded validity 
window and must be rejected rather than
+ * pinned to a short replay-cache fallback that later expires and re-opens 
replay.  When a bounding
+ * {@code NotOnOrAfter} <i>is</i> present, the replay-cache record must be 
retained until that resolved instant
+ * (plus clock skew), not a fixed default.
+ *
+ * @since 10.0.0
+ */
+class SamlAssertionValidator_ReplayExpiry_Test extends TestBase {
+
+       private static final String ISSUER = "https://idp.example.com";;
+       private static final String AUDIENCE = "https://sp.example.com";;
+       private static final String ACS = "https://sp.example.com/saml/acs";;
+       private static final Instant NOW = 
Instant.parse("2026-01-01T00:00:00Z");
+       private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
+       private static final Duration SKEW = Duration.ofSeconds(60);
+       private static final Instant NBF = NOW.minusSeconds(60);
+
+       private static SamlAssertionValidator.Builder base(Credential cred) {
+               return SamlAssertionValidator.create()
+                       .spEntityId(AUDIENCE)
+                       .expectedIssuer(ISSUER)
+                       .signingCredential(cred)
+                       .clock(CLOCK);
+       }
+
+       /** A replay cache that always reports first-seen and records the 
expiry it was handed. */
+       private static final class CapturingCache implements ReplayCache {
+               private final AtomicLong lastExpiry = new 
AtomicLong(Long.MIN_VALUE);
+
+               @Override /* ReplayCache */
+               public boolean checkAndRecord(String id, long expiresAtMs) {
+                       lastExpiry.set(expiresAtMs);
+                       return true;
+               }
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // A: Missing NotOnOrAfter everywhere → rejected (unbounded validity 
window).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_noNotOnOrAfterAnywhere_rejected() throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               // Conditions carries an audience but no NotOnOrAfter; no 
bearer SubjectConfirmation at all.
+               var assertion = SamlTestSupport.buildMinimalAssertion(ISSUER, 
AUDIENCE, "alice", NBF, null);
+               var xml = SamlTestSupport.signAndBuildResponse(cred, ISSUER, 
assertion);
+               var validator = base(cred).build();
+               assertThrows(AuthenticationException.class, () -> 
validator.validate(xml));
+       }
+
+       @Test void a02_conditionsNotOnOrAfterPresent_accepted() throws 
Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var assertion = SamlTestSupport.buildMinimalAssertion(ISSUER, 
AUDIENCE, "alice", NBF, NOW.plusSeconds(300));
+               var xml = SamlTestSupport.signAndBuildResponse(cred, ISSUER, 
assertion);
+               assertEquals("alice", 
base(cred).build().validate(xml).getName());
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B: Replay-cache retention uses the resolved NotOnOrAfter (plus 
skew), not a fixed default.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_missingConditionsNoa_fallsBackToBearerConfirmationNoa() 
throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var confNoa = NOW.plusSeconds(600);
+               // Conditions has no NotOnOrAfter, but a bearer confirmation 
supplies one; recipient(...) is NOT configured,
+               // so the confirmation is not enforced during validation, yet 
must still bound the replay-cache retention.
+               var sc = SamlTestSupport.bearerConfirmation(ACS, null, confNoa, 
null, null);
+               var sub = SamlTestSupport.subjectWithConfirmations("alice", sc);
+               var assertion = 
SamlTestSupport.buildMinimalAssertionWithSubject(ISSUER, AUDIENCE, sub, NBF, 
null);
+               var xml = SamlTestSupport.signAndBuildResponse(cred, ISSUER, 
assertion);
+               var cache = new CapturingCache();
+               assertEquals("alice", 
base(cred).replayCache(cache).build().validate(xml).getName());
+               assertEquals(confNoa.plus(SKEW).toEpochMilli(), 
cache.lastExpiry.get());
+       }
+
+       @Test void b02_conditionsNoa_usedForRetention() throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var condNoa = NOW.plusSeconds(300);
+               var assertion = SamlTestSupport.buildMinimalAssertion(ISSUER, 
AUDIENCE, "alice", NBF, condNoa);
+               var xml = SamlTestSupport.signAndBuildResponse(cred, ISSUER, 
assertion);
+               var cache = new CapturingCache();
+               base(cred).replayCache(cache).build().validate(xml);
+               assertEquals(condNoa.plus(SKEW).toEpochMilli(), 
cache.lastExpiry.get());
+       }
+
+       @Test void b03_laterBearerConfirmationNoa_wins() throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var condNoa = NOW.plusSeconds(300);
+               var confNoa = NOW.plusSeconds(900);  // later than the 
Conditions window
+               var sc = SamlTestSupport.bearerConfirmation(ACS, null, confNoa, 
null, null);
+               var sub = SamlTestSupport.subjectWithConfirmations("alice", sc);
+               var assertion = 
SamlTestSupport.buildMinimalAssertionWithSubject(ISSUER, AUDIENCE, sub, NBF, 
condNoa);
+               var xml = SamlTestSupport.signAndBuildResponse(cred, ISSUER, 
assertion);
+               var cache = new CapturingCache();
+               base(cred).replayCache(cache).build().validate(xml);
+               assertEquals(confNoa.plus(SKEW).toEpochMilli(), 
cache.lastExpiry.get());
+       }
+
+       @Test void b04_laterConditionsNoa_wins() throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var condNoa = NOW.plusSeconds(900);  // later than the 
confirmation window
+               var confNoa = NOW.plusSeconds(300);
+               var sc = SamlTestSupport.bearerConfirmation(ACS, null, confNoa, 
null, null);
+               var sub = SamlTestSupport.subjectWithConfirmations("alice", sc);
+               var assertion = 
SamlTestSupport.buildMinimalAssertionWithSubject(ISSUER, AUDIENCE, sub, NBF, 
condNoa);
+               var xml = SamlTestSupport.signAndBuildResponse(cred, ISSUER, 
assertion);
+               var cache = new CapturingCache();
+               base(cred).replayCache(cache).build().validate(xml);
+               assertEquals(condNoa.plus(SKEW).toEpochMilli(), 
cache.lastExpiry.get());
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // C: Confirmations that do not contribute a NotOnOrAfter fall through 
to the Conditions value.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void c01_nonBearerConfirmation_ignoredForRetention() throws 
Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var condNoa = NOW.plusSeconds(300);
+               var sub = SamlTestSupport.subjectWithConfirmations("alice", 
holderOfKeyConfirmation());
+               var assertion = 
SamlTestSupport.buildMinimalAssertionWithSubject(ISSUER, AUDIENCE, sub, NBF, 
condNoa);
+               var xml = SamlTestSupport.signAndBuildResponse(cred, ISSUER, 
assertion);
+               var cache = new CapturingCache();
+               base(cred).replayCache(cache).build().validate(xml);
+               assertEquals(condNoa.plus(SKEW).toEpochMilli(), 
cache.lastExpiry.get());
+       }
+
+       @Test void c02_bearerConfirmationWithNoData_ignoredForRetention() 
throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var condNoa = NOW.plusSeconds(300);
+               var sub = SamlTestSupport.subjectWithConfirmations("alice", 
bearerConfirmationNoData());
+               var assertion = 
SamlTestSupport.buildMinimalAssertionWithSubject(ISSUER, AUDIENCE, sub, NBF, 
condNoa);
+               var xml = SamlTestSupport.signAndBuildResponse(cred, ISSUER, 
assertion);
+               var cache = new CapturingCache();
+               base(cred).replayCache(cache).build().validate(xml);
+               assertEquals(condNoa.plus(SKEW).toEpochMilli(), 
cache.lastExpiry.get());
+       }
+
+       @Test void c03_bearerConfirmationWithNullNoa_ignoredForRetention() 
throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var condNoa = NOW.plusSeconds(300);
+               // Bearer confirmation carries a SubjectConfirmationData but no 
NotOnOrAfter → contributes nothing.
+               var sc = SamlTestSupport.bearerConfirmation(null, null, null, 
null, null);
+               var sub = SamlTestSupport.subjectWithConfirmations("alice", sc);
+               var assertion = 
SamlTestSupport.buildMinimalAssertionWithSubject(ISSUER, AUDIENCE, sub, NBF, 
condNoa);
+               var xml = SamlTestSupport.signAndBuildResponse(cred, ISSUER, 
assertion);
+               var cache = new CapturingCache();
+               base(cred).replayCache(cache).build().validate(xml);
+               assertEquals(condNoa.plus(SKEW).toEpochMilli(), 
cache.lastExpiry.get());
+       }
+
+       @SuppressWarnings("unchecked")
+       private static SubjectConfirmation holderOfKeyConfirmation() {
+               var scb = (SAMLObjectBuilder<SubjectConfirmation>) 
SamlTestSupport.bf().getBuilder(SubjectConfirmation.DEFAULT_ELEMENT_NAME);
+               var sc = scb.buildObject();
+               sc.setMethod(SubjectConfirmation.METHOD_HOLDER_OF_KEY);
+               return sc;
+       }
+
+       @SuppressWarnings("unchecked")
+       private static SubjectConfirmation bearerConfirmationNoData() {
+               var scb = (SAMLObjectBuilder<SubjectConfirmation>) 
SamlTestSupport.bf().getBuilder(SubjectConfirmation.DEFAULT_ELEMENT_NAME);
+               var sc = scb.buildObject();
+               sc.setMethod(SubjectConfirmation.METHOD_BEARER);
+               return sc;  // no SubjectConfirmationData
+       }
+}

Reply via email to