diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java index c57d328a09a..3f43baf2b62 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java @@ -56,6 +56,7 @@ public class MavenExecutionContextView extends DelegatingExecutionContext { private static final String MAVEN_RESOLUTION_LISTENER = "org.openrewrite.maven.resolutionListener"; private static final String MAVEN_RESOLUTION_TIME = "org.openrewrite.maven.resolutionTime"; private static final String MAVEN_UNREACHABLE_ENDPOINTS = "org.openrewrite.maven.unreachableEndpoints"; + private static final String MAVEN_AUTHENTICATION_REQUIRED_ENDPOINTS = "org.openrewrite.maven.authenticationRequiredEndpoints"; public MavenExecutionContextView(ExecutionContext delegate) { super(delegate); @@ -92,6 +93,20 @@ public Set getUnreachableEndpoints() { return computeMessageIfAbsent(MAVEN_UNREACHABLE_ENDPOINTS, k -> ConcurrentHashMap.newKeySet()); } + /** + * The authentication-side counterpart to {@link #getUnreachableEndpoints()}: connection endpoints, each a + * {@code host:port}, that challenged an anonymous request and required credentials during this execution. + * Once an endpoint is known to require authentication, subsequent requests send credentials preemptively + * instead of paying another anonymous round-trip, mirroring the per-session {@code BasicAuthCache} that + * Apache Maven Resolver keeps on its HTTP client. As with unreachable endpoints, the key is {@code host:port} + * rather than the full URI because the challenge is a property of the endpoint, not the requested path, so the + * same host contacted under different paths or ids is deduped. The set is concurrent because resolution runs + * across multiple threads sharing one execution context. + */ + public Set getAuthenticationRequiredEndpoints() { + return computeMessageIfAbsent(MAVEN_AUTHENTICATION_REQUIRED_ENDPOINTS, k -> ConcurrentHashMap.newKeySet()); + } + public MavenExecutionContextView setResolutionListener(ResolutionEventListener listener) { putMessage(MAVEN_RESOLUTION_LISTENER, listener); return this; diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/internal/MavenPomDownloader.java b/rewrite-maven/src/main/java/org/openrewrite/maven/internal/MavenPomDownloader.java index c278d0b1450..f59f361cb82 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/internal/MavenPomDownloader.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/internal/MavenPomDownloader.java @@ -1017,20 +1017,20 @@ MavenRepository normalizeRepository(MavenRepository repository) throws Throwable HttpSender.Request.Builder request = httpSender.options(httpsUri); - ReachabilityResult reachability = reachable(applyAuthenticationAndTimeoutToRequest(repository, request)); + ReachabilityResult reachability = reachable(applyTimeoutToRequest(repository, request)); if (reachability.isSuccess()) { return repository.withUri(httpsUri); } - reachability = reachable(applyAuthenticationAndTimeoutToRequest(repository, request.withMethod(HttpSender.Method.HEAD).url(httpsUri))); + reachability = reachable(applyTimeoutToRequest(repository, request.withMethod(HttpSender.Method.HEAD).url(httpsUri))); if (reachability.isReachable()) { return repository.withUri(httpsUri); } if (!originalUrl.equals(httpsUri)) { - reachability = reachable(applyAuthenticationAndTimeoutToRequest(repository, request.withMethod(HttpSender.Method.OPTIONS).url(originalUrl))); + reachability = reachable(applyTimeoutToRequest(repository, request.withMethod(HttpSender.Method.OPTIONS).url(originalUrl))); if (reachability.isSuccess()) { return repository.withUri(originalUrl); } - reachability = reachable(applyAuthenticationAndTimeoutToRequest(repository, request.withMethod(HttpSender.Method.HEAD).url(originalUrl))); + reachability = reachable(applyTimeoutToRequest(repository, request.withMethod(HttpSender.Method.HEAD).url(originalUrl))); if (reachability.isReachable()) { return repository.withUri(originalUrl); } @@ -1082,22 +1082,34 @@ private ReachabilityResult reachable(HttpSender.Request.Builder request) { private boolean jarExistsForPomUri(MavenRepository repo, String pomUrl) { String jarUrl = pomUrl.replaceAll("\\.pom$", ".jar"); + // If this host has already required credentials in this session, authenticate preemptively rather than + // paying another anonymous round-trip. Otherwise probe anonymously first. + String endpoint = endpointOrNull(URI.create(jarUrl)); + boolean preemptive = hasCredentials(repo) && endpoint != null && + ctx.getAuthenticationRequiredEndpoints().contains(endpoint); try { try { return Failsafe.with(retryPolicy).get(() -> { - HttpSender.Request authenticated = applyAuthenticationAndTimeoutToRequest(repo, httpSender.head(jarUrl)).build(); - try (HttpSender.Response response = httpSender.send(authenticated)) { + HttpSender.Request request = preemptive ? + applyAuthenticationAndTimeoutToRequest(repo, httpSender.head(jarUrl)).build() : + applyTimeoutToRequest(repo, httpSender.head(jarUrl)).build(); + try (HttpSender.Response response = httpSender.send(request)) { return response.isSuccessful(); } }); } catch (FailsafeException failsafeException) { Throwable cause = failsafeException.getCause(); - if (cause instanceof HttpSenderResponseException && hasCredentials(repo) && + if (cause instanceof HttpSenderResponseException && !preemptive && hasCredentials(repo) && ((HttpSenderResponseException) cause).isClientSideException()) { return Failsafe.with(retryPolicy).get(() -> { - HttpSender.Request unauthenticated = httpSender.head(jarUrl).build(); - try (HttpSender.Response response = httpSender.send(unauthenticated)) { - return response.isSuccessful(); + HttpSender.Request authenticated = applyAuthenticationAndTimeoutToRequest(repo, httpSender.head(jarUrl)).build(); + try (HttpSender.Response response = httpSender.send(authenticated)) { + boolean successful = response.isSuccessful(); + if (successful && endpoint != null) { + // Remember so later requests to this host authenticate preemptively + ctx.getAuthenticationRequiredEndpoints().add(endpoint); + } + return successful; } }); } @@ -1110,27 +1122,39 @@ private boolean jarExistsForPomUri(MavenRepository repo, String pomUrl) { /** - * Replicates Apache Maven's behavior to attempt anonymous download if repository credentials prove invalid + * Replicates Apache Maven's DeferredCredentialsProvider behavior: request anonymously first and only send + * credentials once the server challenges the anonymous request with a 4xx. */ private byte[] requestAsAuthenticatedOrAnonymous(MavenRepository repo, String uriString) throws HttpSenderResponseException, IOException { + // If this host has already required credentials in this session, authenticate preemptively rather than + // paying another anonymous round-trip. Otherwise request anonymously first. + String endpoint = endpointOrNull(URI.create(uriString)); + if (hasCredentials(repo) && endpoint != null && ctx.getAuthenticationRequiredEndpoints().contains(endpoint)) { + return sendRequest(applyAuthenticationAndTimeoutToRequest(repo, httpSender.get(uriString)).build()); + } try { - HttpSender.Request.Builder request = httpSender.get(uriString); - return sendRequest(applyAuthenticationAndTimeoutToRequest(repo, request).build()); + return sendRequest(applyTimeoutToRequest(repo, httpSender.get(uriString)).build()); } catch (HttpSenderResponseException e) { if (hasCredentials(repo) && e.isClientSideException()) { - return retryRequestAnonymously(uriString, e); + return retryRequestWithCredentials(repo, uriString, e); } else { throw e; } } } - private byte[] retryRequestAnonymously(String uriString, HttpSenderResponseException originalException) throws HttpSenderResponseException, IOException { + private byte[] retryRequestWithCredentials(MavenRepository repo, String uriString, HttpSenderResponseException anonymousException) throws HttpSenderResponseException, IOException { try { - return sendRequest(httpSender.get(uriString).build()); + byte[] responseBody = sendRequest(applyAuthenticationAndTimeoutToRequest(repo, httpSender.get(uriString)).build()); + // Remember so later requests to this host authenticate preemptively + String endpoint = endpointOrNull(URI.create(uriString)); + if (endpoint != null) { + ctx.getAuthenticationRequiredEndpoints().add(endpoint); + } + return responseBody; } catch (HttpSenderResponseException retryException) { if (retryException.isAccessDenied()) { - throw originalException; + throw anonymousException; } else { throw retryException; } @@ -1145,29 +1169,43 @@ private MavenRepository applyAuthenticationToRepository(MavenRepository reposito } /** - * Returns a request builder with Authorization header set if the provided repository specifies credentials + * Applies connect/read timeouts from the repository and any matching server configuration, without sending + * any credentials or configured HTTP headers. Used for anonymous-first requests. */ - private HttpSender.Request.Builder applyAuthenticationAndTimeoutToRequest(MavenRepository repository, HttpSender.Request.Builder request) { + private HttpSender.Request.Builder applyTimeoutToRequest(MavenRepository repository, HttpSender.Request.Builder request) { if (mavenSettings != null && mavenSettings.getServers() != null) { request.withConnectTimeout(repository.getTimeout() == null ? Duration.ofSeconds(10) : repository.getTimeout()); request.withReadTimeout(repository.getTimeout() == null ? Duration.ofSeconds(30) : repository.getTimeout()); for (MavenSettings.Server server : mavenSettings.getServers().getServers()) { if (server.getId().equals(repository.getId()) && server.getConfiguration() != null) { MavenSettings.ServerConfiguration configuration = server.getConfiguration(); - if (server.getConfiguration().getHttpHeaders() != null) { - for (MavenSettings.HttpHeader header : configuration.getHttpHeaders()) { - request.withHeader(header.getName(), header.getValue()); - } - } if (configuration.getTimeout() != null) { request.withConnectTimeout(Duration.ofMillis(configuration.getTimeout())); - } - if (configuration.getTimeout() != null) { request.withReadTimeout(Duration.ofMillis(configuration.getTimeout())); } } } } + return request; + } + + /** + * Returns a request builder with timeouts, any configured HTTP headers, and an Authorization header when the + * repository specifies credentials. Only used to retry once an anonymous request has been challenged with a 4xx. + */ + private HttpSender.Request.Builder applyAuthenticationAndTimeoutToRequest(MavenRepository repository, HttpSender.Request.Builder request) { + applyTimeoutToRequest(repository, request); + if (mavenSettings != null && mavenSettings.getServers() != null) { + for (MavenSettings.Server server : mavenSettings.getServers().getServers()) { + MavenSettings.ServerConfiguration configuration = server.getConfiguration(); + if (server.getId().equals(repository.getId()) && configuration != null && + configuration.getHttpHeaders() != null) { + for (MavenSettings.HttpHeader header : configuration.getHttpHeaders()) { + request.withHeader(header.getName(), header.getValue()); + } + } + } + } if (hasCredentials(repository)) { return request.withBasicAuthentication(repository.getUsername(), repository.getPassword()); } diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/rpc/JavaRewriteRpc.java b/rewrite-maven/src/main/java/org/openrewrite/maven/rpc/JavaRewriteRpc.java index 3d36762aef4..07e4dad5051 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/rpc/JavaRewriteRpc.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/rpc/JavaRewriteRpc.java @@ -172,7 +172,8 @@ private static void run(@Nullable Path marketplaceCsv, boolean trace, @Nullable artifactCache, null, // No custom Maven settings new HttpUrlConnectionSender(), - t -> errorHandler.println("Download error: " + t.getMessage()) + t -> errorHandler.println("Download error: " + t.getMessage()), + ctx // Share the session auth cache with POM resolution ); // Set up resolvers diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java b/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java index 4ac58ff038a..2cf5a8aa856 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java @@ -19,9 +19,12 @@ import dev.failsafe.FailsafeException; import dev.failsafe.RetryPolicy; import org.jspecify.annotations.Nullable; +import org.openrewrite.ExecutionContext; +import org.openrewrite.InMemoryExecutionContext; import org.openrewrite.ipc.http.HttpSender; import org.openrewrite.ipc.http.HttpUrlConnectionSender; import org.openrewrite.maven.MavenDownloadingException; +import org.openrewrite.maven.MavenExecutionContextView; import org.openrewrite.maven.MavenSettings; import org.openrewrite.maven.cache.MavenArtifactCache; import org.openrewrite.maven.tree.MavenRepository; @@ -39,6 +42,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import java.util.function.Function; @@ -61,21 +65,50 @@ public class MavenArtifactDownloader { private final Map serverIdToServer; private final Consumer onError; private final HttpSender httpSender; + private final ExecutionContext ctx; + /** + * @deprecated Use {@link #MavenArtifactDownloader(MavenArtifactCache, MavenSettings, Consumer, ExecutionContext)} + * and pass the session {@link ExecutionContext} so the anonymous-first authentication cache is shared with + * POM/metadata resolution instead of living on a throwaway context. + */ + @Deprecated public MavenArtifactDownloader(MavenArtifactCache mavenArtifactCache, @Nullable MavenSettings settings, Consumer onError) { - this(mavenArtifactCache, settings, new HttpUrlConnectionSender(), onError); + this(mavenArtifactCache, settings, new HttpUrlConnectionSender(), onError, new InMemoryExecutionContext()); + } + + public MavenArtifactDownloader(MavenArtifactCache mavenArtifactCache, + @Nullable MavenSettings settings, + Consumer onError, + ExecutionContext ctx) { + this(mavenArtifactCache, settings, new HttpUrlConnectionSender(), onError, ctx); } + /** + * @deprecated Use {@link #MavenArtifactDownloader(MavenArtifactCache, MavenSettings, HttpSender, Consumer, ExecutionContext)} + * and pass the session {@link ExecutionContext} so the anonymous-first authentication cache is shared with + * POM/metadata resolution instead of living on a throwaway context. + */ + @Deprecated public MavenArtifactDownloader(MavenArtifactCache mavenArtifactCache, @Nullable MavenSettings settings, HttpSender httpSender, Consumer onError) { + this(mavenArtifactCache, settings, httpSender, onError, new InMemoryExecutionContext()); + } + + public MavenArtifactDownloader(MavenArtifactCache mavenArtifactCache, + @Nullable MavenSettings settings, + HttpSender httpSender, + Consumer onError, + ExecutionContext ctx) { this.httpSender = httpSender; this.mavenArtifactCache = mavenArtifactCache; this.onError = onError; + this.ctx = ctx; this.serverIdToServer = settings == null || settings.getServers() == null ? new HashMap<>() : settings.getServers().getServers().stream() @@ -111,26 +144,40 @@ public MavenArtifactDownloader(MavenArtifactCache mavenArtifactCache, } else if ("file".equals(URI.create(uri).getScheme())) { bodyStream = Files.newInputStream(Paths.get(URI.create(uri))); } else { - HttpSender.Request.Builder request = applyAuthentication(dependency.getRepository(), httpSender.get(uri)); try { + MavenRepository repository = dependency.getRepository(); + // Mirror Apache Maven's DeferredCredentialsProvider: anonymous first, unless this host has + // already required credentials in this session, in which case authenticate preemptively. + Set authenticationRequiredEndpoints = MavenExecutionContextView.view(ctx).getAuthenticationRequiredEndpoints(); + String endpoint = endpointOrNull(uri); + boolean preemptive = hasAuthentication(repository) && endpoint != null && + authenticationRequiredEndpoints.contains(endpoint); byte[] responseBytes = null; int responseCode; - try (HttpSender.Response response = Failsafe.with(retryPolicy).get(() -> httpSender.send(request.build())); + HttpSender.Request firstRequest = preemptive ? + applyAuthentication(repository, httpSender.get(uri)).build() : + httpSender.get(uri).build(); + try (HttpSender.Response response = Failsafe.with(retryPolicy).get(() -> httpSender.send(firstRequest)); InputStream body = response.getBody()) { responseCode = response.getCode(); if (response.isSuccessful() && body != null) { responseBytes = readAllBytes(body); } } - // Fall back to anonymous if authenticated request fails with a client error - if (responseBytes == null && isClientSideError(responseCode) && hasAuthentication(dependency.getRepository())) { - try (HttpSender.Response response = Failsafe.with(retryPolicy).get(() -> httpSender.send(httpSender.get(uri).build())); + // Retry with credentials if the anonymous request failed with a client error + if (responseBytes == null && !preemptive && isClientSideError(responseCode) && hasAuthentication(repository)) { + HttpSender.Request.Builder request = applyAuthentication(repository, httpSender.get(uri)); + try (HttpSender.Response response = Failsafe.with(retryPolicy).get(() -> httpSender.send(request.build())); InputStream body = response.getBody()) { responseCode = response.getCode(); if (response.isSuccessful() && body != null) { responseBytes = readAllBytes(body); } } + if (responseBytes != null && endpoint != null) { + // Remember so later artifacts from this host authenticate preemptively + authenticationRequiredEndpoints.add(endpoint); + } } if (responseBytes == null) { onError.accept(new MavenDownloadingException(String.format("Unable to download dependency %s:%s:%s%s from %s. Response was %d", @@ -166,6 +213,12 @@ private boolean hasAuthentication(MavenRepository repository) { return !resolveHttpHeaders(repository).isEmpty() || resolveCredentials(repository) != null; } + private static @Nullable String endpointOrNull(String uri) { + URI parsed = URI.create(uri); + String host = parsed.getHost(); + return host == null ? null : host + ':' + parsed.getPort(); + } + /** * All 400s are client-side errors, but 408 (timeout), 425 (too early) and 429 (too many requests) are transient * rather than credential rejections, so an anonymous retry is pointless for them. Mirrors diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/internal/MavenPomDownloaderTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/internal/MavenPomDownloaderTest.java index f2dfbf98c46..fe056224c9f 100755 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/internal/MavenPomDownloaderTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/internal/MavenPomDownloaderTest.java @@ -1432,6 +1432,88 @@ public MockResponse dispatch(RecordedRequest recordedRequest) { } } + @Test + void doesNotSendCredentialsWhenRepositoryServesAnonymously() { + var downloader = new MavenPomDownloader(emptyMap(), ctx); + var gav = new GroupArtifactVersion("fred", "fred", "1.0.0"); + try (MockWebServer mockRepo = getMockServer()) { + List<@Nullable String> authorizationHeaders = synchronizedList(new ArrayList<>()); + mockRepo.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest recordedRequest) { + authorizationHeaders.add(recordedRequest.getHeaders().get("Authorization")); + return new MockResponse().setResponseCode(200).setBody( + //language=xml + """ + + fred + fred + 1.0.0 + + """); + } + }); + mockRepo.start(); + var repositories = List.of(MavenRepository.builder() + .id("id") + .uri("http://%s:%d/maven".formatted(mockRepo.getHostName(), mockRepo.getPort())) + .username("user") + .password("pass") + .build()); + + assertDoesNotThrow(() -> downloader.download(gav, null, null, repositories)); + + // Mirror Apache Maven: a repository that serves anonymously must never be sent credentials + assertThat(authorizationHeaders).containsOnlyNulls(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Test + void authenticatesPreemptivelyAfterCredentialsRequired() { + var downloader = new MavenPomDownloader(emptyMap(), ctx); + try (MockWebServer mockRepo = getMockServer()) { + List<@Nullable String> getRequestAuthHeaders = synchronizedList(new ArrayList<>()); + mockRepo.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest recordedRequest) { + if ("GET".equalsIgnoreCase(recordedRequest.getMethod())) { + getRequestAuthHeaders.add(recordedRequest.getHeaders().get("Authorization")); + } + if (recordedRequest.getHeaders().get("Authorization") == null) { + return new MockResponse().setResponseCode(401).setBody(""); + } + return new MockResponse().setResponseCode(200).setBody( + //language=xml + """ + + fred + fred + 1.0.0 + + """); + } + }); + mockRepo.start(); + var repositories = List.of(MavenRepository.builder() + .id("id") + .uri("http://%s:%d/maven".formatted(mockRepo.getHostName(), mockRepo.getPort())) + .username("user") + .password("pass") + .build()); + + assertDoesNotThrow(() -> downloader.download(new GroupArtifactVersion("fred", "fred", "1.0.0"), null, null, repositories)); + assertDoesNotThrow(() -> downloader.download(new GroupArtifactVersion("fred", "other", "1.0.0"), null, null, repositories)); + + // Only the first body GET probes anonymously; once the host is known to require credentials, later + // GETs authenticate preemptively instead of paying another anonymous 401 round-trip. + assertThat(getRequestAuthHeaders.stream().filter(Objects::isNull).count()).isEqualTo(1); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + @Test void usesAuthenticationIfRepositoryHasCredentials() { var downloader = new MavenPomDownloader(emptyMap(), ctx); diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/CachingMavenRecipeBundleResolverTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/CachingMavenRecipeBundleResolverTest.java index 624cabe78c3..ac50026d351 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/CachingMavenRecipeBundleResolverTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/CachingMavenRecipeBundleResolverTest.java @@ -59,7 +59,8 @@ void setUp() { artifactCache, null, new HttpUrlConnectionSender(), - Throwable::printStackTrace + Throwable::printStackTrace, + ctx ); } diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/MavenRecipeBundleReaderTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/MavenRecipeBundleReaderTest.java index 70992432570..c7ecc5ff27c 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/MavenRecipeBundleReaderTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/MavenRecipeBundleReaderTest.java @@ -65,7 +65,8 @@ void setUp() { artifactCache, null, new HttpUrlConnectionSender(), - Throwable::printStackTrace + Throwable::printStackTrace, + ctx ); resolver = new MavenRecipeBundleResolver(ctx, downloader, RecipeClassLoader::new); diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/MavenRecipeMarketplaceGeneratorTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/MavenRecipeMarketplaceGeneratorTest.java index fa599d8dfdd..4a058012082 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/MavenRecipeMarketplaceGeneratorTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/marketplace/MavenRecipeMarketplaceGeneratorTest.java @@ -88,7 +88,8 @@ void generateMarketplaceFromJar(@TempDir Path tempDir) throws Exception { artifactCache, null, new HttpUrlConnectionSender(), - Throwable::printStackTrace + Throwable::printStackTrace, + ctx ); // Download rewrite-rewrite jar and its dependencies for testing @@ -162,7 +163,8 @@ void generateMarketplaceFromDirectory(@TempDir Path tempDir) throws Exception { artifactCache, null, new HttpUrlConnectionSender(), - Throwable::printStackTrace + Throwable::printStackTrace, + ctx ); // Download rewrite-rewrite jar and extract it to a directory diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java index 127c39740e3..52253ee4768 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java @@ -46,7 +46,7 @@ void downloadDependencies(@TempDir Path tempDir) { ExecutionContext ctx = new InMemoryExecutionContext(Throwable::printStackTrace); MavenArtifactCache artifactCache = new LocalMavenArtifactCache(tempDir); MavenArtifactDownloader downloader = new MavenArtifactDownloader( - artifactCache, null, t -> ctx.getOnError().accept(t)); + artifactCache, null, ctx.getOnError(), ctx); ResolvedGroupArtifactVersion recipeGav = new ResolvedGroupArtifactVersion( "https://repo1.maven.org/maven2", "org.openrewrite.recipe", @@ -93,7 +93,7 @@ void downloadDependenciesWithClassifier(@TempDir Path tempDir) { ExecutionContext ctx = new InMemoryExecutionContext(Throwable::printStackTrace); MavenArtifactCache artifactCache = new LocalMavenArtifactCache(tempDir); MavenArtifactDownloader downloader = new MavenArtifactDownloader( - artifactCache, null, t -> ctx.getOnError().accept(t)); + artifactCache, null, ctx.getOnError(), ctx); MavenParser mavenParser = MavenParser.builder().build(); SourceFile parsed = mavenParser.parse(ctx, @@ -133,14 +133,14 @@ void downloadDependenciesWithClassifier(@TempDir Path tempDir) { } @Test - void fallsBackToAnonymousWhenServerReturns401(@TempDir Path tempDir) throws Exception { + void retriesWithCredentialsWhenAnonymousReturns401(@TempDir Path tempDir) throws Exception { byte[] jarBytes = {0x50, 0x4B, 0x03, 0x04}; try (MockWebServer mockRepo = new MockWebServer()) { mockRepo.setDispatcher(new Dispatcher() { @Override public MockResponse dispatch(RecordedRequest request) { - if (request.getHeader("Authorization") != null) { + if (request.getHeader("Authorization") == null) { return new MockResponse().setResponseCode(401); } return new MockResponse().setResponseCode(200) @@ -168,7 +168,7 @@ public MockResponse dispatch(RecordedRequest request) { MavenArtifactCache artifactCache = new LocalMavenArtifactCache(tempDir); AtomicReference error = new AtomicReference<>(); MavenArtifactDownloader downloader = new MavenArtifactDownloader( - artifactCache, settings, error::set); + artifactCache, settings, error::set, new InMemoryExecutionContext()); MavenRepository repo = new MavenRepository( "mock-repo", repoUrl, "true", "false", true, null, null, null, false); @@ -184,20 +184,20 @@ public MockResponse dispatch(RecordedRequest request) { assertThat(artifact).isNotNull(); assertThat(error.get()).isNull(); assertThat(mockRepo.getRequestCount()).isEqualTo(2); - assertThat(mockRepo.takeRequest().getHeader("Authorization")).isNotNull(); assertThat(mockRepo.takeRequest().getHeader("Authorization")).isNull(); + assertThat(mockRepo.takeRequest().getHeader("Authorization")).isNotNull(); } } @Test - void fallsBackToAnonymousWhenHttpHeaderAuthRejected(@TempDir Path tempDir) throws Exception { + void retriesWithHttpHeaderAuthWhenAnonymousReturns401(@TempDir Path tempDir) throws Exception { byte[] jarBytes = {0x50, 0x4B, 0x03, 0x04}; try (MockWebServer mockRepo = new MockWebServer()) { mockRepo.setDispatcher(new Dispatcher() { @Override public MockResponse dispatch(RecordedRequest request) { - if (request.getHeader("X-Auth-Token") != null) { + if (request.getHeader("X-Auth-Token") == null) { return new MockResponse().setResponseCode(401); } return new MockResponse().setResponseCode(200) @@ -231,7 +231,7 @@ public MockResponse dispatch(RecordedRequest request) { MavenArtifactCache artifactCache = new LocalMavenArtifactCache(tempDir); AtomicReference error = new AtomicReference<>(); MavenArtifactDownloader downloader = new MavenArtifactDownloader( - artifactCache, settings, error::set); + artifactCache, settings, error::set, new InMemoryExecutionContext()); MavenRepository repo = new MavenRepository( "mock-repo", repoUrl, "true", "false", true, null, null, null, false); @@ -247,8 +247,8 @@ public MockResponse dispatch(RecordedRequest request) { assertThat(artifact).isNotNull(); assertThat(error.get()).isNull(); assertThat(mockRepo.getRequestCount()).isEqualTo(2); - assertThat(mockRepo.takeRequest().getHeader("X-Auth-Token")).isNotNull(); assertThat(mockRepo.takeRequest().getHeader("X-Auth-Token")).isNull(); + assertThat(mockRepo.takeRequest().getHeader("X-Auth-Token")).isNotNull(); } } @@ -258,7 +258,7 @@ void doesNotFallBackToAnonymousOnTransientClientError(@TempDir Path tempDir) thr mockRepo.setDispatcher(new Dispatcher() { @Override public MockResponse dispatch(RecordedRequest request) { - // 429 is a transient rate-limit, not a credential rejection, so no anonymous retry should follow + // 429 is a transient rate-limit, not a credential rejection, so no authenticated retry should follow return new MockResponse().setResponseCode(429); } }); @@ -283,7 +283,7 @@ public MockResponse dispatch(RecordedRequest request) { MavenArtifactCache artifactCache = new LocalMavenArtifactCache(tempDir); AtomicReference error = new AtomicReference<>(); MavenArtifactDownloader downloader = new MavenArtifactDownloader( - artifactCache, settings, error::set); + artifactCache, settings, error::set, new InMemoryExecutionContext()); MavenRepository repo = new MavenRepository( "mock-repo", repoUrl, "true", "false", true, null, null, null, false); @@ -299,7 +299,7 @@ public MockResponse dispatch(RecordedRequest request) { assertThat(artifact).isNull(); assertThat(error.get()).isNotNull(); assertThat(mockRepo.getRequestCount()).isEqualTo(1); - assertThat(mockRepo.takeRequest().getHeader("Authorization")).isNotNull(); + assertThat(mockRepo.takeRequest().getHeader("Authorization")).isNull(); } } @@ -339,7 +339,7 @@ public MockResponse dispatch(RecordedRequest request) { MavenArtifactCache artifactCache = new LocalMavenArtifactCache(tempDir); AtomicReference error = new AtomicReference<>(); MavenArtifactDownloader downloader = new MavenArtifactDownloader( - artifactCache, settings, error::set); + artifactCache, settings, error::set, new InMemoryExecutionContext()); MavenRepository repo = new MavenRepository( "mock-repo", repoUrl, "true", "false", true, null, null, null, false); @@ -357,4 +357,70 @@ public MockResponse dispatch(RecordedRequest request) { assertThat(mockRepo.getRequestCount()).isEqualTo(1); } } + + @Test + void authenticatesPreemptivelyAfterFirstChallengeForSameHost(@TempDir Path tempDir) throws Exception { + byte[] jarBytes = {0x50, 0x4B, 0x03, 0x04}; + + try (MockWebServer mockRepo = new MockWebServer()) { + // Private repository: anonymous requests are rejected, authenticated requests succeed + mockRepo.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + if (request.getHeader("Authorization") == null) { + return new MockResponse().setResponseCode(401); + } + return new MockResponse().setResponseCode(200) + .setBody(new okio.Buffer().write(jarBytes)); + } + }); + mockRepo.start(); + + String repoUrl = "http://" + mockRepo.getHostName() + ":" + mockRepo.getPort(); + MavenSettings settings = MavenSettings.parse(new Parser.Input( + Path.of("settings.xml"), () -> new ByteArrayInputStream( + //language=xml + """ + + + + mock-repo + good-user + good-password + + + + """.getBytes())), new InMemoryExecutionContext()); + + MavenArtifactCache artifactCache = new LocalMavenArtifactCache(tempDir); + AtomicReference error = new AtomicReference<>(); + MavenArtifactDownloader downloader = new MavenArtifactDownloader( + artifactCache, settings, error::set, new InMemoryExecutionContext()); + + MavenRepository repo = new MavenRepository( + "mock-repo", repoUrl, "true", "false", true, null, null, null, false); + + Path first = downloader.downloadArtifact(resolvedDependency(repo, repoUrl, "lib-a")); + Path second = downloader.downloadArtifact(resolvedDependency(repo, repoUrl, "lib-b")); + + assertThat(first).isNotNull(); + assertThat(second).isNotNull(); + assertThat(error.get()).isNull(); + // First artifact probes anonymously (401) then authenticates (200). Once the host is known to + // require credentials, the second artifact authenticates preemptively: three requests, not four. + assertThat(mockRepo.getRequestCount()).isEqualTo(3); + assertThat(mockRepo.takeRequest().getHeader("Authorization")).isNull(); + assertThat(mockRepo.takeRequest().getHeader("Authorization")).isNotNull(); + assertThat(mockRepo.takeRequest().getHeader("Authorization")).isNotNull(); + } + } + + private static ResolvedDependency resolvedDependency(MavenRepository repo, String repoUrl, String artifactId) { + GroupArtifactVersion gav = new GroupArtifactVersion("com.example", artifactId, "1.0.0"); + return ResolvedDependency.builder() + .repository(repo) + .gav(new ResolvedGroupArtifactVersion(repoUrl, gav.getGroupId(), gav.getArtifactId(), gav.getVersion(), null)) + .requested(Dependency.builder().gav(gav).build()) + .build(); + } }