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.net.*;
023import java.net.http.*;
024import java.nio.*;
025import java.nio.file.*;
026import java.security.cert.*;
027import java.time.*;
028import java.util.*;
029import java.util.concurrent.*;
030
031import org.apache.juneau.commons.utils.*;
032import org.opensaml.saml.metadata.resolver.*;
033import org.opensaml.saml.metadata.resolver.filter.*;
034import org.opensaml.saml.metadata.resolver.filter.impl.*;
035import org.opensaml.saml.metadata.resolver.impl.*;
036import org.opensaml.security.credential.impl.*;
037import org.opensaml.security.criteria.*;
038import org.opensaml.security.x509.*;
039import org.opensaml.xmlsec.config.impl.*;
040import org.opensaml.xmlsec.signature.support.impl.*;
041import org.w3c.dom.*;
042
043import net.shibboleth.shared.component.*;
044import net.shibboleth.shared.resolver.*;
045import net.shibboleth.shared.xml.impl.*;
046
047/**
048 * Convenience factories for building {@link MetadataResolver} instances backed by an OpenSAML implementation.
049 *
050 * <p>
051 * Juneau ships <i>convenience factories only</i> &mdash; no bundled default
052 * {@link MetadataResolver} singleton.  Applications either:
053 * <ul>
054 *    <li>Call {@link #file(File)} for a local SAML metadata file (typical dev/staging setup).
055 *    <li>Call {@link #url(String)} for a remote {@code /metadata} endpoint (typical production setup).
056 *    <li>Build their own {@link MetadataResolver} directly using OpenSAML APIs (advanced).
057 * </ul>
058 *
059 * <p>
060 * Both factories return resolvers that are <b>already initialized</b> &mdash; ready to hand to
061 * {@link SamlAssertionValidator.Builder#metadataResolver(MetadataResolver)} immediately.
062 *
063 * <h5 class='section'>Notes:</h5>
064 * <p>
065 * The URL factory uses the JDK {@link HttpClient} (Java 17) to fetch the metadata blob once at construction
066 * time and wraps the parsed DOM in a {@link DOMMetadataResolver}.  No background refresh is performed; applications
067 * that need periodic refresh should construct their own {@code HTTPMetadataResolver} (from
068 * {@code opensaml-saml-impl}) with an Apache HttpClient + refresh policy.
069 *
070 * @since 10.0.0
071 */
072public final class SamlMetadataResolvers {
073
074   private SamlMetadataResolvers() {}
075
076   /**
077    * Creates a {@link MetadataResolver} that loads SAML 2.0 metadata from the given file.
078    *
079    * <p>
080    * The file is parsed at construction time and re-parsed on disk modification by the underlying
081    * {@link FilesystemMetadataResolver}.
082    *
083    * @param file The metadata XML file on the local filesystem.  Must exist and be readable.
084    * @return An initialized {@link MetadataResolver}.
085    * @throws IOException If the file cannot be parsed or initialized.
086    */
087   public static MetadataResolver file(File file) throws IOException {
088      if (file == null)
089         throw new IllegalArgumentException("file must not be null");
090      if (!file.isFile())
091         throw new FileNotFoundException("SAML metadata file not found: " + file);
092
093      OpenSamlBootstrap.ensureInitialized();
094      try {
095         var parserPool = new BasicParserPool();
096         parserPool.initialize();
097
098         var resolver = new FilesystemMetadataResolver(file);
099         resolver.setId(file.getAbsolutePath());
100         resolver.setParserPool(parserPool);
101         resolver.setRequireValidMetadata(true);
102         resolver.initialize();
103         return resolver;
104      } catch (ComponentInitializationException | ResolverException e) {
105         throw ioex(e, "Failed to initialize FilesystemMetadataResolver for %s", file);
106      }
107   }
108
109   /**
110    * Creates a {@link MetadataResolver} that fetches SAML 2.0 metadata from the given URL once and serves it
111    * from an in-memory DOM.
112    *
113    * <p>
114    * Fetch happens on the calling thread with a 30-second connect/request timeout.  Applications that need
115    * periodic refresh should construct an OpenSAML {@code HTTPMetadataResolver} directly with an Apache
116    * HttpClient.
117    *
118    * <h5 class='section'>Transport:</h5>
119    * <p>
120    * The metadata blob supplies the trust-anchor signing certificate, so the transport must not be
121    * downgradeable by a network intermediary.  The URL must therefore use <js>"https"</js> or target a
122    * loopback host (<js>"localhost"</js>/<js>"127.0.0.1"</js>/<js>"::1"</js>, for local development); a
123    * plaintext <js>"http"</js> URL aimed at any other host is rejected.  When the IdP offers signed metadata,
124    * prefer {@link #url(String, X509Certificate)} so the metadata's own XML signature is verified against a
125    * pinned certificate independently of the transport.
126    *
127    * @param url The metadata URL.  Must use HTTPS or target a loopback host.
128    * @return An initialized {@link MetadataResolver}.
129    * @throws IOException If the URL cannot be fetched or the metadata is malformed.
130    */
131   public static MetadataResolver url(String url) throws IOException {
132      return url(url, null);
133   }
134
135   /**
136    * Creates a {@link MetadataResolver} that fetches SAML 2.0 metadata from the given URL and verifies the
137    * metadata's XML signature against the supplied pinned certificate before trusting it.
138    *
139    * <p>
140    * This is the strongest option: the metadata document's own enveloped signature is validated against
141    * <jv>metadataSigningCert</jv>, so a substituted or tampered metadata blob is rejected regardless of the
142    * transport used to fetch it.  The same transport rule as {@link #url(String)} still applies.
143    *
144    * <p>
145    * The response body is bounded to {@link SamlAuthFilter#DEFAULT_MAX_INFLATED_BYTES} (1 MiB); use
146    * {@link #url(String, X509Certificate, long)} to override that cap.
147    *
148    * @param url The metadata URL.  Must use HTTPS or target a loopback host.
149    * @param metadataSigningCert The certificate whose public key signed the metadata document.  When
150    *    <jk>null</jk>, no signature validation is performed (equivalent to {@link #url(String)}).
151    * @return An initialized {@link MetadataResolver}.
152    * @throws IOException If the URL cannot be fetched, the metadata is malformed, or its signature does not
153    *    verify against the pinned certificate.
154    */
155   public static MetadataResolver url(String url, X509Certificate metadataSigningCert) throws IOException {
156      return url(url, metadataSigningCert, SamlAuthFilter.DEFAULT_MAX_INFLATED_BYTES);
157   }
158
159   /**
160    * Creates a {@link MetadataResolver} that fetches SAML 2.0 metadata from the given URL, verifies the
161    * metadata's XML signature against the supplied pinned certificate (when provided), and bounds the fetched
162    * response body to <jv>maxMetadataBytes</jv>.
163    *
164    * <p>
165    * A malicious or compromised metadata endpoint must not be able to exhaust heap by returning an
166    * unbounded response.  The cap is enforced both against a declared {@code Content-Length} header (rejected
167    * up front) and against the actual streamed byte count (aborted mid-fetch), so a missing or lying
168    * {@code Content-Length} cannot bypass it.
169    *
170    * @param url The metadata URL.  Must use HTTPS or target a loopback host.
171    * @param metadataSigningCert The certificate whose public key signed the metadata document.  When
172    *    <jk>null</jk>, no signature validation is performed.
173    * @param maxMetadataBytes The maximum number of response bytes to accept before aborting the fetch.
174    * @return An initialized {@link MetadataResolver}.
175    * @throws IOException If the URL cannot be fetched, the response exceeds <jv>maxMetadataBytes</jv>, the
176    *    metadata is malformed, or its signature does not verify against the pinned certificate.
177    */
178   public static MetadataResolver url(String url, X509Certificate metadataSigningCert, long maxMetadataBytes) throws IOException {
179      if (url == null)
180         throw new IllegalArgumentException("url must not be null");
181      UriUtils.assertSecureOrLoopback(URI.create(url));
182
183      OpenSamlBootstrap.ensureInitialized();
184      try {
185         var client = HttpClient.newBuilder()
186            .connectTimeout(Duration.ofSeconds(30))
187            .build();
188         var req = HttpRequest.newBuilder()
189            .uri(URI.create(url))
190            .timeout(Duration.ofSeconds(30))
191            .GET()
192            .build();
193         var resp = client.send(req, boundedByteArrayBodyHandler(maxMetadataBytes));
194         if (resp.statusCode() < 200 || resp.statusCode() >= 300) // HTT: the < 200 branch is unreachable because the JDK HttpClient consumes 1xx responses itself
195            throw ioex("Failed to fetch SAML metadata from %s (HTTP %s)", url, resp.statusCode());
196
197         var doc = SamlXml.newDocumentBuilder().parse(new ByteArrayInputStream(resp.body()));
198         Element root = doc.getDocumentElement();
199
200         var resolver = new DOMMetadataResolver(root);
201         resolver.setId(url);
202         resolver.setRequireValidMetadata(true);
203         if (metadataSigningCert != null)
204            resolver.setMetadataFilter(signatureValidationFilter(metadataSigningCert));
205         resolver.initialize();
206         return resolver;
207      } catch (InterruptedException e) {
208         Thread.currentThread().interrupt();
209         throw ioex(e, "Interrupted while fetching SAML metadata from %s", url);
210      } catch (ComponentInitializationException | javax.xml.parsers.ParserConfigurationException
211            | org.xml.sax.SAXException e) {
212         throw ioex(e, "Failed to initialize DOMMetadataResolver for %s", url);
213      }
214   }
215
216   private static MetadataFilter signatureValidationFilter(X509Certificate cert) throws ComponentInitializationException {
217      var credential = new BasicX509Credential(cert);
218      var trustEngine = new ExplicitKeySignatureTrustEngine(
219         new StaticCredentialResolver(credential),
220         DefaultSecurityConfigurationBootstrap.buildBasicInlineKeyInfoCredentialResolver());
221      var filter = new SignatureValidationFilter(trustEngine);
222      filter.setDefaultCriteria(new CriteriaSet(new UsageCriterion(org.opensaml.security.credential.UsageType.SIGNING)));
223      filter.initialize();
224      return filter;
225   }
226
227   /**
228    * Creates a {@link MetadataResolver} that loads SAML 2.0 metadata from the given {@link Path}.
229    *
230    * <p>
231    * Convenience overload of {@link #file(File)}; useful for callers that have a {@link Path} on hand.
232    *
233    * @param path The metadata XML file as a {@link Path}.
234    * @return An initialized {@link MetadataResolver}.
235    * @throws IOException If the file cannot be parsed or initialized.
236    */
237   public static MetadataResolver file(Path path) throws IOException {
238      if (path == null)
239         throw new IllegalArgumentException("path must not be null");
240      return file(path.toFile());
241   }
242
243   /**
244    * Builds a {@link HttpResponse.BodyHandler} that rejects a declared {@code Content-Length} over
245    * <jv>maxBytes</jv> up front, and otherwise aborts streaming as soon as the actual byte count exceeds
246    * <jv>maxBytes</jv> &mdash; so a missing or understated {@code Content-Length} cannot bypass the cap.
247    */
248   private static HttpResponse.BodyHandler<byte[]> boundedByteArrayBodyHandler(long maxBytes) {
249      return responseInfo -> {
250         var declaredLength = responseInfo.headers().firstValueAsLong("Content-Length");
251         if (declaredLength.isPresent() && declaredLength.getAsLong() > maxBytes)
252            return BoundedByteArraySubscriber.rejected(maxBytes, declaredLength.getAsLong());
253         return new BoundedByteArraySubscriber(maxBytes);
254      };
255   }
256
257   /**
258    * A {@link HttpResponse.BodySubscriber} that accumulates the response body into a byte array, aborting
259    * (cancelling the upstream subscription and failing {@link #getBody()}) as soon as the accumulated byte
260    * count exceeds a fixed cap.
261    */
262   private static final class BoundedByteArraySubscriber implements HttpResponse.BodySubscriber<byte[]> {
263      private final long maxBytes;
264      private final ByteArrayOutputStream out = new ByteArrayOutputStream();
265      private final CompletableFuture<byte[]> future = new CompletableFuture<>();
266      private Flow.Subscription subscription;
267      private long total;
268
269      BoundedByteArraySubscriber(long maxBytes) {
270         this.maxBytes = maxBytes;
271      }
272
273      static BoundedByteArraySubscriber rejected(long maxBytes, long declaredLength) {
274         var subscriber = new BoundedByteArraySubscriber(maxBytes);
275         subscriber.future.completeExceptionally(
276            ioex("SAML metadata response declares Content-Length %s bytes, exceeding the %s-byte cap", declaredLength, maxBytes));
277         return subscriber;
278      }
279
280      @Override /* BodySubscriber */
281      public CompletionStage<byte[]> getBody() {
282         return future;
283      }
284
285      @Override /* Flow.Subscriber */
286      public void onSubscribe(Flow.Subscription subscription) {
287         if (this.subscription != null) { // Reactive Streams rule 2.5: cancel any subscription after the first.
288            subscription.cancel();
289            return;
290         }
291         if (future.isDone()) { // Already rejected via a declared Content-Length over the cap.
292            subscription.cancel();
293            return;
294         }
295         this.subscription = subscription; // Store before request(): a publisher may deliver onNext synchronously from inside it.
296         subscription.request(Long.MAX_VALUE);
297      }
298
299      @Override /* Flow.Subscriber */
300      public void onNext(List<ByteBuffer> item) {
301         if (future.isDone()) // Data arriving after cancel() is dropped.
302            return;
303         for (var buf : item) {
304            total += buf.remaining();
305            if (total > maxBytes) {
306               // Fail the body before cancelling, so a publisher that completes from inside cancel() cannot surface the truncated bytes.
307               future.completeExceptionally(ioex("SAML metadata response exceeds the %s-byte cap", maxBytes));
308               subscription.cancel();
309               return;
310            }
311            var bytes = new byte[buf.remaining()];
312            buf.get(bytes);
313            out.writeBytes(bytes);
314         }
315      }
316
317      @Override /* Flow.Subscriber */
318      public void onError(Throwable throwable) {
319         future.completeExceptionally(throwable);
320      }
321
322      @Override /* Flow.Subscriber */
323      public void onComplete() {
324         future.complete(out.toByteArray()); // No-op when a cap rejection already completed the future.
325      }
326   }
327}