Skip to content

Try Maven downloads anonymously first, retry with credentials on 4xx#7447

Draft
timtebeek wants to merge 2 commits into
mainfrom
tim/maven-auth-401-fallback
Draft

Try Maven downloads anonymously first, retry with credentials on 4xx#7447
timtebeek wants to merge 2 commits into
mainfrom
tim/maven-auth-401-fallback

Conversation

@timtebeek
Copy link
Copy Markdown
Member

@timtebeek timtebeek commented Apr 21, 2026

Summary

  • MavenArtifactDownloader now tries each JAR download anonymously first and only sends settings.xml credentials if the server challenges with a 4xx — mirroring Apache Maven Resolver's DeferredCredentialsProvider. This unblocks anonymous-accessible artifacts when configured credentials are invalid (reported case: 401 against internal Artifactory) and avoids leaking credentials to public artifacts.
  • Fix LocalMavenArtifactCache cache filename missing the hyphen before the classifier (foo-1.0.0recipes.jarfoo-1.0.0-recipes.jar).
  • Include the classifier in the MavenDownloadingException message so artifact coordinates are complete during troubleshooting.

Follow-up to #6845 where this scenario was anticipated; the reporter has now hit it in practice.

Test plan

  • publicArtifactsResolveAnonymouslyEvenWhenCredentialsAreInvalid — bad credentials configured, public artifact, single anonymous request, no auth header sent.
  • retriesWithCredentialsWhenAnonymousReturns401 — private artifact, two requests: first anonymous (401), second authenticated (200).

When Maven settings.xml credentials are rejected by the remote
repository (401/403), retry the JAR download without authentication.
Mirrors `MavenPomDownloader.requestAsAuthenticatedOrAnonymous()` and
Apache Maven's behavior, so anonymous-accessible artifacts resolve
even when configured credentials are invalid.

Also fixes two nits observed during troubleshooting:
- local cache filename was missing the hyphen before the classifier
  (`foo-1.0.0recipes.jar` → `foo-1.0.0-recipes.jar`)
- download error message omitted the classifier
@nmck257
Copy link
Copy Markdown
Collaborator

nmck257 commented Apr 21, 2026

digging a bit into how Maven handles auth on requests:
https://github.com/apache/maven-resolver/blob/master/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheTransporter.java#L270
https://github.com/apache/maven-resolver/blob/master/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/DeferredCredentialsProvider.java

haven't traced into HttpClient yet, but it seems like it does try an unauthenticated request first and then retry with auth if that fails. if that's accurate, then the risk of 2x requests is expected, and we should probably match Maven and try anon first.

Mirrors Apache Maven Resolver's DeferredCredentialsProvider behavior:
issue an unauthenticated request first, then send credentials only when
the server challenges with a 4xx. This matches what users get from
running Maven directly, and avoids leaking credentials to public
artifacts.
@timtebeek
Copy link
Copy Markdown
Member Author

Good call — flipped to anonymous-first in 8408c64. Now we send no auth at all unless the server challenges us with a 4xx, matching Apache Maven Resolver's DeferredCredentialsProvider. Side benefit: credentials never leak to public artifacts.

@timtebeek timtebeek changed the title Fall back to anonymous download on 4xx with Maven credentials Try Maven downloads anonymously first, retry with credentials on 4xx Apr 28, 2026
@nmck257
Copy link
Copy Markdown
Collaborator

nmck257 commented May 22, 2026

hey - just noticed this was still in draft. the changes seem sensible to me; any pending concerns?

@timtebeek
Copy link
Copy Markdown
Member Author

timtebeek commented May 26, 2026

How Apache Maven handles authenticated HTTP requests

Maven 3.x delegates all artifact transport to Maven Resolver (formerly Aether). Findings verified against apache/maven-resolver@a861223:

1. Default behavior is challenge-response, NOT preemptive

DEFAULT_HTTP_PREEMPTIVE_AUTH = false. Auth headers are only sent after the server replies with 401/WWW-Authenticate.

2. Apache HttpClient transport (default since Maven 3.9)

The transport registers a DeferredCredentialsProvider as HttpClient's default credentials provider:

// ApacheTransporter.java
.setDefaultCredentialsProvider(toCredentialsProvider(server, repoAuthContext, ...))

DeferredCredentialsProvider.getCredentials(AuthScope) is only invoked by HttpClient when the server challenges with 401. It lazily pulls username/password from the AuthenticationContext (which wraps the decrypted <server> block from settings.xml):

public Credentials getCredentials(AuthScope authScope) {
    // resolve via Factory only when HttpClient asks
    ...
    delegate.setCredentials(entry.getKey(), entry.getValue().newCredentials());
    return delegate.getCredentials(authScope);
}

