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> — 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> — 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> — 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}