View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.eclipse.aether.transport.jetty;
20  
21  import javax.net.ssl.SSLContext;
22  
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.net.URI;
26  import java.net.URISyntaxException;
27  import java.nio.file.Files;
28  import java.nio.file.Path;
29  import java.nio.file.StandardCopyOption;
30  import java.security.NoSuchAlgorithmException;
31  import java.util.ArrayList;
32  import java.util.Collection;
33  import java.util.HashMap;
34  import java.util.Map;
35  import java.util.TreeSet;
36  import java.util.concurrent.ExecutionException;
37  import java.util.concurrent.TimeUnit;
38  import java.util.concurrent.atomic.AtomicBoolean;
39  import java.util.concurrent.atomic.AtomicReference;
40  import java.util.function.Function;
41  import java.util.regex.Matcher;
42  
43  import org.eclipse.aether.ConfigurationProperties;
44  import org.eclipse.aether.ConfigurationProperties.HttpVersion;
45  import org.eclipse.aether.RepositorySystemSession;
46  import org.eclipse.aether.repository.AuthenticationContext;
47  import org.eclipse.aether.repository.RemoteRepository;
48  import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
49  import org.eclipse.aether.spi.connector.transport.GetTask;
50  import org.eclipse.aether.spi.connector.transport.PeekTask;
51  import org.eclipse.aether.spi.connector.transport.PutTask;
52  import org.eclipse.aether.spi.connector.transport.TransportTask;
53  import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
54  import org.eclipse.aether.spi.connector.transport.http.HttpTransportPropertiesBuilder;
55  import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
56  import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
57  import org.eclipse.aether.spi.io.PathProcessor;
58  import org.eclipse.aether.transfer.HttpTransportProperty;
59  import org.eclipse.aether.transfer.NoTransporterException;
60  import org.eclipse.aether.transfer.TransferCancelledException;
61  import org.eclipse.aether.transfer.TransferEvent;
62  import org.eclipse.aether.util.ConfigUtils;
63  import org.eclipse.aether.util.connector.transport.http.HttpTransporterUtils;
64  import org.eclipse.jetty.client.Authentication;
65  import org.eclipse.jetty.client.BasicAuthentication;
66  import org.eclipse.jetty.client.HttpClient;
67  import org.eclipse.jetty.client.HttpProxy;
68  import org.eclipse.jetty.client.InputStreamResponseListener;
69  import org.eclipse.jetty.client.Request;
70  import org.eclipse.jetty.client.Response;
71  import org.eclipse.jetty.client.transport.HttpClientConnectionFactory;
72  import org.eclipse.jetty.client.transport.HttpClientTransportDynamic;
73  import org.eclipse.jetty.http.HttpField;
74  import org.eclipse.jetty.http.HttpHeader;
75  import org.eclipse.jetty.http2.client.HTTP2Client;
76  import org.eclipse.jetty.http2.client.transport.ClientConnectionFactoryOverHTTP2;
77  import org.eclipse.jetty.http3.client.HTTP3Client;
78  import org.eclipse.jetty.http3.client.HTTP3ClientQuicConfiguration;
79  import org.eclipse.jetty.http3.client.transport.ClientConnectionFactoryOverHTTP3;
80  import org.eclipse.jetty.io.ClientConnectionFactory;
81  import org.eclipse.jetty.io.ClientConnector;
82  import org.eclipse.jetty.io.EndPoint.SslSessionData;
83  import org.eclipse.jetty.quic.quiche.client.QuicheClientQuicConfiguration;
84  import org.eclipse.jetty.quic.quiche.client.QuicheTransport;
85  import org.eclipse.jetty.util.ssl.SslContextFactory;
86  
87  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.ACCEPT_ENCODING;
88  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_LENGTH;
89  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE;
90  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE_PATTERN;
91  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.IF_UNMODIFIED_SINCE;
92  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.LAST_MODIFIED;
93  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.MULTIPLE_CHOICES;
94  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.PRECONDITION_FAILED;
95  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.RANGE;
96  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.USER_AGENT;
97  
98  /**
99   * A transporter for HTTP/HTTPS.
100  *
101  * @since 2.0.0
102  */
103 final class JettyTransporter extends AbstractTransporter implements HttpTransporter {
104     private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
105 
106     private final RepositorySystemSession session;
107 
108     private final RemoteRepository repository;
109 
110     private final ChecksumExtractor checksumExtractor;
111 
112     private final PathProcessor pathProcessor;
113 
114     private final URI baseUri;
115 
116     private final HttpClient client;
117 
118     private final int connectTimeout;
119 
120     private final int requestTimeout;
121 
122     private final Map<String, String> headers;
123 
124     private final boolean preemptiveAuth;
125 
126     private final boolean preemptivePutAuth;
127 
128     private final boolean sendRfc9457Accept;
129 
130     private final boolean insecure;
131 
132     private final AtomicReference<BasicAuthentication.BasicResult> basicServerAuthenticationResult;
133 
134     private final AtomicReference<BasicAuthentication.BasicResult> basicProxyAuthenticationResult;
135 
136     JettyTransporter(
137             RepositorySystemSession session,
138             RemoteRepository repository,
139             ChecksumExtractor checksumExtractor,
140             PathProcessor pathProcessor)
141             throws NoTransporterException {
142         this.session = session;
143         this.repository = repository;
144         this.checksumExtractor = checksumExtractor;
145         this.pathProcessor = pathProcessor;
146         try {
147             this.baseUri = HttpTransporterUtils.getBaseUri(repository);
148         } catch (URISyntaxException e) {
149             throw new NoTransporterException(repository, e.getMessage(), e);
150         }
151 
152         HashMap<String, String> headers = new HashMap<>();
153         String userAgent = HttpTransporterUtils.getUserAgent(session, repository);
154         if (userAgent != null) {
155             headers.put(USER_AGENT, userAgent);
156         }
157         Map<String, String> configuredHeaders = HttpTransporterUtils.getHttpHeaders(session, repository);
158         if (configuredHeaders != null) {
159             headers.putAll(configuredHeaders);
160         }
161 
162         this.headers = headers;
163 
164         this.connectTimeout = HttpTransporterUtils.getHttpConnectTimeout(session, repository);
165         this.requestTimeout = HttpTransporterUtils.getHttpRequestTimeout(session, repository);
166         this.preemptiveAuth = HttpTransporterUtils.isHttpPreemptiveAuth(session, repository);
167         this.preemptivePutAuth = HttpTransporterUtils.isHttpPreemptivePutAuth(session, repository);
168         this.sendRfc9457Accept = HttpTransporterUtils.isHttpSendRfc9457Accept(session, repository);
169         final String httpsSecurityMode = HttpTransporterUtils.getHttpsSecurityMode(session, repository);
170         this.insecure = ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode);
171 
172         this.basicServerAuthenticationResult = new AtomicReference<>(null);
173         this.basicProxyAuthenticationResult = new AtomicReference<>(null);
174         this.client = createClient();
175     }
176 
177     private void mayApplyPreemptiveAuth(Request request) {
178         if (basicServerAuthenticationResult.get() != null) {
179             basicServerAuthenticationResult.get().apply(request);
180         }
181         if (basicProxyAuthenticationResult.get() != null) {
182             basicProxyAuthenticationResult.get().apply(request);
183         }
184     }
185 
186     private URI resolve(TransportTask task) {
187         return baseUri.resolve(task.getLocation());
188     }
189 
190     @Override
191     protected void implPeek(PeekTask task) throws Exception {
192         Request request = client.newRequest(resolve(task)).method("HEAD");
193         request.headers(m -> headers.forEach(m::add));
194         if (preemptiveAuth) {
195             mayApplyPreemptiveAuth(request);
196         }
197         // capture raw response headers as described in https://github.com/jetty/jetty.project/discussions/14404
198         Map<String, HttpField> rawResponseHeaders = new HashMap<>();
199         request.onResponseHeader((r, field) -> {
200             rawResponseHeaders.put(field.getLowerCaseName(), field);
201             return true; // continue processing
202         });
203         Response response = request.send();
204         Map<TransferEvent.TransportPropertyKey, Object> transportProperties =
205                 createTransportProperties(request, rawResponseHeaders);
206         task.getListener().transportPropertiesAvailable(transportProperties);
207         if (response.getStatus() >= MULTIPLE_CHOICES) {
208             throw new HttpTransporterException(response.getStatus());
209         }
210     }
211 
212     @Override
213     protected void implGet(GetTask task) throws Exception {
214         boolean resume = task.getResumeOffset() > 0L && task.getDataPath() != null;
215         Response response;
216         InputStreamResponseListener listener;
217 
218         while (true) {
219             Request request = client.newRequest(resolve(task)).method("GET");
220             request.headers(m -> headers.forEach(m::add));
221             if (preemptiveAuth) {
222                 mayApplyPreemptiveAuth(request);
223             }
224             if (sendRfc9457Accept) {
225                 JettyRFC9457Reporter.INSTANCE.prepareRequest(request);
226             }
227             if (resume) {
228                 long resumeOffset = task.getResumeOffset();
229                 long lastModified =
230                         Files.getLastModifiedTime(task.getDataPath()).toMillis();
231                 request.headers(h -> {
232                     h.add(RANGE, "bytes=" + resumeOffset + '-');
233                     h.addDateField(IF_UNMODIFIED_SINCE, lastModified - MODIFICATION_THRESHOLD);
234                     h.remove(HttpHeader.ACCEPT_ENCODING);
235                     h.add(ACCEPT_ENCODING, "identity");
236                 });
237             }
238 
239             // capture raw response headers as described in https://github.com/jetty/jetty.project/discussions/14404
240             Map<String, HttpField> rawResponseHeaders = new HashMap<>();
241             request.onResponseHeader((r, field) -> {
242                 rawResponseHeaders.put(field.getLowerCaseName(), field);
243                 return true; // continue processing
244             });
245             listener = new InputStreamResponseListener();
246             request.send(listener);
247             try {
248                 response = listener.get(requestTimeout, TimeUnit.MILLISECONDS);
249             } catch (ExecutionException e) {
250                 Throwable t = e.getCause();
251                 if (t instanceof Exception) {
252                     throw (Exception) t;
253                 } else {
254                     throw new RuntimeException(t);
255                 }
256             }
257             Map<TransferEvent.TransportPropertyKey, Object> transportProperties =
258                     createTransportProperties(request, rawResponseHeaders);
259             task.getListener().transportPropertiesAvailable(transportProperties);
260             if (response.getStatus() >= MULTIPLE_CHOICES) {
261                 if (resume && response.getStatus() == PRECONDITION_FAILED) {
262                     resume = false;
263                     continue;
264                 }
265                 JettyRFC9457Reporter.INSTANCE.generateException(listener, (statusCode, reasonPhrase) -> {
266                     throw new HttpTransporterException(statusCode);
267                 });
268             }
269             break;
270         }
271 
272         long offset = 0L, length = response.getHeaders().getLongField(CONTENT_LENGTH);
273         if (resume) {
274             String range = response.getHeaders().get(CONTENT_RANGE);
275             if (range != null) {
276                 Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
277                 if (!m.matches()) {
278                     throw new IOException("Invalid Content-Range header for partial download: " + range);
279                 }
280                 offset = Long.parseLong(m.group(1));
281                 length = Long.parseLong(m.group(2)) + 1L;
282                 if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
283                     throw new IOException("Invalid Content-Range header for partial download from offset "
284                             + task.getResumeOffset() + ": " + range);
285                 }
286             }
287         }
288 
289         final boolean downloadResumed = offset > 0L;
290         final Path dataFile = task.getDataPath();
291         if (dataFile == null) {
292             try (InputStream is = listener.getInputStream()) {
293                 utilGet(task, is, true, length, downloadResumed);
294             }
295         } else {
296             try (PathProcessor.CollocatedTempFile tempFile = pathProcessor.newTempFile(dataFile)) {
297                 task.setDataPath(tempFile.getPath(), downloadResumed);
298                 if (downloadResumed && Files.isRegularFile(dataFile)) {
299                     try (InputStream inputStream = Files.newInputStream(dataFile)) {
300                         Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
301                     }
302                 }
303                 try (InputStream is = listener.getInputStream()) {
304                     utilGet(task, is, true, length, downloadResumed);
305                 }
306                 tempFile.move();
307             } finally {
308                 task.setDataPath(dataFile);
309             }
310         }
311         if (task.getDataPath() != null && response.getHeaders().getDateField(LAST_MODIFIED) != -1) {
312             long lastModified =
313                     response.getHeaders().getDateField(LAST_MODIFIED); // note: Wagon also does first not last
314             if (lastModified != -1) {
315                 pathProcessor.setLastModified(
316                         task.getDataPath(), HttpTransporterUtils.clampRemoteLastModified(lastModified));
317             }
318         }
319         Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
320         if (checksums != null && !checksums.isEmpty()) {
321             checksums.forEach(task::setChecksum);
322         }
323     }
324 
325     private Map<TransferEvent.TransportPropertyKey, Object> createTransportProperties(
326             Request request, Map<String, HttpField> rawResponseHeaders) {
327         HttpTransportPropertiesBuilder builder =
328                 new HttpTransportPropertiesBuilder(toHttpVersion(request.getVersion()));
329         SslSessionData sslSessionData = request.getConnection().getSslSessionData();
330         if (sslSessionData != null && sslSessionData.sslSession() != null) {
331             builder.withSslProtocol(sslSessionData.sslSession().getProtocol());
332             builder.withSslCipherSuite(sslSessionData.sslSession().getCipherSuite());
333         }
334         if (rawResponseHeaders.containsKey("content-encoding")) {
335             builder.withContentCoding(rawResponseHeaders.get("content-encoding").getValue());
336         }
337         return builder.build();
338     }
339 
340     static HttpTransportProperty.HttpVersion toHttpVersion(org.eclipse.jetty.http.HttpVersion version) {
341         switch (version) {
342             case HTTP_1_0:
343                 return HttpTransportProperty.HttpVersion.HTTP_1_0;
344             case HTTP_1_1:
345                 return HttpTransportProperty.HttpVersion.HTTP_1_1;
346             case HTTP_2:
347                 return HttpTransportProperty.HttpVersion.HTTP_2;
348             case HTTP_3:
349                 return HttpTransportProperty.HttpVersion.HTTP_3;
350             default:
351                 throw new IllegalArgumentException("Unknown version " + version.toString());
352         }
353     }
354 
355     private static Function<String, String> headerGetter(Response response) {
356         return s -> response.getHeaders().get(s);
357     }
358 
359     @Override
360     protected void implPut(PutTask task) throws Exception {
361         Request request = client.newRequest(resolve(task)).method("PUT");
362         request.headers(m -> headers.forEach(m::add));
363         if (sendRfc9457Accept) {
364             JettyRFC9457Reporter.INSTANCE.prepareRequest(request);
365         }
366         if (preemptiveAuth || preemptivePutAuth) {
367             mayApplyPreemptiveAuth(request);
368         }
369         request.body(PutTaskRequestContent.from(task));
370         // capture raw response headers as described in https://github.com/jetty/jetty.project/discussions/14404
371         Map<String, HttpField> rawResponseHeaders = new HashMap<>();
372         request.onResponseHeader((r, field) -> {
373             rawResponseHeaders.put(field.getLowerCaseName(), field);
374             return true; // continue processing
375         });
376         AtomicBoolean started = new AtomicBoolean(false);
377         Response response;
378         try (InputStreamResponseListener listener = new InputStreamResponseListener()) {
379             request.onRequestCommit(r -> {
380                         if (task.getDataLength() == 0) {
381                             if (started.compareAndSet(false, true)) {
382                                 try {
383                                     task.getListener().transportStarted(0, task.getDataLength());
384                                 } catch (TransferCancelledException e) {
385                                     r.abort(e);
386                                 }
387                             }
388                         }
389                     })
390                     .onRequestContent((r, b) -> {
391                         if (started.compareAndSet(false, true)) {
392                             try {
393                                 task.getListener().transportStarted(0, task.getDataLength());
394                             } catch (TransferCancelledException e) {
395                                 r.abort(e);
396                                 return;
397                             }
398                         }
399                         try {
400                             task.getListener().transportProgressed(b);
401                         } catch (TransferCancelledException e) {
402                             r.abort(e);
403                         }
404                     })
405                     .send(listener);
406             response = listener.get(requestTimeout, TimeUnit.MILLISECONDS);
407             task.getListener().transportPropertiesAvailable(createTransportProperties(request, rawResponseHeaders));
408             if (response.getStatus() >= MULTIPLE_CHOICES) {
409                 JettyRFC9457Reporter.INSTANCE.generateException(listener, (statusCode, reasonPhrase) -> {
410                     throw new HttpTransporterException(statusCode);
411                 });
412             }
413         } catch (ExecutionException e) {
414             Throwable t = e.getCause();
415             if (t instanceof IOException ioex) {
416                 if (ioex.getCause() instanceof TransferCancelledException) {
417                     throw (TransferCancelledException) ioex.getCause();
418                 } else {
419                     throw ioex;
420                 }
421             } else if (t instanceof Exception) {
422                 throw (Exception) t;
423             } else {
424                 throw new RuntimeException(t);
425             }
426         }
427     }
428 
429     @Override
430     protected void implClose() {
431         try {
432             this.client.stop();
433         } catch (Exception e) {
434             throw new RuntimeException(e);
435         }
436     }
437 
438     @SuppressWarnings("checkstyle:methodlength")
439     private HttpClient createClient() throws RuntimeException {
440         BasicAuthentication.BasicResult serverAuth = null;
441         BasicAuthentication.BasicResult proxyAuth = null;
442         SSLContext sslContext = null;
443         BasicAuthentication basicAuthentication = null;
444         try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
445             if (repoAuthContext != null) {
446                 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
447 
448                 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
449                 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
450 
451                 URI uri = URI.create(repository.getUrl());
452                 basicAuthentication = new BasicAuthentication(uri, Authentication.ANY_REALM, username, password);
453                 if (preemptiveAuth || preemptivePutAuth) {
454                     serverAuth = new BasicAuthentication.BasicResult(uri, HttpHeader.AUTHORIZATION, username, password);
455                 }
456             }
457         }
458 
459         SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
460         if (insecure) {
461             // this is also passed on to Quiche for HTTP/3
462             sslContextFactory.setEndpointIdentificationAlgorithm(null);
463             sslContextFactory.setHostnameVerifier((name, context) -> true);
464             sslContextFactory.setTrustAll(true);
465         } else {
466             try {
467                 if (sslContext == null) {
468                     // use the JVM's default SSL context (potentially with custom keystores/truststores)
469                     // https://github.com/jetty/jetty.project/issues/15378
470                     sslContext = SSLContext.getDefault();
471                 }
472             } catch (NoSuchAlgorithmException e) {
473                 throw new IllegalStateException("SSL Context setup failure", e);
474             }
475         }
476 
477         if (sslContext != null) {
478             // not properly supported by Quiche for HTTP/3, but Jetty will use it for HTTP/2 and HTTP/1.1
479             sslContextFactory.setSslContext(sslContext);
480         }
481 
482         ClientConnector clientConnector = new ClientConnector();
483         clientConnector.setSslContextFactory(sslContextFactory);
484 
485         Collection<ClientConnectionFactory.Info> connectors = new ArrayList<>();
486         if ("https".equalsIgnoreCase(repository.getProtocol())) {
487             HttpVersion httpVersion = HttpTransporterUtils.getHttpVersion(session, repository);
488             switch (httpVersion) {
489                 case MAXIMUM:
490                 case HTTP_3:
491                     QuicheClientQuicConfiguration clientQuicConfig =
492                             HTTP3ClientQuicConfiguration.configure(new QuicheClientQuicConfiguration());
493                     HTTP3Client http3Client = new HTTP3Client(clientQuicConfig, clientConnector);
494                     QuicheTransport transport = new QuicheTransport(clientQuicConfig);
495                     ClientConnectionFactoryOverHTTP3.HTTP3 http3 =
496                             new ClientConnectionFactoryOverHTTP3.HTTP3(http3Client, transport);
497                     connectors.add(http3);
498                     break; // fallback to HTTP/2 not supported yet (https://github.com/jetty/jetty.project/issues/15423)
499                 case HTTP_2:
500                 case DEFAULT:
501                     HTTP2Client http2Client = new HTTP2Client(clientConnector);
502                     ClientConnectionFactoryOverHTTP2.HTTP2 http2 =
503                             new ClientConnectionFactoryOverHTTP2.HTTP2(http2Client);
504                     connectors.add(http2);
505                     break;
506                 default:
507                     break;
508             }
509         }
510         connectors.add(HttpClientConnectionFactory.HTTP11); // HTTP/1.1, always supported but has least priority
511 
512         HttpClientTransportDynamic dynamicTransport = new HttpClientTransportDynamic(
513                 clientConnector, connectors.toArray(new ClientConnectionFactory.Info[0]));
514         HttpClient httpClient = new HttpClient(dynamicTransport);
515         httpClient.setConnectTimeout(connectTimeout);
516         httpClient.setIdleTimeout(requestTimeout);
517         httpClient.setFollowRedirects(ConfigUtils.getBoolean(
518                 session,
519                 JettyTransporterConfigurationKeys.DEFAULT_FOLLOW_REDIRECTS,
520                 JettyTransporterConfigurationKeys.CONFIG_PROP_FOLLOW_REDIRECTS));
521         httpClient.setMaxRedirects(ConfigUtils.getInteger(
522                 session,
523                 JettyTransporterConfigurationKeys.DEFAULT_MAX_REDIRECTS,
524                 JettyTransporterConfigurationKeys.CONFIG_PROP_MAX_REDIRECTS));
525 
526         boolean followInsecureRedirects = ConfigUtils.getBoolean(
527                 session,
528                 JettyTransporterConfigurationKeys.DEFAULT_FOLLOW_INSECURE_REDIRECTS,
529                 JettyTransporterConfigurationKeys.CONFIG_PROP_FOLLOW_INSECURE_REDIRECTS + "." + repository.getId(),
530                 JettyTransporterConfigurationKeys.CONFIG_PROP_FOLLOW_INSECURE_REDIRECTS);
531 
532         boolean originScopedHeaders = ConfigUtils.getBoolean(
533                 session,
534                 JettyTransporterConfigurationKeys.DEFAULT_ORIGIN_SCOPED_HEADERS,
535                 JettyTransporterConfigurationKeys.CONFIG_PROP_ORIGIN_SCOPED_HEADERS + "." + repository.getId(),
536                 JettyTransporterConfigurationKeys.CONFIG_PROP_ORIGIN_SCOPED_HEADERS);
537 
538         httpClient.setUserAgentField(null); // we manage it
539 
540         if (basicAuthentication != null) {
541             httpClient.getAuthenticationStore().addAuthentication(basicAuthentication);
542         }
543 
544         if (repository.getProxy() != null) {
545             HttpProxy proxy = new HttpProxy(
546                     repository.getProxy().getHost(), repository.getProxy().getPort());
547 
548             httpClient.getProxyConfiguration().addProxy(proxy);
549             try (AuthenticationContext proxyAuthContext = AuthenticationContext.forProxy(session, repository)) {
550                 if (proxyAuthContext != null) {
551                     String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
552                     String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
553 
554                     BasicAuthentication proxyAuthentication =
555                             new BasicAuthentication(proxy.getURI(), Authentication.ANY_REALM, username, password);
556 
557                     httpClient.getAuthenticationStore().addAuthentication(proxyAuthentication);
558                     if (preemptiveAuth || preemptivePutAuth) {
559                         proxyAuth = new BasicAuthentication.BasicResult(
560                                 proxy.getURI(), HttpHeader.PROXY_AUTHORIZATION, username, password);
561                     }
562                 }
563             }
564         }
565         if (serverAuth != null) {
566             this.basicServerAuthenticationResult.set(serverAuth);
567         }
568         if (proxyAuth != null) {
569             this.basicProxyAuthenticationResult.set(proxyAuth);
570         }
571 
572         try {
573             httpClient.start();
574 
575             // Register request listeners after start() so that Jetty's content decoder factories are
576             // fully initialized first.
577             if (!followInsecureRedirects && "https".equalsIgnoreCase(repository.getProtocol())) {
578                 // this client serves exactly one repository: any non-https request it ever issues can only be the
579                 // result of following a protocol-downgrading redirect, so refuse it before anything is sent
580                 httpClient.getRequestListeners().addQueuedListener(new InsecureRedirectGuard());
581             }
582             if (originScopedHeaders) {
583                 // Jetty's redirector copies the request headers onto every redirect hop it follows; scope configured
584                 // headers (and preemptively applied Authorization) to the repository origin so a cross-origin redirect
585                 // does not replay them to the redirect target. The copied redirect request passes through the
586                 // client-level request listeners again, same mechanism as the InsecureRedirectGuard above.
587                 // Challenge-based authentication from the authentication store is URI-scoped by Jetty already.
588                 //
589                 // Only register the listener when there are actual credentials or configured headers to protect:
590                 // without preemptive authentication or user-configured headers, there is nothing on the request
591                 // that could leak to a redirect target, so registering the listener would be a no-op.
592                 Map<String, String> configuredHeaders = HttpTransporterUtils.getHttpHeaders(session, repository);
593                 boolean hasPreemptiveAuth = basicAuthentication != null;
594                 boolean hasConfiguredHeaders = configuredHeaders != null && !configuredHeaders.isEmpty();
595                 if (hasPreemptiveAuth || hasConfiguredHeaders) {
596                     TreeSet<String> scopedHeaderNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
597                     scopedHeaderNames.add(HttpHeader.AUTHORIZATION.asString());
598                     if (hasConfiguredHeaders) {
599                         scopedHeaderNames.addAll(configuredHeaders.keySet());
600                     }
601                     httpClient
602                             .getRequestListeners()
603                             .addHeadersListener(new OriginScopedHeadersListener(baseUri, scopedHeaderNames));
604                 }
605             }
606 
607             return httpClient;
608         } catch (Exception e) {
609             if (e instanceof RuntimeException) {
610                 throw (RuntimeException) e;
611             } else {
612                 throw new IllegalStateException("Jetty client start failure", e);
613             }
614         }
615     }
616 }