diff --git a/compat/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java b/compat/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java
index 28b912868dec..8e41862b2b18 100644
--- a/compat/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java
+++ b/compat/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java
@@ -80,7 +80,10 @@ public AuthenticationInfo getAuthenticationInfo(String id) {
if (servers != null) {
for (Server server : servers) {
- if (id.equalsIgnoreCase(server.getId())) {
+ // Server ids are matched exactly, consistent with
+ // LegacyRepositorySystem.injectAuthentication and the resolver's
+ // authentication selector.
+ if (id.equals(server.getId())) {
SettingsDecryptionResult result =
settingsDecrypter.decrypt(new DefaultSettingsDecryptionRequest(server));
server = result.getServer();
diff --git a/compat/maven-compat/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java b/compat/maven-compat/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java
index 34ed688eb3b3..c4f18d75a82b 100644
--- a/compat/maven-compat/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java
+++ b/compat/maven-compat/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java
@@ -43,6 +43,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -130,8 +131,8 @@ void testRelocationInvalidArtifactIdIsRejected() throws Exception {
ArtifactMetadataRetrievalException exception =
assertThrows(ArtifactMetadataRetrievalException.class, () -> source.retrieve(request));
- assertEquals(true, exception.getMessage().contains("a/b"));
- assertEquals(true, exception.getMessage().contains("artifactId"));
+ assertTrue(exception.getMessage().contains("a/b"));
+ assertTrue(exception.getMessage().contains("artifactId"));
}
@Test
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
index 6648ffc9d8de..d1ee91a346e3 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
@@ -27,6 +27,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.stream.Collectors;
import org.apache.maven.api.Constants;
@@ -36,6 +37,7 @@
import org.apache.maven.api.feature.Features;
import org.apache.maven.api.services.TypeRegistry;
import org.apache.maven.api.xml.XmlNode;
+import org.apache.maven.artifact.repository.ArtifactRepository;
import org.apache.maven.eventspy.internal.EventSpyDispatcher;
import org.apache.maven.execution.MavenExecutionRequest;
import org.apache.maven.impl.resolver.MavenSessionBuilderSupplier;
@@ -53,6 +55,7 @@
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.RepositorySystemSession.SessionBuilder;
import org.eclipse.aether.collection.VersionFilterBuilder;
+import org.eclipse.aether.repository.AuthenticationSelector;
import org.eclipse.aether.repository.RepositoryPolicy;
import org.eclipse.aether.resolution.ResolutionErrorPolicy;
import org.eclipse.aether.util.listener.ChainedRepositoryListener;
@@ -96,6 +99,24 @@ public class DefaultRepositorySystemSessionFactory implements RepositorySystemSe
public static final String MAVEN_RESOLVER_TRANSPORT_AUTO = "auto";
+ /**
+ * User property selecting how server credentials configured in settings are scoped to repositories:
+ *
> declaredRepositoryOrigins = new HashMap<>();
+
DefaultMirrorSelector mirrorSelector = new DefaultMirrorSelector();
for (Mirror mirror : request.getMirrors()) {
mirrorSelector.add(
@@ -201,8 +226,17 @@ public SessionBuilder newRepositorySessionBuilder(MavenExecutionRequest request)
mirror.isBlocked(),
mirror.getMirrorOf(),
mirror.getMirrorOfLayouts());
+ OriginBoundAuthenticationSelector.addOrigin(declaredRepositoryOrigins, mirror.getId(), mirror.getUrl());
}
sessionBuilder.setMirrorSelector(mirrorSelector);
+ for (ArtifactRepository repository : request.getRemoteRepositories()) {
+ OriginBoundAuthenticationSelector.addOrigin(
+ declaredRepositoryOrigins, repository.getId(), repository.getUrl());
+ }
+ for (ArtifactRepository repository : request.getPluginArtifactRepositories()) {
+ OriginBoundAuthenticationSelector.addOrigin(
+ declaredRepositoryOrigins, repository.getId(), repository.getUrl());
+ }
DefaultProxySelector proxySelector = new DefaultProxySelector();
for (Proxy proxy : request.getProxies()) {
@@ -306,7 +340,11 @@ public SessionBuilder newRepositorySessionBuilder(MavenExecutionRequest request)
configProps.put("aether.transport.wagon.perms.fileMode." + server.getId(), server.getFilePermissions());
configProps.put("aether.transport.wagon.perms.dirMode." + server.getId(), server.getDirectoryPermissions());
}
- sessionBuilder.setAuthenticationSelector(authSelector);
+ String credentialScope = mergedProps.getOrDefault(
+ MAVEN_REPOSITORY_CREDENTIAL_SCOPE, OriginBoundAuthenticationSelector.SCOPE_ORIGIN);
+ AuthenticationSelector effectiveAuthSelector = OriginBoundAuthenticationSelector.wrap(
+ authSelector, credentialScope, declaredRepositoryOrigins, logger);
+ sessionBuilder.setAuthenticationSelector(effectiveAuthSelector);
Object transport =
mergedProps.getOrDefault(Constants.MAVEN_RESOLVER_TRANSPORT, MAVEN_RESOLVER_TRANSPORT_DEFAULT);
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java
new file mode 100644
index 000000000000..030b38742d32
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.aether;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.eclipse.aether.repository.Authentication;
+import org.eclipse.aether.repository.AuthenticationSelector;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.slf4j.Logger;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * An {@link AuthenticationSelector} that scopes server credentials to the origin (protocol, host and
+ * port) of the repository or mirror the operator declared for the same server id.
+ *
+ * A repository's id and its origin are independent: this selector serves a server id's credentials
+ * only to a repository whose origin matches one the operator declared for that id, in settings or on
+ * the command line. Ids with no operator-declared origin keep the previous behaviour unless
+ * {@code strict} scope is requested, and a warning naming the target origin is emitted once per
+ * id/origin pair.
+ *
+ * @see DefaultRepositorySystemSessionFactory#MAVEN_REPOSITORY_CREDENTIAL_SCOPE
+ */
+class OriginBoundAuthenticationSelector implements AuthenticationSelector {
+ /**
+ * Credentials are bound to operator-declared origins; ids without a declared origin keep legacy
+ * behavior, with a warning.
+ */
+ static final String SCOPE_ORIGIN = "origin";
+
+ /**
+ * Credentials are bound to operator-declared origins; ids without a declared origin get no
+ * credentials.
+ */
+ static final String SCOPE_STRICT = "strict";
+
+ /**
+ * Legacy behavior: credentials are matched by server id only.
+ */
+ static final String SCOPE_ID = "id";
+
+ private final AuthenticationSelector delegate;
+
+ private final Map> declaredOrigins;
+
+ private final boolean strict;
+
+ private final Logger logger;
+
+ private final Set reported = ConcurrentHashMap.newKeySet();
+
+ private OriginBoundAuthenticationSelector(
+ AuthenticationSelector delegate, Map> declaredOrigins, boolean strict, Logger logger) {
+ this.delegate = requireNonNull(delegate, "delegate");
+ this.declaredOrigins = requireNonNull(declaredOrigins, "declaredOrigins");
+ this.strict = strict;
+ this.logger = requireNonNull(logger, "logger");
+ }
+
+ /**
+ * Wraps the given selector according to the requested credential scope.
+ *
+ * @param delegate the selector holding the actual credentials, keyed by server id
+ * @param credentialScope one of {@link #SCOPE_ORIGIN}, {@link #SCOPE_STRICT} or {@link #SCOPE_ID}
+ * @param declaredOrigins origins of operator-declared repositories and mirrors, keyed by id
+ * @param logger logger used to report id/origin mismatches
+ * @return the delegate itself for {@link #SCOPE_ID}, an origin-bound wrapper otherwise
+ */
+ static AuthenticationSelector wrap(
+ AuthenticationSelector delegate,
+ String credentialScope,
+ Map> declaredOrigins,
+ Logger logger) {
+ if (SCOPE_ID.equals(credentialScope)) {
+ return delegate;
+ } else if (SCOPE_ORIGIN.equals(credentialScope) || SCOPE_STRICT.equals(credentialScope)) {
+ return new OriginBoundAuthenticationSelector(
+ delegate, declaredOrigins, SCOPE_STRICT.equals(credentialScope), logger);
+ } else {
+ throw new IllegalArgumentException("Unknown value '" + credentialScope + "' for "
+ + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE
+ + ". Supported values are: " + SCOPE_ORIGIN + ", " + SCOPE_STRICT + ", " + SCOPE_ID);
+ }
+ }
+
+ /**
+ * Records the origin of an operator-declared repository or mirror for the given id. URLs without a
+ * parseable server authority (for example {@code file:} URLs) are ignored.
+ */
+ static void addOrigin(Map> declaredOrigins, String id, String url) {
+ String origin = originOf(url);
+ if (id != null && origin != null) {
+ declaredOrigins.computeIfAbsent(id, k -> new HashSet<>()).add(origin);
+ }
+ }
+
+ @Override
+ public Authentication getAuthentication(RemoteRepository repository) {
+ Authentication auth = delegate.getAuthentication(repository);
+ if (auth == null) {
+ return null;
+ }
+ String id = repository.getId();
+ String origin = originOf(repository.getUrl());
+ Set origins = declaredOrigins.get(id);
+ if (origins != null && !origins.isEmpty()) {
+ if (origin != null && origins.contains(origin)) {
+ return auth;
+ }
+ warnOnce(
+ id,
+ origin,
+ "Not using credentials of server '" + id + "' for repository " + repository.getUrl()
+ + ": the repository or mirror declared for this id resides at " + origins
+ + ". Set "
+ + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE + "="
+ + SCOPE_ID + " to restore legacy id-only credential matching.");
+ return null;
+ }
+ if (strict) {
+ warnOnce(
+ id,
+ origin,
+ "Not using credentials of server '" + id + "' for repository " + repository.getUrl()
+ + ": no repository or mirror with this id is declared in settings or on the command"
+ + " line, and " + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE
+ + "=" + SCOPE_STRICT + " is in effect.");
+ return null;
+ }
+ warnOnce(
+ id,
+ origin,
+ "Using credentials of server '" + id + "' for repository " + repository.getUrl()
+ + ", although no repository or mirror with this id is declared in settings or on the"
+ + " command line. Set "
+ + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE
+ + "=" + SCOPE_STRICT + " to refuse such credential use.");
+ return auth;
+ }
+
+ private void warnOnce(String id, String origin, String message) {
+ if (reported.add(id + "->" + origin)) {
+ logger.warn(message);
+ }
+ }
+
+ /**
+ * Returns the normalized origin ({@code protocol://host[:port]}, lower-cased, default http/https
+ * ports elided) of the given URL, or {@code null} if the URL has no parseable server authority.
+ */
+ static String originOf(String url) {
+ if (url == null) {
+ return null;
+ }
+ try {
+ URI uri = new URI(url).parseServerAuthority();
+ String scheme = uri.getScheme();
+ String host = uri.getHost();
+ if (scheme == null || host == null) {
+ return null;
+ }
+ scheme = scheme.toLowerCase(Locale.ROOT);
+ host = host.toLowerCase(Locale.ROOT);
+ int port = uri.getPort();
+ if ((port == 80 && "http".equals(scheme)) || (port == 443 && "https".equals(scheme))) {
+ port = -1;
+ }
+ return port >= 0 ? scheme + "://" + host + ":" + port : scheme + "://" + host;
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ }
+}
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelectorTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelectorTest.java
new file mode 100644
index 000000000000..794213ab17be
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelectorTest.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.aether;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.aether.repository.AuthenticationSelector;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.util.repository.AuthenticationBuilder;
+import org.eclipse.aether.util.repository.DefaultAuthenticationSelector;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * UT for {@link OriginBoundAuthenticationSelector}.
+ */
+class OriginBoundAuthenticationSelectorTest {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(OriginBoundAuthenticationSelectorTest.class);
+
+ private static AuthenticationSelector serverCredentials(String... ids) {
+ DefaultAuthenticationSelector selector = new DefaultAuthenticationSelector();
+ for (String id : ids) {
+ selector.add(
+ id,
+ new AuthenticationBuilder()
+ .addUsername("user")
+ .addPassword("pass")
+ .build());
+ }
+ return selector;
+ }
+
+ private static Map> declared(String id, String url) {
+ Map> origins = new HashMap<>();
+ OriginBoundAuthenticationSelector.addOrigin(origins, id, url);
+ return origins;
+ }
+
+ private static RemoteRepository repo(String id, String url) {
+ return new RemoteRepository.Builder(id, "default", url).build();
+ }
+
+ @Test
+ void credentialsServedForDeclaredOrigin() {
+ AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap(
+ serverCredentials("releases"),
+ OriginBoundAuthenticationSelector.SCOPE_ORIGIN,
+ declared("releases", "https://repo.example.org/releases/"),
+ LOGGER);
+
+ assertNotNull(selector.getAuthentication(repo("releases", "https://repo.example.org/releases/")));
+ }
+
+ @Test
+ void authenticationScopedToDeclaredOrigin() {
+ // credentials are scoped to the declared origin, so a different-origin
+ // repository with the same id is not served
+ AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap(
+ serverCredentials("releases"),
+ OriginBoundAuthenticationSelector.SCOPE_ORIGIN,
+ declared("releases", "https://repo.example.org/releases/"),
+ LOGGER);
+
+ assertNull(selector.getAuthentication(repo("releases", "https://other.example.org/m2/")));
+ // an unparseable URL on a bound id fails closed as well
+ assertNull(selector.getAuthentication(repo("releases", "notaurl")));
+ }
+
+ @Test
+ void undeclaredIdKeepsLegacyBehaviorInOriginScope() {
+ // e.g. a pure deployment server whose URL only exists in the project's distributionManagement
+ AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap(
+ serverCredentials("deploy-server"),
+ OriginBoundAuthenticationSelector.SCOPE_ORIGIN,
+ new HashMap<>(),
+ LOGGER);
+
+ assertNotNull(selector.getAuthentication(repo("deploy-server", "https://deploy.example.org/releases/")));
+ }
+
+ @Test
+ void undeclaredIdRefusedInStrictScope() {
+ AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap(
+ serverCredentials("deploy-server"),
+ OriginBoundAuthenticationSelector.SCOPE_STRICT,
+ new HashMap<>(),
+ LOGGER);
+
+ assertNull(selector.getAuthentication(repo("deploy-server", "https://deploy.example.org/releases/")));
+ }
+
+ @Test
+ void idScopeReturnsUnwrappedDelegate() {
+ AuthenticationSelector delegate = serverCredentials("releases");
+ AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap(
+ delegate,
+ OriginBoundAuthenticationSelector.SCOPE_ID,
+ declared("releases", "https://repo.example.org/releases/"),
+ LOGGER);
+
+ assertSame(delegate, selector);
+ assertNotNull(selector.getAuthentication(repo("releases", "https://other.example.org/m2/")));
+ }
+
+ @Test
+ void unknownScopeIsRejected() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> OriginBoundAuthenticationSelector.wrap(serverCredentials(), "bogus", new HashMap<>(), LOGGER));
+ }
+
+ @Test
+ void originsAreNormalized() {
+ assertEquals(
+ OriginBoundAuthenticationSelector.originOf("https://repo.example.org/releases/"),
+ OriginBoundAuthenticationSelector.originOf("HTTPS://Repo.Example.Org:443/other/path"));
+ assertEquals(
+ OriginBoundAuthenticationSelector.originOf("http://repo.example.org:80/"),
+ OriginBoundAuthenticationSelector.originOf("http://repo.example.org/releases/"));
+ assertNull(OriginBoundAuthenticationSelector.originOf("file:/tmp/repo"));
+ assertNull(OriginBoundAuthenticationSelector.originOf(null));
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java
index 5620c4c4268e..97a8853cd7b8 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java
@@ -18,7 +18,6 @@
*/
package org.apache.maven.impl.resolver;
-import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.ArrayList;
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java
index 28aa6386ecf6..c4cc9282e45b 100644
--- a/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java
@@ -61,7 +61,7 @@ private static Model newModel(String groupId, String artifactId, String version)
}
@Test
- void noRelocationReturnsNull() {
+ void noRelocationReturnsNull() throws Exception {
Model model = Model.newBuilder().build();
Artifact result = source.relocatedTarget(null, newResult(), model);
assertNull(result);