001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.server.auth.saml; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.security.*; 023import java.time.*; 024import java.util.*; 025import java.util.logging.*; 026 027import javax.xml.parsers.*; 028import org.apache.juneau.commons.concurrent.*; 029import org.apache.juneau.rest.server.auth.*; 030import org.opensaml.core.criterion.*; 031import org.opensaml.core.xml.io.*; 032import org.opensaml.saml.common.xml.*; 033import org.opensaml.saml.criterion.*; 034import org.opensaml.saml.metadata.resolver.*; 035import org.opensaml.saml.saml2.core.*; 036import org.opensaml.saml.saml2.encryption.Decrypter; 037import org.opensaml.saml.saml2.metadata.*; 038import org.opensaml.saml.security.impl.*; 039import org.opensaml.security.credential.*; 040import org.opensaml.security.x509.*; 041import org.opensaml.xmlsec.encryption.support.*; 042import org.opensaml.xmlsec.keyinfo.*; 043import org.opensaml.xmlsec.keyinfo.impl.*; 044import org.opensaml.xmlsec.signature.*; 045import org.opensaml.xmlsec.signature.support.*; 046import org.opensaml.xmlsec.signature.support.SignatureException; 047import org.w3c.dom.*; 048 049import net.shibboleth.shared.resolver.*; 050 051/** 052 * Validates a SAML 2.0 {@code <samlp:Response>} document and produces a {@link ClaimsPrincipal} marked 053 * with {@code issuerType=SAML}. 054 * 055 * <p> 056 * Wraps OpenSAML 5.x's parser, unmarshaller, signature validator, and (optionally) assertion decrypter. 057 * The validator enforces: 058 * 059 * <ul> 060 * <li><b>Mandatory assertion signature</b> — every assertion must carry its own enveloped signature. 061 * A response that signs only the {@code <Response>} envelope (and not the assertion) is rejected as 062 * unsigned; response-envelope-level signatures are not supported. 063 * <li><b>Strict signature-algorithm allowlist</b> — only {@code rsa-sha256} and 064 * {@code ecdsa-sha256} are accepted out of the box. SHA-1 is permanently rejected. The reference 065 * {@code DigestMethod} is separately allowlisted (SHA-256/384/512), so a SHA-1 digest is rejected even 066 * under a SHA-256 signature. 067 * <li><b>Response status</b> — the {@code <samlp:Status>} must report the {@code Success} status code. 068 * <li><b>Clock skew</b> — defaults to 60 seconds tolerance on {@code NotBefore} / 069 * {@code NotOnOrAfter}; capped at 300 seconds. 070 * <li><b>Audience restriction</b> — {@code <AudienceRestriction>} must list the configured SP entity ID. 071 * <li><b>Bearer subject confirmation</b> — enforced whenever a {@code recipient} (the SP ACS URL) is 072 * known, either because {@link Builder#recipient(String)} was configured on this validator or because 073 * the caller invoked {@link #validate(String, String)} with one. {@link SamlAuthFilter} always calls 074 * {@link #validate(String, String)} with the ACS URL derived from the current request, so a validator 075 * reached only through the filter can never skip this check. When enforced, the assertion must carry a 076 * {@code bearer} {@code <SubjectConfirmation>} whose {@code <SubjectConfirmationData>} matches the 077 * expected {@code Recipient}, is within its {@code NotBefore}/{@code NotOnOrAfter} window, and satisfies 078 * the configured {@code InResponseTo}/{@code Address} expectations. A validator used standalone (not 079 * through {@link SamlAuthFilter}) skips this check unless {@link Builder#recipient(String)} is 080 * configured — that mode is <b>not</b> safe for browser-SSO ACS endpoints, since nothing then binds 081 * the assertion to a specific recipient. 082 * <li><b>One-time use</b> — each assertion ID is recorded in a {@link ReplayCache} and a second 083 * presentation of the same ID is rejected. The check is fail-closed: if the cache cannot answer, the 084 * assertion is rejected. An assertion whose validity window is unbounded — carrying neither a 085 * {@code Conditions/NotOnOrAfter} nor a bearer {@code SubjectConfirmationData/NotOnOrAfter} — is 086 * rejected outright, and the cache record is retained until the resolved {@code NotOnOrAfter} (plus clock 087 * skew) rather than a short default window that would later expire and re-open replay. 088 * <li><b>Encrypted assertions</b> — if the response contains 089 * {@code <EncryptedAssertion>}, a {@code decryptionCredential} must be configured. 090 * </ul> 091 * 092 * <h5 class='topic'>Marker claim</h5> 093 * <p> 094 * The returned {@link ClaimsPrincipal} is annotated with {@code issuerType=SAML} so downstream code 095 * can distinguish SAML-derived principals from JWT-derived principals without resorting to a subclass: 096 * 097 * <p class='bjava'> 098 * <jk>if</jk> (<js>"SAML"</js>.equals(<jv>principal</jv>.getClaim(<js>"issuerType"</js>, String.<jk>class</jk>).orElse(<jk>null</jk>))) { 099 * <jc>// SAML-issued principal</jc> 100 * } 101 * </p> 102 * 103 * <h5 class='topic'>Builder usage</h5> 104 * 105 * <p> 106 * This recipe builds a <b>standalone</b> validator with no {@link Builder#recipient(String)} configured, so 107 * bearer subject-confirmation is not enforced. It is safe for assertion-only / non-browser callers that 108 * validate an assertion outside of an ACS redirect. Deploying it behind {@link SamlAuthFilter} for 109 * browser-SSO is always safe regardless: the filter derives the ACS recipient from the request and calls 110 * {@link #validate(String, String)}, which enforces bearer confirmation for that call no matter how this 111 * validator was built. 112 * 113 * <p class='bjava'> 114 * <jk>var</jk> validator = SamlAssertionValidator.<jsm>create</jsm>() 115 * .metadataResolver(SamlMetadataResolvers.<jsm>url</jsm>(<js>"https://idp.example.com/metadata"</js>)) 116 * .spEntityId(<js>"https://sp.example.com"</js>) 117 * .expectedIssuer(<js>"https://idp.example.com"</js>) 118 * .build(); 119 * </p> 120 * 121 * <h5 class='section'>See Also:</h5> 122 * <ul> 123 * <li class='jc'>{@link SamlAuthFilter} 124 * <li class='jc'>{@link SamlMetadataResolvers} 125 * <li class='link'><a class="doclink" href="https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf">SAML 2.0 Core (OASIS)</a> 126 * </ul> 127 * 128 * @since 10.0.0 129 */ 130@SuppressWarnings({ 131 "java:S1192", // Duplicate string literals are SAML protocol claim names and JWT-style field keys; intentional 132 "java:S6539" // Monolithic class: validator is a single end-to-end pipeline. 133}) 134public class SamlAssertionValidator { 135 136 private static final Logger LOG = Logger.getLogger(SamlAssertionValidator.class.getName()); 137 138 /** Marker claim name on the returned {@link ClaimsPrincipal}. */ 139 public static final String ISSUER_TYPE_CLAIM = "issuerType"; 140 141 /** Marker claim value identifying the principal as SAML-issued. */ 142 public static final String ISSUER_TYPE_SAML = "SAML"; 143 144 /** Maximum clock-skew tolerance the builder will accept (5 minutes). */ 145 private static final Duration MAX_CLOCK_SKEW = Duration.ofSeconds(300); 146 147 /** XML-DSig namespace URI, used when scanning the signature DOM for reference digest methods. */ 148 private static final String XMLSIG_NS = "http://www.w3.org/2000/09/xmldsig#"; 149 150 /** Default allowlist of XML signature algorithms. */ 151 private static final Set<String> DEFAULT_ALGORITHMS = Set.of( 152 SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256, 153 SignatureConstants.ALGO_ID_SIGNATURE_ECDSA_SHA256 154 ); 155 156 /** Default allowlist of XML reference digest algorithms. SHA-1 is deliberately excluded. */ 157 private static final Set<String> DEFAULT_DIGEST_ALGORITHMS = Set.of( 158 SignatureConstants.ALGO_ID_DIGEST_SHA256, 159 SignatureConstants.ALGO_ID_DIGEST_SHA384, 160 SignatureConstants.ALGO_ID_DIGEST_SHA512 161 ); 162 163 /** 164 * Static creator. 165 * 166 * @return A new builder. 167 */ 168 public static Builder create() { 169 return new Builder(); 170 } 171 172 /** 173 * Builder for {@link SamlAssertionValidator}. 174 */ 175 public static class Builder { 176 private MetadataResolver metadataResolver; 177 private Credential signingCredential; 178 private Credential decryptionCredential; 179 private String spEntityId; 180 private String expectedIssuer; 181 private String recipient; 182 private String expectedInResponseTo; 183 private String expectedAddress; 184 private Set<String> algorithms = new LinkedHashSet<>(DEFAULT_ALGORITHMS); 185 private Set<String> digestAlgorithms = new LinkedHashSet<>(DEFAULT_DIGEST_ALGORITHMS); 186 private Duration clockSkew = Duration.ofSeconds(60); 187 private Clock clock = Clock.systemUTC(); 188 private ReplayCache replayCache = new InMemoryReplayCache(); 189 190 /** Constructor. */ 191 protected Builder() {} 192 193 /** 194 * Sets the {@link MetadataResolver} used to resolve the IdP's signing credential at validation time. 195 * 196 * <p> 197 * Mutually exclusive with {@link #signingCredential(Credential)}. 198 * 199 * @param value The metadata resolver. Must not be <jk>null</jk>. 200 * @return This object. 201 */ 202 public Builder metadataResolver(MetadataResolver value) { 203 metadataResolver = reqnn("value", value); 204 return this; 205 } 206 207 /** 208 * Sets a static IdP signing credential. Useful when metadata is not available. 209 * 210 * <p> 211 * Mutually exclusive with {@link #metadataResolver(MetadataResolver)}. 212 * 213 * @param value The IdP signing credential. Must not be <jk>null</jk>. 214 * @return This object. 215 */ 216 public Builder signingCredential(Credential value) { 217 signingCredential = reqnn("value", value); 218 return this; 219 } 220 221 /** 222 * Sets the credential used to decrypt {@code <EncryptedAssertion>} elements. 223 * 224 * <p> 225 * If the response contains encrypted assertions and no decryption credential is configured, 226 * validation fails with {@link AuthenticationException}. 227 * 228 * @param value The SP decryption credential. Must not be <jk>null</jk>. 229 * @return This object. 230 */ 231 public Builder decryptionCredential(Credential value) { 232 decryptionCredential = reqnn("value", value); 233 return this; 234 } 235 236 /** 237 * Sets the Service Provider entity ID used to validate {@code <AudienceRestriction>}. 238 * 239 * @param value The SP entity ID. Must not be <jk>null</jk> or blank. 240 * @return This object. 241 */ 242 public Builder spEntityId(String value) { 243 spEntityId = reqnb("value", value); 244 return this; 245 } 246 247 /** 248 * Sets the expected IdP issuer (the {@code <Issuer>} element). Required. 249 * 250 * @param value The expected IdP entity ID. Must not be <jk>null</jk> or blank. 251 * @return This object. 252 */ 253 public Builder expectedIssuer(String value) { 254 expectedIssuer = reqnb("value", value); 255 return this; 256 } 257 258 /** 259 * Replaces the default signature-algorithm allowlist with the supplied URIs. 260 * 261 * <p> 262 * SHA-1 algorithms are silently filtered out (permanent rejection). 263 * 264 * @param values The XML-DSig algorithm URIs to allow. Must contain at least one non-SHA-1 entry. 265 * @return This object. 266 */ 267 public Builder algorithms(String... values) { 268 reqnn("values", values); 269 if (values.length == 0) 270 throw iaex("algorithms allowlist must be non-empty"); 271 var next = new LinkedHashSet<String>(); 272 for (var a : values) { 273 reqnn("algorithm", a); 274 if (a.toLowerCase(Locale.ROOT).contains("sha1")) 275 throw iaex("SHA-1 algorithms are permanently rejected: %s", a); 276 next.add(a); 277 } 278 algorithms = next; 279 return this; 280 } 281 282 /** 283 * Replaces the default reference-digest allowlist with the supplied URIs. 284 * 285 * <p> 286 * The reference {@code DigestMethod} of the enveloped signature must appear in this set. SHA-1 287 * algorithms are silently filtered out (permanent rejection), mirroring {@link #algorithms(String...)}. 288 * 289 * @param values The XML-DSig digest algorithm URIs to allow. Must contain at least one non-SHA-1 entry. 290 * @return This object. 291 */ 292 public Builder digestAlgorithms(String... values) { 293 reqnn("values", values); 294 if (values.length == 0) 295 throw iaex("digest allowlist must be non-empty"); 296 var next = new LinkedHashSet<String>(); 297 for (var a : values) { 298 reqnn("digestAlgorithm", a); 299 if (a.toLowerCase(Locale.ROOT).contains("sha1")) 300 throw iaex("SHA-1 digest algorithms are permanently rejected: %s", a); 301 next.add(a); 302 } 303 digestAlgorithms = next; 304 return this; 305 } 306 307 /** 308 * Sets the expected {@code Recipient} (this SP's Assertion Consumer Service URL) and, by doing so, 309 * enables bearer {@code <SubjectConfirmation>} validation. 310 * 311 * <p> 312 * When set, every validated assertion must carry a {@code bearer} {@code <SubjectConfirmation>} whose 313 * {@code <SubjectConfirmationData>} names this exact {@code Recipient}, is within its 314 * {@code NotBefore}/{@code NotOnOrAfter} window, and satisfies the {@link #expectedInResponseTo(String)} 315 * / {@link #subjectAddress(String)} expectations. When left unset (the default), subject-confirmation 316 * validation is skipped for calls to {@link SamlAssertionValidator#validate(String)} — safe only 317 * for assertion-only / non-browser callers. A validator reached through {@link SamlAuthFilter} enforces 318 * bearer confirmation regardless of this setting, since the filter derives the recipient from the 319 * request and calls {@link SamlAssertionValidator#validate(String, String)}. 320 * 321 * @param value The expected ACS URL. Must not be <jk>null</jk> or blank. 322 * @return This object. 323 */ 324 public Builder recipient(String value) { 325 recipient = reqnb("value", value); 326 return this; 327 } 328 329 /** 330 * Sets the request ID a bearer {@code <SubjectConfirmationData>} must echo in its {@code InResponseTo} 331 * attribute. 332 * 333 * <p> 334 * Only consulted when {@link #recipient(String)} is configured. When set, the confirmation's 335 * {@code InResponseTo} must equal this value (a solicited, SP-initiated flow). When left unset (the 336 * default), the confirmation must <i>not</i> carry an {@code InResponseTo} at all (an unsolicited, 337 * IdP-initiated flow) — a captured solicited assertion cannot be injected into the unsolicited 338 * path. 339 * 340 * @param value The expected request ID. Must not be <jk>null</jk> or blank. 341 * @return This object. 342 */ 343 public Builder expectedInResponseTo(String value) { 344 expectedInResponseTo = reqnb("value", value); 345 return this; 346 } 347 348 /** 349 * Sets the client address a bearer {@code <SubjectConfirmationData>} must name in its {@code Address} 350 * attribute. 351 * 352 * <p> 353 * Only consulted when {@link #recipient(String)} is configured. When set, the confirmation's 354 * {@code Address} must equal this value; when unset (the default) the {@code Address} attribute is not 355 * checked. 356 * 357 * @param value The expected client address. Must not be <jk>null</jk> or blank. 358 * @return This object. 359 */ 360 public Builder subjectAddress(String value) { 361 expectedAddress = reqnb("value", value); 362 return this; 363 } 364 365 /** 366 * Overrides the {@link ReplayCache} used to enforce one-time use of each assertion ID. 367 * 368 * <p> 369 * Defaults to a per-process {@link InMemoryReplayCache}, which enforces single-use within one JVM. 370 * Multi-node deployments should inject a {@link ReplayCache} backed by a store shared across every node 371 * so an assertion consumed against one node cannot be replayed against another. The check is always 372 * fail-closed: a cache that cannot answer causes the assertion to be rejected. 373 * 374 * @param value The replay cache. Must not be <jk>null</jk>. 375 * @return This object. 376 */ 377 public Builder replayCache(ReplayCache value) { 378 replayCache = reqnn("value", value); 379 return this; 380 } 381 382 /** 383 * Sets the clock-skew tolerance for {@code NotBefore} / {@code NotOnOrAfter} validation. 384 * 385 * @param value The tolerance. Must be non-negative and not exceed 5 minutes. 386 * @return This object. 387 */ 388 public Builder clockSkew(Duration value) { 389 reqnn("value", value); 390 if (value.isNegative()) 391 throw iaex("clockSkew must be non-negative"); 392 if (value.compareTo(MAX_CLOCK_SKEW) > 0) 393 throw iaex("clockSkew must not exceed 5 minutes (was %s)", value); 394 clockSkew = value; 395 return this; 396 } 397 398 /** 399 * Overrides the {@link Clock} used for timestamp validation. Useful in tests. 400 * 401 * @param value The clock. Must not be <jk>null</jk>. 402 * @return This object. 403 */ 404 public Builder clock(Clock value) { 405 clock = reqnn("value", value); 406 return this; 407 } 408 409 /** 410 * Builds the validator. 411 * 412 * @return A new {@link SamlAssertionValidator}. 413 */ 414 public SamlAssertionValidator build() { 415 if (spEntityId == null) 416 throw new IllegalStateException("SamlAssertionValidator requires spEntityId(...)"); 417 if (expectedIssuer == null) 418 throw new IllegalStateException("SamlAssertionValidator requires expectedIssuer(...)"); 419 if (metadataResolver == null && signingCredential == null) 420 throw new IllegalStateException("SamlAssertionValidator requires either metadataResolver(...) or signingCredential(...)"); 421 if (metadataResolver != null && signingCredential != null) 422 throw new IllegalStateException("SamlAssertionValidator: metadataResolver(...) and signingCredential(...) are mutually exclusive"); 423 return new SamlAssertionValidator(this); 424 } 425 } 426 427 private final MetadataResolver metadataResolver; 428 private final Credential signingCredential; 429 private final Credential decryptionCredential; 430 private final String spEntityId; 431 private final String expectedIssuer; 432 private final String recipient; 433 private final String expectedInResponseTo; 434 private final String expectedAddress; 435 private final Set<String> algorithms; 436 private final Set<String> digestAlgorithms; 437 private final Duration clockSkew; 438 private final Clock clock; 439 private final ReplayCache replayCache; 440 441 /** 442 * Constructor. 443 * 444 * @param b The builder to read configuration from. 445 */ 446 protected SamlAssertionValidator(Builder b) { 447 OpenSamlBootstrap.ensureInitialized(); 448 this.metadataResolver = b.metadataResolver; 449 this.signingCredential = b.signingCredential; 450 this.decryptionCredential = b.decryptionCredential; 451 this.spEntityId = b.spEntityId; 452 this.expectedIssuer = b.expectedIssuer; 453 this.recipient = b.recipient; 454 this.expectedInResponseTo = b.expectedInResponseTo; 455 this.expectedAddress = b.expectedAddress; 456 this.algorithms = u(cp(b.algorithms)); 457 this.digestAlgorithms = u(cp(b.digestAlgorithms)); 458 this.clockSkew = b.clockSkew; 459 this.clock = b.clock; 460 this.replayCache = b.replayCache; 461 } 462 463 /** 464 * Returns the SP entity ID this validator enforces in {@code <AudienceRestriction>}. 465 * 466 * @return The configured SP entity ID. 467 */ 468 public String getSpEntityId() { 469 return spEntityId; 470 } 471 472 /** 473 * Returns the expected IdP {@code <Issuer>} value. 474 * 475 * @return The expected issuer. 476 */ 477 public String getExpectedIssuer() { 478 return expectedIssuer; 479 } 480 481 /** 482 * Returns the configured signature algorithm allowlist. 483 * 484 * @return The unmodifiable allowlist. 485 */ 486 public Set<String> getAlgorithms() { 487 return algorithms; 488 } 489 490 /** 491 * Returns the configured clock-skew tolerance. 492 * 493 * @return The clock skew. 494 */ 495 public Duration getClockSkew() { 496 return clockSkew; 497 } 498 499 /** 500 * Validates the supplied SAML 2.0 {@code <samlp:Response>} XML and returns the resolved principal. 501 * 502 * @param xml The full XML payload (already base64-decoded; already URL-decoded and inflated for the 503 * REDIRECT binding). Must be a {@code <samlp:Response>} envelope. 504 * @return A {@link ClaimsPrincipal} carrying the IdP-supplied claims plus the {@code issuerType=SAML} 505 * marker. 506 * @throws AuthenticationException If the response cannot be parsed or validation fails. 507 */ 508 public Principal validate(String xml) throws AuthenticationException { 509 reqnb("xml", xml); 510 return validateInternal(xml, recipient); 511 } 512 513 /** 514 * Validates the supplied SAML 2.0 {@code <samlp:Response>} XML, enforcing bearer 515 * {@code <SubjectConfirmation>} against the given {@code recipient} for this call. 516 * 517 * <p> 518 * Unlike {@link #validate(String)}, bearer subject-confirmation is always enforced here — 519 * {@code recipient} overrides whatever {@link Builder#recipient(String)} this validator was built with 520 * (including unset). {@link SamlAuthFilter} calls this overload with the ACS URL derived from the 521 * current request so that a validator built without {@link Builder#recipient(String)} configured still 522 * rejects an assertion bearer-confirmed to a different endpoint on the filter path. 523 * 524 * @param xml The full XML payload (already base64-decoded; already URL-decoded and inflated for the 525 * REDIRECT binding). Must be a {@code <samlp:Response>} envelope. 526 * @param recipient The expected {@code Recipient} (this SP's ACS URL) for bearer subject-confirmation. 527 * Must not be <jk>null</jk> or blank. 528 * @return A {@link ClaimsPrincipal} carrying the IdP-supplied claims plus the {@code issuerType=SAML} 529 * marker. 530 * @throws AuthenticationException If the response cannot be parsed or validation fails. 531 */ 532 public Principal validate(String xml, String recipient) throws AuthenticationException { 533 reqnb("xml", xml); 534 reqnb("recipient", recipient); 535 return validateInternal(xml, recipient); 536 } 537 538 private Principal validateInternal(String xml, String recipient) throws AuthenticationException { 539 var response = parseResponse(xml); 540 validateStatus(response); 541 var assertion = extractAndDecryptAssertion(response); 542 verifySignature(assertion); 543 var claims = buildClaims(response, assertion); 544 validateSubjectConfirmation(assertion, recipient); 545 recordSingleUse(assertion); 546 var subject = nameId(assertion); 547 return new ClaimsPrincipal(subject, claims); 548 } 549 550 private void validateStatus(Response response) throws AuthenticationException { 551 var status = response.getStatus(); 552 var code = status == null ? null : status.getStatusCode(); 553 var value = code == null ? null : code.getValue(); 554 if (! StatusCode.SUCCESS.equals(value)) 555 throw new AuthenticationException("SAML response status is not Success: " + value) 556 .wwwAuthenticate("SAML error=\"invalid_response\""); 557 } 558 559 private Response parseResponse(String xml) throws AuthenticationException { 560 try { 561 var doc = SamlXml.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); 562 Element root = doc.getDocumentElement(); 563 var registry = org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport.getUnmarshallerFactory(); 564 var unmarshaller = registry.getUnmarshaller(root); 565 if (unmarshaller == null) 566 throw new AuthenticationException("Unknown SAML element: " + root.getNodeName()) 567 .wwwAuthenticate("SAML error=\"invalid_response\""); 568 var obj = unmarshaller.unmarshall(root); 569 if (!(obj instanceof Response obj2)) 570 throw new AuthenticationException("SAML envelope is not a <Response>: " + cns(obj)) 571 .wwwAuthenticate("SAML error=\"invalid_response\""); 572 return obj2; 573 } catch (UnmarshallingException | ParserConfigurationException | IOException | org.xml.sax.SAXException e) { 574 throw new AuthenticationException(e, "SAML response could not be parsed") 575 .wwwAuthenticate("SAML error=\"invalid_response\""); 576 } 577 } 578 579 private Assertion extractAndDecryptAssertion(Response response) throws AuthenticationException { 580 var assertions = response.getAssertions(); 581 var encrypted = response.getEncryptedAssertions(); 582 if (assertions.isEmpty() && encrypted.isEmpty()) 583 throw new AuthenticationException("SAML response contains no <Assertion>") 584 .wwwAuthenticate("SAML error=\"invalid_response\""); 585 if (!encrypted.isEmpty()) { 586 if (decryptionCredential == null) 587 throw new AuthenticationException("SAML response contains <EncryptedAssertion> but no decryption credential is configured") 588 .wwwAuthenticate("SAML error=\"decryption_required\""); 589 var resolver = (KeyInfoCredentialResolver) new StaticKeyInfoCredentialResolver(decryptionCredential); 590 EncryptedKeyResolver keyResolver = new ChainingEncryptedKeyResolver(List.of( 591 new InlineEncryptedKeyResolver(), 592 new SimpleRetrievalMethodEncryptedKeyResolver() 593 )); 594 var decrypter = new Decrypter(null, resolver, keyResolver); 595 decrypter.setRootInNewDocument(true); 596 try { 597 return decrypter.decrypt(encrypted.get(0)); 598 } catch (org.opensaml.xmlsec.encryption.support.DecryptionException e) { 599 throw new AuthenticationException(e, "SAML <EncryptedAssertion> could not be decrypted") 600 .wwwAuthenticate("SAML error=\"decryption_failed\""); 601 } 602 } 603 return assertions.get(0); 604 } 605 606 private void verifySignature(Assertion assertion) throws AuthenticationException { 607 var signature = assertion.getSignature(); 608 if (signature == null) 609 throw new AuthenticationException("SAML assertion is unsigned") 610 .wwwAuthenticate("SAML error=\"signature_required\""); 611 612 var alg = signature.getSignatureAlgorithm(); 613 if (alg == null || !algorithms.contains(alg)) // HTT: alg==null unreachable; a signature with no SignatureMethod is rejected while parsing the response 614 throw new AuthenticationException("SAML signature algorithm not allowlisted: " + alg) 615 .wwwAuthenticate("SAML error=\"signature_algorithm_rejected\""); 616 617 validateDigestAlgorithms(signature); 618 619 // First, run the SAML signature profile validator to catch wrapping/structural attacks. 620 try { 621 new SAMLSignatureProfileValidator().validate(signature); 622 } catch (SignatureException e) { 623 throw new AuthenticationException(e, "SAML signature profile validation failed") 624 .wwwAuthenticate("SAML error=\"signature_invalid\""); 625 } 626 627 Credential credential = resolveSigningCredential(); 628 try { 629 SignatureValidator.validate(signature, credential); 630 } catch (SignatureException e) { 631 throw new AuthenticationException(e, "SAML signature verification failed") 632 .wwwAuthenticate("SAML error=\"signature_invalid\""); 633 } 634 } 635 636 private Credential resolveSigningCredential() throws AuthenticationException { 637 if (signingCredential != null) 638 return signingCredential; 639 var idp = resolveIdpDescriptor(); 640 var credential = extractSigningCredential(idp); 641 if (credential == null) 642 throw new AuthenticationException("No signing X.509 certificate in metadata for issuer: " + expectedIssuer) 643 .wwwAuthenticate("SAML error=\"metadata_invalid\""); 644 return credential; 645 } 646 647 private IDPSSODescriptor resolveIdpDescriptor() throws AuthenticationException { 648 try { 649 var criteria = new CriteriaSet( 650 new EntityIdCriterion(expectedIssuer), 651 new EntityRoleCriterion(IDPSSODescriptor.DEFAULT_ELEMENT_NAME) 652 ); 653 var entityDescriptor = metadataResolver.resolveSingle(criteria); 654 if (entityDescriptor == null) 655 throw new AuthenticationException("No SAML metadata for issuer: " + expectedIssuer) 656 .wwwAuthenticate("SAML error=\"metadata_missing\""); 657 var idp = entityDescriptor.getIDPSSODescriptor(SAMLConstants.SAML20P_NS); 658 if (idp == null) 659 throw new AuthenticationException("SAML metadata for issuer has no IDPSSODescriptor") 660 .wwwAuthenticate("SAML error=\"metadata_invalid\""); 661 return idp; 662 } catch (ResolverException e) { 663 throw new AuthenticationException(e, "SAML metadata resolution failed") 664 .wwwAuthenticate("SAML error=\"metadata_invalid\""); 665 } 666 } 667 668 private Credential extractSigningCredential(IDPSSODescriptor idp) { 669 for (KeyDescriptor kd : idp.getKeyDescriptors()) { 670 var credential = credentialFromKeyDescriptor(kd); 671 if (credential != null) 672 return credential; 673 } 674 return null; 675 } 676 677 private Credential credentialFromKeyDescriptor(KeyDescriptor kd) { 678 if (!isSigningKey(kd)) 679 return null; 680 var keyInfo = kd.getKeyInfo(); 681 if (keyInfo == null) 682 return null; 683 for (var x509 : keyInfo.getX509Datas()) { 684 var credential = credentialFromX509Data(x509); 685 if (credential != null) 686 return credential; 687 } 688 return null; 689 } 690 691 private Credential credentialFromX509Data(X509Data x509) { 692 for (var cert : x509.getX509Certificates()) { 693 var base64 = cert.getValue(); 694 if (base64 == null) 695 continue; 696 var credential = parseX509Credential(base64); 697 if (credential != null) 698 return credential; 699 } 700 return null; 701 } 702 703 private static boolean isSigningKey(KeyDescriptor kd) { 704 return kd.getUse() == null || kd.getUse() == UsageType.SIGNING || kd.getUse() == UsageType.UNSPECIFIED; 705 } 706 707 private BasicX509Credential parseX509Credential(String base64) { 708 try { 709 var bytes = Base64.getMimeDecoder().decode(base64); 710 var cf = java.security.cert.CertificateFactory.getInstance("X.509"); 711 var certificate = (java.security.cert.X509Certificate) cf.generateCertificate(new ByteArrayInputStream(bytes)); 712 // The metadata signing cert is the trust anchor; reject one outside its validity window so an 713 // expired (or not-yet-valid) cert can no longer be used to verify assertions. 714 certificate.checkValidity(Date.from(clock.instant())); 715 return new BasicX509Credential(certificate); 716 } catch (java.security.cert.CertificateException | IllegalArgumentException e) { 717 return null; // Expired/not-yet-valid or malformed cert: caller tries the next certificate. 718 } 719 } 720 721 private Map<String,Object> buildClaims(Response response, Assertion assertion) throws AuthenticationException { 722 validateConditions(assertion); 723 validateIssuer(response, assertion); 724 725 Map<String,Object> claims = m(ISSUER_TYPE_CLAIM, ISSUER_TYPE_SAML, "iss", expectedIssuer, "aud", spEntityId); 726 727 for (var stmt : assertion.getAttributeStatements()) { 728 for (var attr : stmt.getAttributes()) { 729 var name = attr.getName(); 730 if (name == null) 731 continue; 732 var values = l(); 733 for (var av : attr.getAttributeValues()) 734 values.add(extractStringValue(av)); 735 if (values.size() == 1) 736 claims.put(name, values.get(0)); 737 else if (!values.isEmpty()) 738 claims.put(name, values); 739 } 740 } 741 return claims; 742 } 743 744 private void validateConditions(Assertion assertion) throws AuthenticationException { 745 var conditions = assertion.getConditions(); 746 if (conditions == null) 747 throw rejectAssertion("missing <Conditions>"); 748 749 var now = clock.instant(); 750 var nbf = conditions.getNotBefore(); 751 var noa = conditions.getNotOnOrAfter(); 752 if (nbf != null && nbf.minus(clockSkew).isAfter(now)) 753 throw rejectAssertion("assertion not yet valid"); 754 if (noa != null && !noa.plus(clockSkew).isAfter(now)) 755 throw rejectAssertion("assertion has expired"); 756 757 boolean audienceOk = false; 758 for (var ar : conditions.getAudienceRestrictions()) { 759 for (var aud : ar.getAudiences()) { 760 if (eq(spEntityId, aud.getURI())) { 761 audienceOk = true; 762 break; 763 } 764 } 765 } 766 if (!audienceOk) 767 throw rejectAssertion("audience does not include SP entity ID: " + spEntityId); 768 } 769 770 private void validateIssuer(Response response, Assertion assertion) throws AuthenticationException { 771 var assertionIssuer = assertion.getIssuer(); 772 if (assertionIssuer == null || neq(expectedIssuer, assertionIssuer.getValue())) 773 throw rejectAssertion("assertion <Issuer> does not match expected issuer"); 774 var respIssuer = response.getIssuer(); 775 if (respIssuer != null && neq(expectedIssuer, respIssuer.getValue())) 776 throw rejectAssertion("response <Issuer> does not match expected issuer"); 777 } 778 779 private void validateDigestAlgorithms(org.opensaml.xmlsec.signature.Signature signature) throws AuthenticationException { 780 var dom = signature.getDOM(); 781 if (dom == null) // HTT: null DOM unreachable; a verifiable signature is always marshalled to DOM 782 return; // HTT: null DOM unreachable; a verifiable signature is always marshalled to DOM 783 var digestNodes = dom.getElementsByTagNameNS(XMLSIG_NS, "DigestMethod"); 784 for (var i = 0; i < digestNodes.getLength(); i++) { 785 var alg = ((Element) digestNodes.item(i)).getAttribute("Algorithm"); 786 if (alg == null || alg.isEmpty() || alg.toLowerCase(Locale.ROOT).contains("sha1") || !digestAlgorithms.contains(alg)) // HTT: the alg==null clause is unreachable because Element.getAttribute never returns null; kept as a fail-closed guard 787 throw new AuthenticationException("SAML signature digest algorithm not allowlisted: " + alg) 788 .wwwAuthenticate("SAML error=\"signature_algorithm_rejected\""); 789 } 790 } 791 792 private void validateSubjectConfirmation(Assertion assertion, String recipient) throws AuthenticationException { 793 if (recipient == null) // Bearer subject-confirmation validation is enabled by configuring recipient(...) or calling validate(xml, recipient). 794 return; 795 var subject = assertion.getSubject(); 796 if (subject == null) 797 throw rejectAssertion("missing <Subject> for bearer confirmation"); 798 String failure = null; 799 var sawBearer = false; 800 for (var sc : subject.getSubjectConfirmations()) { 801 if (! SubjectConfirmation.METHOD_BEARER.equals(sc.getMethod())) 802 continue; 803 sawBearer = true; 804 var reason = confirmationFailureReason(sc, recipient); 805 if (reason == null) 806 return; // A valid bearer confirmation was found. 807 failure = reason; 808 } 809 if (! sawBearer) 810 throw rejectAssertion("missing bearer <SubjectConfirmation>"); 811 throw rejectAssertion(failure); 812 } 813 814 private String confirmationFailureReason(SubjectConfirmation sc, String recipient) { 815 var data = sc.getSubjectConfirmationData(); 816 if (data == null) 817 return "bearer <SubjectConfirmationData> is missing"; 818 if (neq(recipient, data.getRecipient())) 819 return "bearer confirmation <Recipient> does not match the expected ACS URL"; 820 var now = clock.instant(); 821 var noa = data.getNotOnOrAfter(); 822 if (noa == null) 823 return "bearer confirmation is missing NotOnOrAfter"; 824 if (! noa.plus(clockSkew).isAfter(now)) 825 return "bearer confirmation has expired"; 826 var nbf = data.getNotBefore(); 827 if (nbf != null && nbf.minus(clockSkew).isAfter(now)) 828 return "bearer confirmation is not yet valid"; 829 var inResponseTo = data.getInResponseTo(); 830 if (expectedInResponseTo != null) { 831 if (neq(expectedInResponseTo, inResponseTo)) 832 return "bearer confirmation <InResponseTo> does not match the expected request ID"; 833 } else if (inResponseTo != null && ! inResponseTo.isEmpty()) { // HTT: OpenSAML trims an empty InResponseTo to null, so the isEmpty() branch cannot run; kept as a fail-closed guard 834 return "unsolicited bearer confirmation must not carry <InResponseTo>"; 835 } 836 if (expectedAddress != null && neq(expectedAddress, data.getAddress())) 837 return "bearer confirmation <Address> does not match the expected client address"; 838 return null; 839 } 840 841 private void recordSingleUse(Assertion assertion) throws AuthenticationException { 842 var id = assertion.getID(); 843 if (id == null || id.isEmpty()) // HTT: OpenSAML trims an empty ID to null, so the isEmpty() branch cannot run; kept as a fail-closed guard 844 throw rejectAssertion("assertion has no ID for single-use enforcement"); 845 var notOnOrAfter = resolveNotOnOrAfter(assertion); 846 if (notOnOrAfter == null) 847 throw rejectAssertion("assertion has no NotOnOrAfter to bound one-time use"); 848 var expiresAtMs = notOnOrAfter.plus(clockSkew).toEpochMilli(); 849 var firstSeen = replayCache.checkAndRecord(id, expiresAtMs, ReplayCache.FailMode.FAIL_CLOSED, 850 e -> LOG.log(Level.WARNING, e, () -> "SAML assertion single-use check failed; rejecting (fail-closed).")); 851 if (! firstSeen) 852 throw new AuthenticationException("SAML assertion has already been presented") 853 .wwwAuthenticate("SAML error=\"assertion_replayed\""); 854 } 855 856 /** 857 * Resolves the effective {@code NotOnOrAfter} that bounds one-time use of the assertion. 858 * 859 * <p> 860 * The resolved value is the latest of the assertion's {@code Conditions/NotOnOrAfter} and any bearer 861 * {@code SubjectConfirmationData/NotOnOrAfter}. Returns <jk>null</jk> when the assertion carries neither 862 * — in that case its validity window is unbounded, so single-use cannot be enforced past a cache 863 * eviction and the caller rejects the assertion rather than inventing a short fallback that later re-opens 864 * replay. {@code <Conditions>} itself is guaranteed non-<jk>null</jk> here (a null {@code <Conditions>} is 865 * rejected earlier by {@link #validateConditions(Assertion)}). 866 * 867 * @param assertion The assertion. 868 * @return The latest bounding instant, or <jk>null</jk> if the assertion carries no {@code NotOnOrAfter}. 869 */ 870 private static Instant resolveNotOnOrAfter(Assertion assertion) { 871 var result = assertion.getConditions().getNotOnOrAfter(); 872 var subject = assertion.getSubject(); 873 if (subject != null) { 874 for (var sc : subject.getSubjectConfirmations()) { 875 var noa = bearerNotOnOrAfter(sc); 876 if (noa != null && (result == null || noa.isAfter(result))) 877 result = noa; 878 } 879 } 880 return result; 881 } 882 883 /** 884 * Returns the {@code NotOnOrAfter} of {@code sc}'s {@code <SubjectConfirmationData>} if {@code sc} is a 885 * bearer confirmation carrying one, or <jk>null</jk> otherwise. 886 * 887 * @param sc The subject confirmation. 888 * @return The bearer confirmation's {@code NotOnOrAfter}, or <jk>null</jk>. 889 */ 890 private static Instant bearerNotOnOrAfter(SubjectConfirmation sc) { 891 if (! SubjectConfirmation.METHOD_BEARER.equals(sc.getMethod())) 892 return null; 893 var data = sc.getSubjectConfirmationData(); 894 return data == null ? null : data.getNotOnOrAfter(); 895 } 896 897 private static String extractStringValue(org.opensaml.core.xml.XMLObject av) { 898 if (av == null) // HTT: null av unreachable; OpenSAML attribute value lists never contain null entries 899 return null; // HTT: null av unreachable; OpenSAML attribute value lists never contain null entries 900 var dom = av.getDOM(); 901 if (dom != null) // HTT: the response is always parsed from XML, so every unmarshalled value carries its DOM 902 return dom.getTextContent(); 903 return av.toString(); // HTT: the response is always parsed from XML, so every unmarshalled value carries its DOM 904 } 905 906 private static String nameId(Assertion assertion) { 907 var subject = assertion.getSubject(); 908 if (subject == null) 909 return "<no-subject>"; 910 var nameID = subject.getNameID(); 911 if (nameID != null && nameID.getValue() != null) 912 return nameID.getValue(); 913 return "<no-subject>"; 914 } 915 916 private static AuthenticationException rejectAssertion(String reason) { 917 return new AuthenticationException("SAML assertion rejected: " + reason) 918 .wwwAuthenticate("SAML error=\"invalid_assertion\""); 919 } 920}