You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/08/16 19:33:52 UTC

(juneau) 03/08: READY-379: Bind SAML assertions to the request ACS recipient (bearer subject-confirmation)

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 <ja...@apache.org>
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());