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 1b22e38502f51a6181a906b59e995d783e16701b
Author: James Bognar <[email protected]>
AuthorDate: Sun Aug 16 15:32:47 2026 -0400

    READY-379: Bind SAML assertions to the request ACS recipient (bearer 
subject-confirmation)
    
    Makes SamlAuthFilter.deriveRecipient() null-safe: when getRequestURL()
    returns null, the ACS recipient is reconstructed from scheme/server
    name/server port/request URI (omitting the port when it's the
    scheme's default), and the request is rejected via AuthenticationException
    when even that fails, instead of throwing an NPE. Also syncs the
    juneau-integration-tests duplicate of SamlAuthFilter_Test with the
    module copy's request mock so it exercises the same getRequestURL()
    path.
---
 .../rest/server/auth/saml/SamlAuthFilter_Test.java |   3 +-
 .../server/auth/saml/SamlAssertionValidator.java   |  69 ++++++--
 .../rest/server/auth/saml/SamlAuthFilter.java      |  71 +++++++-
 ...ssertionValidator_SubjectConfirmation_Test.java |  44 +++++
 .../auth/saml/SamlAuthFilter_MaxInflate_Test.java  |   3 +-
 .../saml/SamlAuthFilter_RecipientBinding_Test.java | 182 +++++++++++++++++++++
 .../rest/server/auth/saml/SamlAuthFilter_Test.java |   4 +-
 7 files changed, 355 insertions(+), 21 deletions(-)

diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_Test.java
index d63d8f92cb..fadaf32376 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_Test.java
@@ -51,7 +51,7 @@ class SamlAuthFilter_Test extends TestBase {
                                        .spEntityId("https://sp.example.com";)
                                        
.expectedIssuer("https://idp.example.com";)
                                        .signingCredential(new 
BasicCredential(pair.getPublic(), pair.getPrivate()))) {
-                       @Override public Principal validate(String xml) {
+                       @Override public Principal validate(String xml, String 
recipient) {
                                return impl.apply(xml);
                        }
                };
@@ -62,6 +62,7 @@ class SamlAuthFilter_Test extends TestBase {
                when(r.getPathInfo()).thenReturn(path);
                when(r.getServletPath()).thenReturn(path);
                when(r.getParameter("SAMLResponse")).thenReturn(samlResponse);
+               when(r.getRequestURL()).thenReturn(new 
StringBuffer("https://sp.example.com"; + path));
                return r;
        }
 
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 62044f1d9c..a410dc86ef 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
@@ -72,11 +72,17 @@ import net.shibboleth.shared.resolver.*;
  *     <li><b>Clock skew</b> &mdash; defaults to 60 seconds tolerance on 
{@code NotBefore} /
  *             {@code NotOnOrAfter}; capped at 300 seconds.
  *     <li><b>Audience restriction</b> &mdash; {@code <AudienceRestriction>} 
must list the configured SP entity ID.
- *     <li><b>Bearer subject confirmation</b> &mdash; when a {@link 
Builder#recipient(String) recipient} (the SP
- *             ACS URL) is configured, the assertion must carry a {@code 
bearer} {@code <SubjectConfirmation>} whose
- *             {@code <SubjectConfirmationData>} matches the expected {@code 
Recipient}, is within its
- *             {@code NotBefore}/{@code NotOnOrAfter} window, and satisfies 
the configured
- *             {@code InResponseTo}/{@code Address} expectations.
+ *     <li><b>Bearer subject confirmation</b> &mdash; enforced whenever a 
{@code recipient} (the SP ACS URL) is
+ *             known, either because {@link Builder#recipient(String)} was 
configured on this validator or because
+ *             the caller invoked {@link #validate(String, String)} with one.  
{@link SamlAuthFilter} always calls
+ *             {@link #validate(String, String)} with the ACS URL derived from 
the current request, so a validator
+ *             reached only through the filter can never skip this check.  
When enforced, the assertion must carry a
+ *             {@code bearer} {@code <SubjectConfirmation>} whose {@code 
<SubjectConfirmationData>} matches the
+ *             expected {@code Recipient}, is within its {@code 
NotBefore}/{@code NotOnOrAfter} window, and satisfies
+ *             the configured {@code InResponseTo}/{@code Address} 
expectations.  A validator used standalone (not
+ *             through {@link SamlAuthFilter}) skips this check unless {@link 
Builder#recipient(String)} is
+ *             configured &mdash; that mode is <b>not</b> safe for browser-SSO 
ACS endpoints, since nothing then binds
+ *             the assertion to a specific recipient.
  *     <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.  An assertion whose validity window is 
unbounded &mdash; carrying neither a
@@ -100,6 +106,14 @@ import net.shibboleth.shared.resolver.*;
  *
  * <h5 class='topic'>Builder usage</h5>
  *
+ * <p>
+ * This recipe builds a <b>standalone</b> validator with no {@link 
Builder#recipient(String)} configured, so
+ * bearer subject-confirmation is not enforced.  It is safe for assertion-only 
/ non-browser callers that
+ * validate an assertion outside of an ACS redirect.  Deploying it behind 
{@link SamlAuthFilter} for
+ * browser-SSO is always safe regardless: the filter derives the ACS recipient 
from the request and calls
+ * {@link #validate(String, String)}, which enforces bearer confirmation for 
that call no matter how this
+ * validator was built.
+ *
  * <p class='bjava'>
  *     <jk>var</jk> validator = SamlAssertionValidator.<jsm>create</jsm>()
  *             
.metadataResolver(SamlMetadataResolvers.<jsm>url</jsm>(<js>"https://idp.example.com/metadata";</js>))
@@ -303,8 +317,10 @@ public class SamlAssertionValidator {
                 * {@code <SubjectConfirmationData>} names this exact {@code 
Recipient}, is within its
                 * {@code NotBefore}/{@code NotOnOrAfter} window, and satisfies 
the {@link #expectedInResponseTo(String)}
                 * / {@link #subjectAddress(String)} expectations.  When left 
unset (the default), subject-confirmation
-                * validation is skipped &mdash; operators consuming bearer 
assertions from a browser SSO flow should
-                * configure this.
+                * validation is skipped for calls to {@link 
SamlAssertionValidator#validate(String)} &mdash; safe only
+                * for assertion-only / non-browser callers.  A validator 
reached through {@link SamlAuthFilter} enforces
+                * bearer confirmation regardless of this setting, since the 
filter derives the recipient from the
+                * request and calls {@link 
SamlAssertionValidator#validate(String, String)}.
                 *
                 * @param value The expected ACS URL.  Must not be 
<jk>null</jk> or blank.
                 * @return This object.
@@ -495,12 +511,41 @@ public class SamlAssertionValidator {
         */
        public Principal validate(String xml) throws AuthenticationException {
                assertArgNotNullOrBlank("xml", xml);
+               return validateInternal(xml, recipient);
+       }
+
+       /**
+        * Validates the supplied SAML 2.0 {@code <samlp:Response>} XML, 
enforcing bearer
+        * {@code <SubjectConfirmation>} against the given {@code recipient} 
for this call.
+        *
+        * <p>
+        * Unlike {@link #validate(String)}, bearer subject-confirmation is 
always enforced here &mdash;
+        * {@code recipient} overrides whatever {@link 
Builder#recipient(String)} this validator was built with
+        * (including unset).  {@link SamlAuthFilter} calls this overload with 
the ACS URL derived from the
+        * current request so that a validator built without {@link 
Builder#recipient(String)} configured still
+        * rejects an assertion bearer-confirmed to a different endpoint on the 
filter path.
+        *
+        * @param xml The full XML payload (already base64-decoded; already 
URL-decoded and inflated for the
+        *      REDIRECT binding).  Must be a {@code <samlp:Response>} envelope.
+        * @param recipient The expected {@code Recipient} (this SP's ACS URL) 
for bearer subject-confirmation.
+        *      Must not be <jk>null</jk> or blank.
+        * @return A {@link ClaimsPrincipal} carrying the IdP-supplied claims 
plus the {@code issuerType=SAML}
+        *      marker.
+        * @throws AuthenticationException If the response cannot be parsed or 
validation fails.
+        */
+       public Principal validate(String xml, String recipient) throws 
AuthenticationException {
+               assertArgNotNullOrBlank("xml", xml);
+               assertArgNotNullOrBlank("recipient", recipient);
+               return validateInternal(xml, recipient);
+       }
+
+       private Principal validateInternal(String xml, String recipient) throws 
AuthenticationException {
                var response = parseResponse(xml);
                validateStatus(response);
                var assertion = extractAndDecryptAssertion(response);
                verifySignature(assertion);
                var claims = buildClaims(response, assertion);
-               validateSubjectConfirmation(assertion);
+               validateSubjectConfirmation(assertion, recipient);
                recordSingleUse(assertion);
                var subject = nameId(assertion);
                return new ClaimsPrincipal(subject, claims);
@@ -758,8 +803,8 @@ public class SamlAssertionValidator {
                }
        }
 
-       private void validateSubjectConfirmation(Assertion assertion) throws 
AuthenticationException {
-               if (recipient == null)  // Bearer subject-confirmation 
validation is enabled by configuring recipient(...).
+       private void validateSubjectConfirmation(Assertion assertion, String 
recipient) throws AuthenticationException {
+               if (recipient == null)  // Bearer subject-confirmation 
validation is enabled by configuring recipient(...) or calling validate(xml, 
recipient).
                        return;
                var subject = assertion.getSubject();
                if (subject == null)
@@ -770,7 +815,7 @@ public class SamlAssertionValidator {
                        if (! 
SubjectConfirmation.METHOD_BEARER.equals(sc.getMethod()))
                                continue;
                        sawBearer = true;
-                       var reason = confirmationFailureReason(sc);
+                       var reason = confirmationFailureReason(sc, recipient);
                        if (reason == null)
                                return;  // A valid bearer confirmation was 
found.
                        failure = reason;
@@ -780,7 +825,7 @@ public class SamlAssertionValidator {
                throw rejectAssertion(failure);
        }
 
-       private String confirmationFailureReason(SubjectConfirmation sc) {
+       private String confirmationFailureReason(SubjectConfirmation sc, String 
recipient) {
                var data = sc.getSubjectConfirmationData();
                if (data == null)
                        return "bearer <SubjectConfirmationData> is missing";
diff --git 
a/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter.java
 
b/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter.java
index e2f57cdb76..2d2aa65349 100644
--- 
a/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter.java
+++ 
b/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter.java
@@ -42,9 +42,13 @@ import jakarta.servlet.http.*;
  *     <li>For the {@link SamlBinding#POST} binding: reads the {@code 
SAMLResponse} form parameter and
  *             base64-decodes it.  For {@link SamlBinding#REDIRECT}: reads the 
{@code SAMLResponse} query parameter,
  *             base64-decodes it, and then DEFLATE-inflates (RFC 1951) per 
OASIS SAML 2.0 Redirect binding rules.
- *     <li>Delegates to {@link SamlAssertionValidator#validate(String)}.  On 
success, builds an {@link AuthResult}
- *             carrying the resolved {@link ClaimsPrincipal}.  On failure, 
re-throws as an
- *             {@link AuthenticationException} with a {@code WWW-Authenticate: 
SAML ...} challenge.
+ *     <li>Derives the ACS recipient URL (scheme, host, port, and path) from 
the current request and delegates
+ *             to {@link SamlAssertionValidator#validate(String, String)} with 
it, so the assertion's bearer
+ *             {@code <SubjectConfirmation>} is always bound to the actual 
endpoint this request was delivered to
+ *             &mdash; regardless of whether the {@link 
SamlAssertionValidator} was itself built with
+ *             {@link SamlAssertionValidator.Builder#recipient(String) 
recipient(...)} configured.  On success,
+ *             builds an {@link AuthResult} carrying the resolved {@link 
ClaimsPrincipal}.  On failure, re-throws as
+ *             an {@link AuthenticationException} with a {@code 
WWW-Authenticate: SAML ...} challenge.
  * </ol>
  *
  * <h5 class='topic'>Roles</h5>
@@ -55,6 +59,12 @@ import jakarta.servlet.http.*;
  *
  * <h5 class='topic'>Usage</h5>
  *
+ * <p>
+ * Note that {@code validator} below is <i>not</i> built with
+ * {@link SamlAssertionValidator.Builder#recipient(String) recipient(...)}.  
That is safe here because this
+ * filter always derives the ACS recipient from the request and binds it for 
that call, so bearer
+ * subject-confirmation is enforced regardless of the validator's own 
configuration.
+ *
  * <p class='bjava'>
  *     <jk>var</jk> validator = SamlAssertionValidator.<jsm>create</jsm>()
  *             
.metadataResolver(SamlMetadataResolvers.<jsm>url</jsm>(<js>"https://idp.example.com/metadata";</js>))
@@ -259,7 +269,7 @@ public class SamlAuthFilter extends AuthFilter {
                if (isEmpty(raw))
                        return oe();
                var xml = decodeSamlResponse(raw);
-               var principal = runValidator(xml);
+               var principal = runValidator(xml, deriveRecipient(req));
                return o(AuthResult.of(principal, extractRoles(principal)));
        }
 
@@ -269,6 +279,55 @@ public class SamlAuthFilter extends AuthFilter {
                return s != null && s.equals(consumerPath);
        }
 
+       /**
+        * Derives the ACS recipient URL (scheme, host, port, and path) that 
this request was actually delivered
+        * to, so it can be bound to the assertion's bearer {@code 
<SubjectConfirmation>}.
+        *
+        * <p>
+        * Normally this is just {@link HttpServletRequest#getRequestURL()}.  
Some servlet-container and test
+        * request implementations return <jk>null</jk> from that method, so as 
a fallback the URL is
+        * reconstructed from {@link HttpServletRequest#getScheme()}, {@link 
HttpServletRequest#getServerName()},
+        * {@link HttpServletRequest#getServerPort()} (omitted when it's the 
scheme's default port), and
+        * {@link HttpServletRequest#getRequestURI()}.  If the recipient still 
cannot be determined, the request
+        * is rejected via {@link AuthenticationException} &mdash; never with 
an {@link NullPointerException}.
+        *
+        * @param req The current request; {@link #matchesPath} has already 
confirmed its path matches
+        *      {@code consumerPath}.
+        * @return The recipient URL, e.g. {@code 
https://sp.example.com/saml/acs}.
+        * @throws AuthenticationException If the recipient URL cannot be 
determined from the request.
+        */
+       private String deriveRecipient(HttpServletRequest req) throws 
AuthenticationException {
+               var url = req.getRequestURL();
+               if (url != null)
+                       return url.toString();
+               return reconstructRecipient(req);
+       }
+
+       /**
+        * Fallback for {@link #deriveRecipient(HttpServletRequest)} when 
{@link HttpServletRequest#getRequestURL()}
+        * returns <jk>null</jk>.
+        *
+        * @param req The current request.
+        * @return The reconstructed recipient URL.
+        * @throws AuthenticationException If {@code scheme}, {@code 
serverName}, or {@code requestURI} is
+        *      <jk>null</jk> or blank, so no recipient can be determined.
+        */
+       private String reconstructRecipient(HttpServletRequest req) throws 
AuthenticationException {
+               var scheme = req.getScheme();
+               var host = req.getServerName();
+               var uri = req.getRequestURI();
+               if (isBlank(scheme) || isBlank(host) || isBlank(uri))
+                       throw new AuthenticationException("Unable to derive 
SAML ACS recipient from request").wwwAuthenticate(challenge);
+               var port = req.getServerPort();
+               var isDefaultPort = port <= 0
+                       || (port == 80 && "http".equalsIgnoreCase(scheme))
+                       || (port == 443 && "https".equalsIgnoreCase(scheme));
+               var sb = new StringBuilder(scheme).append("://").append(host);
+               if (!isDefaultPort)
+                       sb.append(':').append(port);
+               return sb.append(uri).toString();
+       }
+
        private String decodeSamlResponse(String raw) throws 
AuthenticationException {
                try {
                        byte[] decoded = Base64.getMimeDecoder().decode(raw);
@@ -288,9 +347,9 @@ public class SamlAuthFilter extends AuthFilter {
                }
        }
 
-       private Principal runValidator(String xml) throws 
AuthenticationException {
+       private Principal runValidator(String xml, String recipient) throws 
AuthenticationException {
                try {
-                       var p = validator.validate(xml);
+                       var p = validator.validate(xml, recipient);
                        if (p == null)
                                throw new AuthenticationException("SAML 
validator returned null").wwwAuthenticate(challenge);
                        return p;
diff --git 
a/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator_SubjectConfirmation_Test.java
 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator_SubjectConfirmation_Test.java
index 2b0e6adcb5..3cb691e181 100644
--- 
a/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator_SubjectConfirmation_Test.java
+++ 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAssertionValidator_SubjectConfirmation_Test.java
@@ -199,4 +199,48 @@ class SamlAssertionValidator_SubjectConfirmation_Test 
extends TestBase {
                var xml = signedWithSubject(cred, 
SamlTestSupport.subjectWithConfirmations("alice", bad, good));
                assertEquals("alice", 
base(cred).recipient(ACS).build().validate(xml).getName());
        }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // validate(xml, recipient) overload — used by SamlAuthFilter to bind 
the actual per-request ACS URL
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void f01_explicitRecipientOverload_matchingRecipient_accepted() 
throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var sc = SamlTestSupport.bearerConfirmation(ACS, null, NOA, 
null, null);
+               var xml = signedWithSubject(cred, 
SamlTestSupport.subjectWithConfirmations("alice", sc));
+               // No recipient(...) configured on the builder — the two-arg 
overload must still enforce it.
+               var v = base(cred).build();
+               assertEquals("alice", v.validate(xml, ACS).getName());
+       }
+
+       @Test void f02_explicitRecipientOverload_mismatchedRecipient_rejected() 
throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var sc = 
SamlTestSupport.bearerConfirmation("https://evil.example.com/acs";, null, NOA, 
null, null);
+               var xml = signedWithSubject(cred, 
SamlTestSupport.subjectWithConfirmations("alice", sc));
+               var v = base(cred).build();
+               assertThrows(AuthenticationException.class, () -> 
v.validate(xml, ACS));
+       }
+
+       @Test void 
f03_explicitRecipientOverload_overridesConfiguredBuilderRecipient() throws 
Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var sc = SamlTestSupport.bearerConfirmation(ACS, null, NOA, 
null, null);
+               var xml = signedWithSubject(cred, 
SamlTestSupport.subjectWithConfirmations("alice", sc));
+               // Builder was configured with a DIFFERENT recipient; the 
per-call argument wins.
+               var v = 
base(cred).recipient("https://other-sp.example.com/saml/acs";).build();
+               assertEquals("alice", v.validate(xml, ACS).getName());
+       }
+
+       @Test void f04_explicitRecipientOverload_nullRecipient_throws() throws 
Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var xml = SamlTestSupport.buildSignedResponse(cred, ISSUER, 
AUDIENCE, "alice", NBF, NOA, Map.of());
+               var v = base(cred).build();
+               assertThrows(IllegalArgumentException.class, () -> 
v.validate(xml, null));
+       }
+
+       @Test void f05_explicitRecipientOverload_blankRecipient_throws() throws 
Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var xml = SamlTestSupport.buildSignedResponse(cred, ISSUER, 
AUDIENCE, "alice", NBF, NOA, Map.of());
+               var v = base(cred).build();
+               assertThrows(IllegalArgumentException.class, () -> 
v.validate(xml, " "));
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_MaxInflate_Test.java
 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_MaxInflate_Test.java
index 0f1735069d..fa1cf346f6 100644
--- 
a/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_MaxInflate_Test.java
+++ 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_MaxInflate_Test.java
@@ -51,7 +51,7 @@ class SamlAuthFilter_MaxInflate_Test extends TestBase {
                                        .spEntityId("https://sp.example.com";)
                                        
.expectedIssuer("https://idp.example.com";)
                                        .signingCredential(new 
BasicCredential(pair.getPublic(), pair.getPrivate()))) {
-                       @Override public Principal validate(String xml) {
+                       @Override public Principal validate(String xml, String 
recipient) {
                                return impl.apply(xml);
                        }
                };
@@ -62,6 +62,7 @@ class SamlAuthFilter_MaxInflate_Test extends TestBase {
                when(r.getPathInfo()).thenReturn("/saml/acs");
                when(r.getServletPath()).thenReturn("/saml/acs");
                when(r.getParameter("SAMLResponse")).thenReturn(samlResponse);
+               when(r.getRequestURL()).thenReturn(new 
StringBuffer("https://sp.example.com/saml/acs";));
                return r;
        }
 
diff --git 
a/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_RecipientBinding_Test.java
 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_RecipientBinding_Test.java
new file mode 100644
index 0000000000..708cfea661
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_RecipientBinding_Test.java
@@ -0,0 +1,182 @@
+/*
+ * 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 static org.mockito.Mockito.*;
+
+import java.nio.charset.*;
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.auth.*;
+import org.junit.jupiter.api.*;
+import org.opensaml.security.credential.*;
+
+import jakarta.servlet.http.*;
+
+/**
+ * Filter-level tests proving {@link SamlAuthFilter} binds every assertion to 
the actual ACS URL of the
+ * current request &mdash; regardless of whether the {@link 
SamlAssertionValidator} it wraps was itself built
+ * with {@link SamlAssertionValidator.Builder#recipient(String) 
recipient(...)} configured.
+ *
+ * @since 10.0.0
+ */
+class SamlAuthFilter_RecipientBinding_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 Instant NBF = NOW.minusSeconds(60);
+       private static final Instant NOA = NOW.plusSeconds(300);
+
+       private static SamlAssertionValidator.Builder base(Credential cred) {
+               return SamlAssertionValidator.create()
+                       .spEntityId(AUDIENCE)
+                       .expectedIssuer(ISSUER)
+                       .signingCredential(cred)
+                       .clock(CLOCK);
+       }
+
+       /** Builds a signed response whose bearer confirmation names {@code 
confirmedRecipient} as its Recipient. */
+       private static String signedResponseWithRecipient(Credential cred, 
String confirmedRecipient) throws Exception {
+               var sc = SamlTestSupport.bearerConfirmation(confirmedRecipient, 
null, NOA, null, null);
+               var sub = SamlTestSupport.subjectWithConfirmations("alice", sc);
+               var assertion = 
SamlTestSupport.buildMinimalAssertionWithSubject(ISSUER, AUDIENCE, sub, NBF, 
NOA);
+               return SamlTestSupport.signAndBuildResponse((BasicCredential) 
cred, ISSUER, assertion);
+       }
+
+       /** Mocks a request that was actually delivered to {@code 
deliveredToAcs}, carrying {@code responseXml}. */
+       private static HttpServletRequest req(String deliveredToAcs, String 
responseXml) {
+               var r = mock(HttpServletRequest.class);
+               when(r.getPathInfo()).thenReturn("/saml/acs");
+               when(r.getServletPath()).thenReturn("/saml/acs");
+               when(r.getParameter("SAMLResponse")).thenReturn(
+                       
Base64.getEncoder().encodeToString(responseXml.getBytes(StandardCharsets.UTF_8)));
+               when(r.getRequestURL()).thenReturn(new 
StringBuffer(deliveredToAcs));
+               return r;
+       }
+
+       @Test void a01_matchingAcsRecipient_accepted() throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var xml = signedResponseWithRecipient(cred, ACS);
+               // No recipient(...) configured on the validator — the filter 
must derive and bind it anyway.
+               var validator = base(cred).build();
+               var f = SamlAuthFilter.create().validator(validator).build();
+               var result = f.authenticate(req(ACS, xml));
+               assertTrue(result.isPresent());
+               assertEquals("alice", result.get().getPrincipal().getName());
+       }
+
+       @Test void a02_differentAcsRecipient_rejected() throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               // The assertion's bearer confirmation names a different ACS 
than the one this request actually hit.
+               var xml = signedResponseWithRecipient(cred, 
"https://other-sp.example.com/saml/acs";);
+               var validator = base(cred).build();
+               var f = SamlAuthFilter.create().validator(validator).build();
+               var ex = assertThrows(AuthenticationException.class, () -> 
f.authenticate(req(ACS, xml)));
+               assertTrue(ex.getHeaders().stream().anyMatch(h -> 
"WWW-Authenticate".equalsIgnoreCase(h.getName())));
+       }
+
+       @Test void a03_publishedExampleRecipe_stillBindsRecipientOnFilterPath() 
throws Exception {
+               // Mirrors SamlAuthFilter's javadoc "Usage" example exactly: 
validator built with no recipient(...).
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var xml = signedResponseWithRecipient(cred, 
"https://other-sp.example.com/saml/acs";);
+               var validator = SamlAssertionValidator.create()
+                       .spEntityId(AUDIENCE)
+                       .expectedIssuer(ISSUER)
+                       .signingCredential(cred)
+                       .clock(CLOCK)
+                       .build();
+               var f = SamlAuthFilter.create().validator(validator).build();
+               assertThrows(AuthenticationException.class, () -> 
f.authenticate(req(ACS, xml)));
+       }
+
+       @Test void 
a04_standaloneValidator_recipientOptedInDirectly_unaffectedByFilterOverride() 
throws Exception {
+               // Sanity check: calling the validator directly (not through 
the filter) with recipient(...) configured
+               // still enforces the builder-level recipient, independent of 
the filter's per-call override.
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var xml = signedResponseWithRecipient(cred, ACS);
+               var validator = base(cred).recipient(ACS).build();
+               assertEquals("alice", validator.validate(xml).getName());
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B: getRequestURL() returns null — the recipient is reconstructed 
from scheme/host/port/path, or the
+       // request is rejected via AuthenticationException (never an NPE) when 
reconstruction is impossible.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       /** Mocks a request with a null getRequestURL(), delivered to the given 
scheme/host/port/uri. */
+       private static HttpServletRequest reqNullRequestUrl(String scheme, 
String host, int port, String uri, String responseXml) {
+               var r = mock(HttpServletRequest.class);
+               when(r.getPathInfo()).thenReturn("/saml/acs");
+               when(r.getServletPath()).thenReturn("/saml/acs");
+               when(r.getParameter("SAMLResponse")).thenReturn(
+                       
Base64.getEncoder().encodeToString(responseXml.getBytes(StandardCharsets.UTF_8)));
+               when(r.getRequestURL()).thenReturn(null);
+               when(r.getScheme()).thenReturn(scheme);
+               when(r.getServerName()).thenReturn(host);
+               when(r.getServerPort()).thenReturn(port);
+               when(r.getRequestURI()).thenReturn(uri);
+               return r;
+       }
+
+       @Test void 
b01_nullRequestUrl_reconstructedAtDefaultPort_acceptsMatchingRecipient() throws 
Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var xml = signedResponseWithRecipient(cred, ACS);
+               var validator = base(cred).build();
+               var f = SamlAuthFilter.create().validator(validator).build();
+               var result = f.authenticate(reqNullRequestUrl("https", 
"sp.example.com", 443, "/saml/acs", xml));
+               assertTrue(result.isPresent());
+               assertEquals("alice", result.get().getPrincipal().getName());
+       }
+
+       @Test void 
b02_nullRequestUrl_reconstructedAtNonDefaultPort_includesPortInRecipient() 
throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var xml = signedResponseWithRecipient(cred, 
"https://sp.example.com:8443/saml/acs";);
+               var validator = base(cred).build();
+               var f = SamlAuthFilter.create().validator(validator).build();
+               var result = f.authenticate(reqNullRequestUrl("https", 
"sp.example.com", 8443, "/saml/acs", xml));
+               assertTrue(result.isPresent());
+       }
+
+       @Test void b03_nullRequestUrl_reconstructedRecipientMismatch_rejected() 
throws Exception {
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               // The assertion's bearer confirmation names a different ACS 
than the one this request actually hit.
+               var xml = signedResponseWithRecipient(cred, 
"https://other-sp.example.com/saml/acs";);
+               var validator = base(cred).build();
+               var f = SamlAuthFilter.create().validator(validator).build();
+               var ex = assertThrows(AuthenticationException.class,
+                       () -> f.authenticate(reqNullRequestUrl("https", 
"sp.example.com", 443, "/saml/acs", xml)));
+               assertTrue(ex.getHeaders().stream().anyMatch(h -> 
"WWW-Authenticate".equalsIgnoreCase(h.getName())));
+       }
+
+       @Test void b04_nullRequestUrl_andNullScheme_failsClosedNotNpe() throws 
Exception {
+               // Neither getRequestURL() nor the scheme/host/uri fallback 
getters are usable — must fail closed with
+               // an AuthenticationException, never propagate an NPE.
+               var cred = 
SamlTestSupport.credential(SamlTestSupport.generateRsaKeyPair());
+               var xml = signedResponseWithRecipient(cred, ACS);
+               var validator = base(cred).build();
+               var f = SamlAuthFilter.create().validator(validator).build();
+               var ex = assertThrows(AuthenticationException.class,
+                       () -> f.authenticate(reqNullRequestUrl(null, null, 0, 
null, xml)));
+               assertTrue(ex.getHeaders().stream().anyMatch(h -> 
"WWW-Authenticate".equalsIgnoreCase(h.getName())));
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_Test.java
 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_Test.java
index 2d5fd09a99..904efb5a1d 100644
--- 
a/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_Test.java
+++ 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlAuthFilter_Test.java
@@ -51,7 +51,7 @@ class SamlAuthFilter_Test extends TestBase {
                                        .spEntityId("https://sp.example.com";)
                                        
.expectedIssuer("https://idp.example.com";)
                                        .signingCredential(new 
BasicCredential(pair.getPublic(), pair.getPrivate()))) {
-                       @Override public Principal validate(String xml) {
+                       @Override public Principal validate(String xml, String 
recipient) {
                                return impl.apply(xml);
                        }
                };
@@ -62,6 +62,7 @@ class SamlAuthFilter_Test extends TestBase {
                when(r.getPathInfo()).thenReturn(path);
                when(r.getServletPath()).thenReturn(path);
                when(r.getParameter("SAMLResponse")).thenReturn(samlResponse);
+               when(r.getRequestURL()).thenReturn(new 
StringBuffer("https://sp.example.com"; + path));
                return r;
        }
 
@@ -144,6 +145,7 @@ class SamlAuthFilter_Test extends TestBase {
                when(r.getServletPath()).thenReturn("/saml/acs");
                var b64 = 
Base64.getEncoder().encodeToString("<x/>".getBytes(StandardCharsets.UTF_8));
                when(r.getParameter("SAMLResponse")).thenReturn(b64);
+               when(r.getRequestURL()).thenReturn(new 
StringBuffer("https://sp.example.com/saml/acs";));
                var cp = new ClaimsPrincipal("bob", Map.of());
                var f = SamlAuthFilter.create().validator(validator(x -> 
cp)).build();
                assertTrue(f.authenticate(r).isPresent());

Reply via email to