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> — defaults to 60 seconds tolerance on {@code NotBefore} /
* {@code NotOnOrAfter}; capped at 300 seconds.
* <li><b>Audience restriction</b> — {@code <AudienceRestriction>} must list the configured SP entity ID.
- * <li><b>Bearer subject confirmation</b> — 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> — 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 — 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> — 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 — 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 — operators consuming bearer assertions from a browser SSO flow should
- * configure this.
+ * validation is skipped for calls to {@link SamlAssertionValidator#validate(String)} — 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 —
+ * {@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
+ * — 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} — 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 — 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());