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> — 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());