So the flow for a single GET:

  1. HttpClient sends GET /artifact.jar with no Authorization header.
  2. If the server responds 200 -> done. No credentials ever touched.
  3. If the server responds 401 WWW-Authenticate: Basic realm="..." -> HttpClient's HttpAuthenticator calls getCredentials() on the provider, which lazily loads the creds and HttpClient retries the request with Authorization: Basic ....
  4. On success, HttpClient stores the scheme in BasicAuthCache for the session.

The BasicAuthCache is significant: after the first successful auth to a host, subsequent requests in the same session are preemptively authenticated, so you pay the 401 round-trip exactly once per host, not per artifact.

3. JDK HttpClient transport (alternative)

Same model, different mechanism. Credentials are registered on the JDK HttpClient.Builder via a java.net.Authenticator:

builder.authenticator(new Authenticator() {
    @Override
    protected PasswordAuthentication getPasswordAuthentication() {
        return authentications.get(getRequestorType());
    }
});

The JDK's HttpClient only invokes getPasswordAuthentication() when the server challenges. Preemptive mode is gated by the same preemptiveAuth flag (off by default):

private void prepare(HttpRequest.Builder requestBuilder) {
    if (preemptiveAuth || (preemptivePutAuth && method.equals("PUT"))) {
        if (serverAuthentication != null) {
            requestBuilder.setHeader("Authorization", getBasicAuthValue(...));
        }
    }
}

4. What this means for the reported scenario

When settings.xml has env-var placeholder credentials that aren't set, Apache Maven:

  • sends the first artifact request anonymously,
  • the remote repo responds 200 for public artifacts (no challenge),
  • never consults the bad credentials at all.

That's why mvn works and the Moderne CLI (using OpenRewrite's old MavenArtifactDownloader) didn't.

5. What this PR does vs. what Maven does

Aspect Apache Maven Resolver This PR
First request Anonymous Anonymous
On 401 with creds available Retry authenticated Retry authenticated
Subsequent requests after success Preemptive (BasicAuthCache) Always anonymous-first (no cache)
Preemptive opt-in flag aether.transport.http.preemptiveAuth=true Not implemented

Functional parity for the reported case. The missing piece is the per-session auth cache: each artifact currently pays a 401 round-trip when creds are required. For recipe-jar downloads (typically a handful of artifacts) this is negligible, but if profiling shows it matters we can add a Map<host, Boolean> cache to skip straight to authenticated for hosts that have already 401'd once.

@timtebeek
Copy link
Copy Markdown
Member Author

Follow-up: avoiding the wasted anonymous round-trip when credentials are actually required

The anonymous-first approach in this PR costs one extra request per artifact when the remote actually requires auth. For a recipe-bundle resolution that's typically small, but if profiling shows it matters, two options worth considering:

Option B — Per-instance "auth-required hosts" set in each downloader

Each of MavenArtifactDownloader and MavenPomDownloader keeps a private Set<String> of hosts that returned 401 and then succeeded with credentials. Subsequent requests to a remembered host skip the anonymous attempt and authenticate preemptively.

  • Smallest diff: two Set<String> fields, one write site and one read site per downloader.
  • No API changes, no LST impact.
  • Downside: the two sets are independent, so MavenArtifactDownloader still pays one wasted anonymous round-trip per host even though MavenPomDownloader already discovered the answer during POM resolution.

Option D — Flag on MavenRepository, populated during POM resolution

MavenPomDownloader runs first and probes every repository many times (parent POMs, transitive POMs, metadata). By the time MavenArtifactDownloader runs, the answer is already known — we just don't record it anywhere.

Add a non-serialized hint on MavenRepository:

@With
@NonFinal
@JsonIgnore
@Nullable
Boolean requiresCredentials;  // null = unknown, true = observed to require auth

MavenPomDownloader.requestAsAuthenticatedOrAnonymous sets it the first time an authenticated retry succeeds. Both downloaders read it: if Boolean.TRUE.equals(repo.getRequiresCredentials()) and credentials exist, skip the anonymous attempt entirely.

  • Zero wasted anonymous requests at JAR-download time — the POM phase already paid the discovery cost.
  • @JsonIgnore keeps it a per-process hint so stale knowledge doesn't bleed into persisted LSTs or cross RPC boundaries.
  • Single write site, two read sites, no constructor/API changes.
  • Downside: we'd be the first place mutating an LST node mid-resolution (existing @NonFinal fields like knownToExist are set-at-construction only). Mitigated by the "only ever set true, never set false" rule — a stale true just means we send credentials we didn't strictly need, which is the previous default behavior anyway.

Why hasCredentials alone isn't enough

hasCredentials(repo) answers "are credentials configured?", not "does the server want them?" The two diverge in real setups: stale-but-concrete credentials against public artifacts (the customer's scenario), mixed-access Artifactory/Nexus proxies, and any case where one shouldn't leak Basic-auth headers to endpoints that don't require them. That's the same reason Apache Maven Resolver defaults preemptiveAuth=false even when credentials are present.

Happy to land either follow-up in a separate PR if there's interest — leaning D because it converts work the POM phase already does into savings the JAR phase can use.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants