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> &mdash; 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> &mdash; 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> &mdash; the {@code <samlp:Status>} must report the {@code Success} status code.
068 *    <li><b>Clock skew</b> &mdash; defaults to 60 seconds tolerance on {@code NotBefore} /
069 *       {@code NotOnOrAfter}; capped at 300 seconds.
070 *    <li><b>Audience restriction</b> &mdash; {@code <AudienceRestriction>} must list the configured SP entity ID.
071 *    <li><b>Bearer subject confirmation</b> &mdash; 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 &mdash; 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> &mdash; 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 &mdash; carrying neither a
085 *       {@code Conditions/NotOnOrAfter} nor a bearer {@code SubjectConfirmationData/NotOnOrAfter} &mdash; 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> &mdash; 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)} &mdash; 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) &mdash; 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 &mdash;
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    * &mdash; 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}