Browse Source
* pr/45269: Add Docker configuration authentication to Maven and Gradle plugins Support Docker configuration authentication including helper support Polish 'Update `DockerConfigurationMetadata` to support credentials' Update `DockerConfigurationMetadata` to support credentials Closes gh-45269pull/45286/head
23 changed files with 1235 additions and 44 deletions
@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
|
||||
/* |
||||
* Copyright 2012-2025 the original author or authors. |
||||
* |
||||
* Licensed 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 |
||||
* |
||||
* https://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.springframework.boot.buildpack.platform.docker.configuration; |
||||
|
||||
import java.lang.invoke.MethodHandles; |
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
||||
import org.springframework.boot.buildpack.platform.json.MappedObject; |
||||
|
||||
/** |
||||
* A class that represents credentials for a server as returned from a |
||||
* {@link CredentialHelper}. |
||||
* |
||||
* @author Dmytro Nosan |
||||
*/ |
||||
class Credential extends MappedObject { |
||||
|
||||
/** |
||||
* If the secret being stored is an identity token, the username should be set to |
||||
* {@code <token>}. |
||||
*/ |
||||
private static final String TOKEN_USERNAME = "<token>"; |
||||
|
||||
private final String username; |
||||
|
||||
private final String secret; |
||||
|
||||
private String serverUrl; |
||||
|
||||
Credential(JsonNode node) { |
||||
super(node, MethodHandles.lookup()); |
||||
this.username = valueAt("/Username", String.class); |
||||
this.secret = valueAt("/Secret", String.class); |
||||
this.serverUrl = valueAt("/ServerURL", String.class); |
||||
} |
||||
|
||||
String getUsername() { |
||||
return this.username; |
||||
} |
||||
|
||||
String getSecret() { |
||||
return this.secret; |
||||
} |
||||
|
||||
String getServerUrl() { |
||||
return this.serverUrl; |
||||
} |
||||
|
||||
boolean isIdentityToken() { |
||||
return TOKEN_USERNAME.equals(this.username); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
|
||||
/* |
||||
* Copyright 2012-2025 the original author or authors. |
||||
* |
||||
* Licensed 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 |
||||
* |
||||
* https://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.springframework.boot.buildpack.platform.docker.configuration; |
||||
|
||||
import java.io.IOException; |
||||
import java.io.InputStream; |
||||
import java.io.OutputStream; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
import java.util.Set; |
||||
|
||||
import com.sun.jna.Platform; |
||||
|
||||
import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; |
||||
|
||||
/** |
||||
* Invokes a Docker credential helper executable that can be used to get {@link Credential |
||||
* credentials}. |
||||
* |
||||
* @author Dmytro Nosan |
||||
* @author Phillip Webb |
||||
*/ |
||||
class CredentialHelper { |
||||
|
||||
private static final String USR_LOCAL_BIN = "/usr/local/bin/"; |
||||
|
||||
Set<String> CREDENTIAL_NOT_FOUND_MESSAGES = Set.of("credentials not found in native keychain", |
||||
"no credentials server URL", "no credentials username"); |
||||
|
||||
private final String executable; |
||||
|
||||
CredentialHelper(String executable) { |
||||
this.executable = executable; |
||||
} |
||||
|
||||
Credential get(String serverUrl) throws IOException { |
||||
ProcessBuilder processBuilder = processBuilder("get"); |
||||
Process process = start(processBuilder); |
||||
try (OutputStream request = process.getOutputStream()) { |
||||
request.write(serverUrl.getBytes(StandardCharsets.UTF_8)); |
||||
} |
||||
try { |
||||
int exitCode = process.waitFor(); |
||||
try (InputStream response = process.getInputStream()) { |
||||
if (exitCode == 0) { |
||||
return new Credential(SharedObjectMapper.get().readTree(response)); |
||||
} |
||||
String errorMessage = new String(response.readAllBytes(), StandardCharsets.UTF_8); |
||||
if (!isCredentialsNotFoundError(errorMessage)) { |
||||
throw new IOException("%s' exited with code %d: %s".formatted(process, exitCode, errorMessage)); |
||||
} |
||||
return null; |
||||
} |
||||
} |
||||
catch (InterruptedException ex) { |
||||
Thread.currentThread().interrupt(); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
private ProcessBuilder processBuilder(String string) { |
||||
ProcessBuilder processBuilder = new ProcessBuilder().redirectErrorStream(true); |
||||
if (Platform.isWindows()) { |
||||
processBuilder.command("cmd", "/c"); |
||||
} |
||||
processBuilder.command(this.executable, string); |
||||
return processBuilder; |
||||
} |
||||
|
||||
private Process start(ProcessBuilder processBuilder) throws IOException { |
||||
try { |
||||
return processBuilder.start(); |
||||
} |
||||
catch (IOException ex) { |
||||
if (!Platform.isMac()) { |
||||
throw ex; |
||||
} |
||||
List<String> command = new ArrayList<>(processBuilder.command()); |
||||
command.set(0, USR_LOCAL_BIN + command.get(0)); |
||||
return processBuilder.command(command).start(); |
||||
} |
||||
} |
||||
|
||||
private boolean isCredentialsNotFoundError(String message) { |
||||
return this.CREDENTIAL_NOT_FOUND_MESSAGES.contains(message.trim()); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
|
||||
/* |
||||
* Copyright 2012-2025 the original author or authors. |
||||
* |
||||
* Licensed 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 |
||||
* |
||||
* https://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.springframework.boot.buildpack.platform.docker.configuration; |
||||
|
||||
import java.io.IOException; |
||||
import java.util.Map; |
||||
import java.util.Map.Entry; |
||||
import java.util.concurrent.ConcurrentHashMap; |
||||
import java.util.function.BiConsumer; |
||||
import java.util.function.Function; |
||||
|
||||
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfigurationMetadata.Auth; |
||||
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfigurationMetadata.DockerConfig; |
||||
import org.springframework.boot.buildpack.platform.docker.type.ImageReference; |
||||
import org.springframework.boot.buildpack.platform.system.Environment; |
||||
import org.springframework.util.StringUtils; |
||||
|
||||
/** |
||||
* {@link DockerRegistryAuthentication} for |
||||
* {@link DockerRegistryAuthentication#configuration(DockerRegistryAuthentication, BiConsumer)}. |
||||
* |
||||
* @author Dmytro Nosan |
||||
* @author Phillip Webb |
||||
*/ |
||||
class DockerRegistryConfigAuthentication implements DockerRegistryAuthentication { |
||||
|
||||
private static final String DEFAULT_DOMAIN = "docker.io"; |
||||
|
||||
private static final String INDEX_URL = "https://index.docker.io/v1/"; |
||||
|
||||
private static Map<String, Credential> credentialFromHelperCache = new ConcurrentHashMap<>(); |
||||
|
||||
private final DockerRegistryAuthentication fallback; |
||||
|
||||
private final BiConsumer<String, Exception> credentialHelperExceptionHandler; |
||||
|
||||
private final Function<String, CredentialHelper> credentialHelperFactory; |
||||
|
||||
private final DockerConfig dockerConfig; |
||||
|
||||
DockerRegistryConfigAuthentication(DockerRegistryAuthentication fallback, |
||||
BiConsumer<String, Exception> credentialHelperExceptionHandler) { |
||||
this(fallback, credentialHelperExceptionHandler, Environment.SYSTEM, |
||||
(helper) -> new CredentialHelper("docker-credential-" + helper.trim())); |
||||
} |
||||
|
||||
DockerRegistryConfigAuthentication(DockerRegistryAuthentication fallback, |
||||
BiConsumer<String, Exception> credentialHelperExceptionHandler, Environment environment, |
||||
Function<String, CredentialHelper> credentialHelperFactory) { |
||||
this.fallback = fallback; |
||||
this.credentialHelperExceptionHandler = credentialHelperExceptionHandler; |
||||
this.dockerConfig = DockerConfigurationMetadata.from(environment).getConfiguration(); |
||||
this.credentialHelperFactory = credentialHelperFactory; |
||||
} |
||||
|
||||
@Override |
||||
public String getAuthHeader() { |
||||
return getAuthHeader(null); |
||||
} |
||||
|
||||
@Override |
||||
public String getAuthHeader(ImageReference imageReference) { |
||||
String serverUrl = getServerUrl(imageReference); |
||||
DockerRegistryAuthentication authentication = getAuthentication(serverUrl); |
||||
return (authentication != null) ? authentication.getAuthHeader(imageReference) : null; |
||||
} |
||||
|
||||
private String getServerUrl(ImageReference imageReference) { |
||||
String domain = (imageReference != null) ? imageReference.getDomain() : null; |
||||
return (!DEFAULT_DOMAIN.equals(domain)) ? domain : INDEX_URL; |
||||
} |
||||
|
||||
private DockerRegistryAuthentication getAuthentication(String serverUrl) { |
||||
Credential credentialsFromHelper = getCredentialsFromHelper(serverUrl); |
||||
Map.Entry<String, Auth> authConfigEntry = getAuthConfigEntry(serverUrl); |
||||
serverUrl = (authConfigEntry != null) ? authConfigEntry.getKey() : serverUrl; |
||||
Auth authConfig = (authConfigEntry != null) ? authConfigEntry.getValue() : null; |
||||
if (credentialsFromHelper != null) { |
||||
return getAuthentication(credentialsFromHelper, authConfig, serverUrl); |
||||
} |
||||
if (authConfigEntry != null) { |
||||
return DockerRegistryAuthentication.user(authConfig.getUsername(), authConfig.getPassword(), serverUrl, |
||||
authConfig.getEmail()); |
||||
} |
||||
return this.fallback; |
||||
} |
||||
|
||||
private DockerRegistryAuthentication getAuthentication(Credential credentialsFromHelper, Auth authConfig, |
||||
String serverUrl) { |
||||
if (credentialsFromHelper.isIdentityToken()) { |
||||
return DockerRegistryAuthentication.token(credentialsFromHelper.getSecret()); |
||||
} |
||||
String username = credentialsFromHelper.getUsername(); |
||||
String password = credentialsFromHelper.getSecret(); |
||||
String serverAddress = (credentialsFromHelper.getServerUrl() != null |
||||
&& !credentialsFromHelper.getServerUrl().isEmpty()) ? credentialsFromHelper.getServerUrl() : serverUrl; |
||||
String email = (authConfig != null) ? authConfig.getEmail() : null; |
||||
return DockerRegistryAuthentication.user(username, password, serverAddress, email); |
||||
} |
||||
|
||||
private Credential getCredentialsFromHelper(String serverUrl) { |
||||
return (StringUtils.hasText(serverUrl)) |
||||
? credentialFromHelperCache.computeIfAbsent(serverUrl, this::computeCredentialsFromHelper) : null; |
||||
} |
||||
|
||||
private Credential computeCredentialsFromHelper(String serverUrl) { |
||||
CredentialHelper credentialHelper = getCredentialHelper(serverUrl); |
||||
if (credentialHelper != null) { |
||||
try { |
||||
return credentialHelper.get(serverUrl); |
||||
} |
||||
catch (IOException ex) { |
||||
String message = "Error retrieving credentials for '%s' due to: %s".formatted(serverUrl, |
||||
ex.getMessage()); |
||||
this.credentialHelperExceptionHandler.accept(message, ex); |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
private CredentialHelper getCredentialHelper(String serverUrl) { |
||||
String name = this.dockerConfig.getCredHelpers().get(serverUrl); |
||||
name = (StringUtils.hasText(name)) ? name : this.dockerConfig.getCredsStore(); |
||||
return (name != null) ? this.credentialHelperFactory.apply(name.trim()) : null; |
||||
} |
||||
|
||||
private Entry<String, Auth> getAuthConfigEntry(String serverUrl) { |
||||
for (Map.Entry<String, Auth> candidate : this.dockerConfig.getAuths().entrySet()) { |
||||
if (candidate.getKey().equals(serverUrl) || candidate.getKey().endsWith("://" + serverUrl)) { |
||||
return candidate; |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
|
||||
/* |
||||
* Copyright 2012-2025 the original author or authors. |
||||
* |
||||
* Licensed 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 |
||||
* |
||||
* https://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.springframework.boot.buildpack.platform.docker.configuration; |
||||
|
||||
import java.util.UUID; |
||||
|
||||
import com.sun.jna.Platform; |
||||
import org.junit.jupiter.api.BeforeAll; |
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.core.io.ClassPathResource; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
import static org.assertj.core.api.Assertions.assertThatIOException; |
||||
|
||||
/** |
||||
* Tests for {@link CredentialHelper}. |
||||
* |
||||
* @author Dmytro Nosan |
||||
*/ |
||||
class CredentialHelperTests { |
||||
|
||||
private static CredentialHelper helper; |
||||
|
||||
@BeforeAll |
||||
static void setUp() throws Exception { |
||||
String executableName = "docker-credential-test" + ((Platform.isWindows()) ? ".bat" : ".sh"); |
||||
String executable = new ClassPathResource(executableName, CredentialHelperTests.class).getFile() |
||||
.getAbsolutePath(); |
||||
helper = new CredentialHelper(executable); |
||||
} |
||||
|
||||
@Test |
||||
void getWhenKnowUser() throws Exception { |
||||
Credential credentials = helper.get("user.example.com"); |
||||
assertThat(credentials).isNotNull(); |
||||
assertThat(credentials.isIdentityToken()).isFalse(); |
||||
assertThat(credentials.getServerUrl()).isEqualTo("user.example.com"); |
||||
assertThat(credentials.getUsername()).isEqualTo("username"); |
||||
assertThat(credentials.getSecret()).isEqualTo("secret"); |
||||
} |
||||
|
||||
@Test |
||||
void getWhenKnowToken() throws Exception { |
||||
Credential credentials = helper.get("token.example.com"); |
||||
assertThat(credentials).isNotNull(); |
||||
assertThat(credentials.isIdentityToken()).isTrue(); |
||||
assertThat(credentials.getServerUrl()).isEqualTo("token.example.com"); |
||||
assertThat(credentials.getUsername()).isEqualTo("<token>"); |
||||
assertThat(credentials.getSecret()).isEqualTo("secret"); |
||||
} |
||||
|
||||
@Test |
||||
void getWhenCredentialsMissingMessageReturnsNull() throws Exception { |
||||
Credential credentials = helper.get("credentials.missing.example.com"); |
||||
assertThat(credentials).isNull(); |
||||
} |
||||
|
||||
@Test |
||||
void getWhenUsernameMissingMessageReturnsNull() throws Exception { |
||||
Credential credentials = helper.get("username.missing.example.com"); |
||||
assertThat(credentials).isNull(); |
||||
} |
||||
|
||||
@Test |
||||
void getWhenUrlMissingMessageReturnsNull() throws Exception { |
||||
Credential credentials = helper.get("url.missing.example.com"); |
||||
assertThat(credentials).isNull(); |
||||
} |
||||
|
||||
@Test |
||||
void getWhenUnknownErrorThrowsException() { |
||||
assertThatIOException().isThrownBy(() -> helper.get("invalid.example.com")) |
||||
.withMessageContaining("Unknown error"); |
||||
} |
||||
|
||||
@Test |
||||
void getWhenCommandDoesNotExistErrorThrowsException() { |
||||
String name = "docker-credential-%s".formatted(UUID.randomUUID().toString()); |
||||
assertThatIOException().isThrownBy(() -> new CredentialHelper(name).get("invalid.example.com")) |
||||
.withMessageContaining(name); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
/* |
||||
* Copyright 2012-2025 the original author or authors. |
||||
* |
||||
* Licensed 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 |
||||
* |
||||
* https://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.springframework.boot.buildpack.platform.docker.configuration; |
||||
|
||||
import java.io.IOException; |
||||
import java.io.InputStream; |
||||
|
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; |
||||
import org.springframework.boot.testsupport.classpath.resources.WithResource; |
||||
import org.springframework.core.io.ClassPathResource; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
||||
/** |
||||
* Tests for {@link Credential}. |
||||
* |
||||
* @author Dmytro Nosan |
||||
*/ |
||||
class CredentialsTests { |
||||
|
||||
@Test |
||||
@WithResource(name = "credentials.json", content = """ |
||||
{ |
||||
"ServerURL": "https://index.docker.io/v1/", |
||||
"Username": "user", |
||||
"Secret": "secret" |
||||
} |
||||
""") |
||||
void createWhenUserCredentials() throws Exception { |
||||
Credential credentials = getCredentials("credentials.json"); |
||||
assertThat(credentials.getUsername()).isEqualTo("user"); |
||||
assertThat(credentials.getSecret()).isEqualTo("secret"); |
||||
assertThat(credentials.getServerUrl()).isEqualTo("https://index.docker.io/v1/"); |
||||
assertThat(credentials.isIdentityToken()).isFalse(); |
||||
} |
||||
|
||||
@Test |
||||
@WithResource(name = "credentials.json", content = """ |
||||
{ |
||||
"ServerURL": "https://index.docker.io/v1/", |
||||
"Username": "<token>", |
||||
"Secret": "secret" |
||||
} |
||||
""") |
||||
void createWhenTokenCredentials() throws Exception { |
||||
Credential credentials = getCredentials("credentials.json"); |
||||
assertThat(credentials.getUsername()).isEqualTo("<token>"); |
||||
assertThat(credentials.getSecret()).isEqualTo("secret"); |
||||
assertThat(credentials.getServerUrl()).isEqualTo("https://index.docker.io/v1/"); |
||||
assertThat(credentials.isIdentityToken()).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
@WithResource(name = "credentials.json", content = """ |
||||
{ |
||||
"Username": "user", |
||||
"Secret": "secret" |
||||
} |
||||
""") |
||||
void createWhenNoServerUrl() throws Exception { |
||||
Credential credentials = getCredentials("credentials.json"); |
||||
assertThat(credentials.getUsername()).isEqualTo("user"); |
||||
assertThat(credentials.getSecret()).isEqualTo("secret"); |
||||
assertThat(credentials.getServerUrl()).isNull(); |
||||
assertThat(credentials.isIdentityToken()).isFalse(); |
||||
} |
||||
|
||||
private Credential getCredentials(String name) throws IOException { |
||||
try (InputStream inputStream = new ClassPathResource(name).getInputStream()) { |
||||
return new Credential(SharedObjectMapper.get().readTree(inputStream)); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,357 @@
@@ -0,0 +1,357 @@
|
||||
/* |
||||
* Copyright 2012-2025 the original author or authors. |
||||
* |
||||
* Licensed 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 |
||||
* |
||||
* https://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.springframework.boot.buildpack.platform.docker.configuration; |
||||
|
||||
import java.io.IOException; |
||||
import java.io.InputStream; |
||||
import java.nio.file.Path; |
||||
import java.util.Base64; |
||||
import java.util.HashMap; |
||||
import java.util.LinkedHashMap; |
||||
import java.util.Map; |
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference; |
||||
import org.junit.jupiter.api.Test; |
||||
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
||||
import org.springframework.boot.buildpack.platform.docker.type.ImageReference; |
||||
import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; |
||||
import org.springframework.boot.testsupport.classpath.resources.ResourcesRoot; |
||||
import org.springframework.boot.testsupport.classpath.resources.WithResource; |
||||
import org.springframework.boot.testsupport.system.OutputCaptureExtension; |
||||
import org.springframework.core.io.ClassPathResource; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
import static org.mockito.BDDMockito.given; |
||||
import static org.mockito.Mockito.mock; |
||||
|
||||
/** |
||||
* Tests for {@link DockerRegistryConfigAuthentication}. |
||||
* |
||||
* @author Dmytro Nosan |
||||
* @author Phillip Webb |
||||
*/ |
||||
@ExtendWith(OutputCaptureExtension.class) |
||||
class DockerRegistryConfigAuthenticationTests { |
||||
|
||||
private final Map<String, String> environment = new LinkedHashMap<>(); |
||||
|
||||
private final Map<String, Exception> helperExceptions = new LinkedHashMap<>(); |
||||
|
||||
private Map<String, CredentialHelper> credentialHelpers = new HashMap<>(); |
||||
|
||||
@WithResource(name = "config.json", content = """ |
||||
{ |
||||
"auths": { |
||||
"https://index.docker.io/v1/": { |
||||
"auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", |
||||
"email": "test@gmail.com" |
||||
} |
||||
} |
||||
} |
||||
""") |
||||
@Test |
||||
void getAuthHeaderWhenAuthForDockerDomain(@ResourcesRoot Path directory) throws Exception { |
||||
this.environment.put("DOCKER_CONFIG", directory.toString()); |
||||
ImageReference imageReference = ImageReference.of("docker.io/ubuntu:latest"); |
||||
String authHeader = getAuthHeader(imageReference); |
||||
assertThat(decode(authHeader)).hasSize(4) |
||||
.containsEntry("serveraddress", "https://index.docker.io/v1/") |
||||
.containsEntry("username", "username") |
||||
.containsEntry("password", "password") |
||||
.containsEntry("email", "test@gmail.com"); |
||||
} |
||||
|
||||
@WithResource(name = "config.json", content = """ |
||||
{ |
||||
"auths": { |
||||
"https://index.docker.io/v1/": { |
||||
"auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", |
||||
"email": "test@gmail.com" |
||||
} |
||||
} |
||||
} |
||||
""") |
||||
@Test |
||||
void getAuthHeaderWhenAuthForLegacyDockerDomain(@ResourcesRoot Path directory) throws Exception { |
||||
this.environment.put("DOCKER_CONFIG", directory.toString()); |
||||
ImageReference imageReference = ImageReference.of("index.docker.io/ubuntu:latest"); |
||||
String authHeader = getAuthHeader(imageReference); |
||||
assertThat(decode(authHeader)).hasSize(4) |
||||
.containsEntry("serveraddress", "https://index.docker.io/v1/") |
||||
.containsEntry("username", "username") |
||||
.containsEntry("password", "password") |
||||
.containsEntry("email", "test@gmail.com"); |
||||
} |
||||
|
||||
@WithResource(name = "config.json", content = """ |
||||
{ |
||||
"auths": { |
||||
"my-registry.example.com": { |
||||
"auth": "Y3VzdG9tVXNlcjpjdXN0b21QYXNz" |
||||
} |
||||
} |
||||
} |
||||
""") |
||||
@Test |
||||
void getAuthHeaderWhenAuthForCustomDomain(@ResourcesRoot Path directory) throws Exception { |
||||
this.environment.put("DOCKER_CONFIG", directory.toString()); |
||||
ImageReference imageReference = ImageReference.of("my-registry.example.com/ubuntu:latest"); |
||||
String authHeader = getAuthHeader(imageReference); |
||||
assertThat(decode(authHeader)).hasSize(4) |
||||
.containsEntry("serveraddress", "my-registry.example.com") |
||||
.containsEntry("username", "customUser") |
||||
.containsEntry("password", "customPass") |
||||
.containsEntry("email", null); |
||||
} |
||||
|
||||
@WithResource(name = "config.json", content = """ |
||||
{ |
||||
"auths": { |
||||
"https://my-registry.example.com": { |
||||
"auth": "Y3VzdG9tVXNlcjpjdXN0b21QYXNz" |
||||
} |
||||
} |
||||
} |
||||
""") |
||||
@Test |
||||
void getAuthHeaderWhenAuthForCustomDomainWithLegacyFormat(@ResourcesRoot Path directory) throws Exception { |
||||
this.environment.put("DOCKER_CONFIG", directory.toString()); |
||||
ImageReference imageReference = ImageReference.of("my-registry.example.com/ubuntu:latest"); |
||||
String authHeader = getAuthHeader(imageReference); |
||||
assertThat(decode(authHeader)).hasSize(4) |
||||
.containsEntry("serveraddress", "https://my-registry.example.com") |
||||
.containsEntry("username", "customUser") |
||||
.containsEntry("password", "customPass") |
||||
.containsEntry("email", null); |
||||
} |
||||
|
||||
@WithResource(name = "config.json", content = """ |
||||
{ |
||||
} |
||||
""") |
||||
@Test |
||||
void getAuthHeaderWhenEmptyConfigDirectoryReturnsFallback(@ResourcesRoot Path directory) throws Exception { |
||||
this.environment.put("DOCKER_CONFIG", directory.toString()); |
||||
ImageReference imageReference = ImageReference.of("docker.io/ubuntu:latest"); |
||||
String authHeader = getAuthHeader(imageReference, DockerRegistryAuthentication.EMPTY_USER); |
||||
assertThat(decode(authHeader)).hasSize(4) |
||||
.containsEntry("serveraddress", "") |
||||
.containsEntry("username", "") |
||||
.containsEntry("password", "") |
||||
.containsEntry("email", ""); |
||||
} |
||||
|
||||
@WithResource(name = "config.json", content = """ |
||||
{ |
||||
"credsStore": "desktop" |
||||
} |
||||
""") |
||||
@WithResource(name = "credentials.json", content = """ |
||||
{ |
||||
"ServerURL": "https://index.docker.io/v1/", |
||||
"Username": "<token>", |
||||
"Secret": "secret" |
||||
} |
||||
""") |
||||
@Test |
||||
void getAuthHeaderWhenUsingHelperFromCredsStore(@ResourcesRoot Path directory) throws Exception { |
||||
this.environment.put("DOCKER_CONFIG", directory.toString()); |
||||
ImageReference imageReference = ImageReference.of("docker.io/ubuntu:latest"); |
||||
mockHelper("desktop", "https://index.docker.io/v1/", "credentials.json"); |
||||
String authHeader = getAuthHeader(imageReference); |
||||
assertThat(decode(authHeader)).hasSize(1).containsEntry("identitytoken", "secret"); |
||||
} |
||||
|
||||
@WithResource(name = "config.json", content = """ |
||||
{ |
||||
"auths": { |
||||
"gcr.io": { |
||||
"email": "test@gmail.com" |
||||
} |
||||
}, |
||||
"credsStore": "desktop", |
||||
"credHelpers": { |
||||
"gcr.io": "gcr" |
||||
} |
||||
} |
||||
""") |
||||
@WithResource(name = "credentials.json", content = """ |
||||
{ |
||||
"ServerURL": "https://my-gcr.io", |
||||
"Username": "username", |
||||
"Secret": "secret" |
||||
} |
||||
""") |
||||
@Test |
||||
void getAuthHeaderWhenUsingHelperFromCredsStoreAndUseEmailFromAuth(@ResourcesRoot Path directory) throws Exception { |
||||
this.environment.put("DOCKER_CONFIG", directory.toString()); |
||||
ImageReference imageReference = ImageReference.of("gcr.io/ubuntu:latest"); |
||||
mockHelper("gcr", "gcr.io", "credentials.json"); |
||||
String authHeader = getAuthHeader(imageReference); |
||||
assertThat(decode(authHeader)).hasSize(4) |
||||
.containsEntry("serveraddress", "https://my-gcr.io") |
||||
.containsEntry("username", "username") |
||||
.containsEntry("password", "secret") |
||||
.containsEntry("email", "test@gmail.com"); |
||||
} |
||||
|
||||
@WithResource(name = "config.json", content = """ |
||||
{ |
||||
"credsStore": "desktop", |
||||
"credHelpers": { |
||||
"gcr.io": "gcr" |
||||
} |
||||
} |
||||
""") |
||||
@WithResource(name = "credentials.json", content = """ |
||||
{ |
||||
"Username": "username", |
||||
"Secret": "secret" |
||||
} |
||||
""") |
||||
@Test |
||||
void getAuthHeaderWhenUsingHelperFromCredHelpersUsesProvidedServerUrl(@ResourcesRoot Path directory) |
||||
throws Exception { |
||||
this.environment.put("DOCKER_CONFIG", directory.toString()); |
||||
ImageReference imageReference = ImageReference.of("gcr.io/ubuntu:latest"); |
||||
mockHelper("gcr", "gcr.io", "credentials.json"); |
||||
String authHeader = getAuthHeader(imageReference); |
||||
assertThat(decode(authHeader)).hasSize(4) |
||||
.containsEntry("serveraddress", "gcr.io") |
||||
.containsEntry("username", "username") |
||||
.containsEntry("password", "secret") |
||||
.containsEntry("email", null); |
||||
} |
||||
|
||||
@WithResource(name = "config.json", content = """ |
||||
{ |
||||
"auths": { |
||||
"gcr.io": { |
||||
"auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", |
||||
"email": "test@gmail.com" |
||||
} |
||||
}, |
||||
"credsStore": "desktop", |
||||
"credHelpers": { |
||||
"gcr.io": "gcr" |
||||
} |
||||
} |
||||
""") |
||||
@Test |
||||
void getAuthHeaderWhenUsingHelperThatFailsLogsErrorAndReturnsFromAuths(@ResourcesRoot Path directory) |
||||
throws Exception { |
||||
this.environment.put("DOCKER_CONFIG", directory.toString()); |
||||
ImageReference imageReference = ImageReference.of("gcr.io/ubuntu:latest"); |
||||
CredentialHelper helper = mockHelper("gcr"); |
||||
given(helper.get("gcr.io")).willThrow(new IOException("Failed to obtain credentials for registry")); |
||||
String authHeader = getAuthHeader(imageReference); |
||||
assertThat(decode(authHeader)).hasSize(4) |
||||
.containsEntry("serveraddress", "gcr.io") |
||||
.containsEntry("username", "username") |
||||
.containsEntry("password", "password") |
||||
.containsEntry("email", "test@gmail.com"); |
||||
assertThat(this.helperExceptions).hasSize(1); |
||||
assertThat(this.helperExceptions.keySet().iterator().next()) |
||||
.contains("Error retrieving credentials for 'gcr.io' due to: Failed to obtain credentials for registry"); |
||||
} |
||||
|
||||
@WithResource(name = "config.json", content = """ |
||||
{ |
||||
"credsStore": "desktop", |
||||
"credHelpers": { |
||||
"gcr.io": "gcr" |
||||
} |
||||
} |
||||
""") |
||||
@Test |
||||
void getAuthHeaderWhenUsingHelperThatFailsAndNoAuthLogsErrorAndReturnsFallback(@ResourcesRoot Path directory) |
||||
throws Exception { |
||||
this.environment.put("DOCKER_CONFIG", directory.toString()); |
||||
ImageReference imageReference = ImageReference.of("gcr.io/ubuntu:latest"); |
||||
CredentialHelper helper = mockHelper("gcr"); |
||||
given(helper.get("gcr.io")).willThrow(new IOException("Failed to obtain credentials for registry")); |
||||
String authHeader = getAuthHeader(imageReference, DockerRegistryAuthentication.EMPTY_USER); |
||||
assertThat(this.helperExceptions).hasSize(1); |
||||
assertThat(this.helperExceptions.keySet().iterator().next()) |
||||
.contains("Error retrieving credentials for 'gcr.io' due to: Failed to obtain credentials for registry"); |
||||
assertThat(decode(authHeader)).hasSize(4) |
||||
.containsEntry("serveraddress", "") |
||||
.containsEntry("username", "") |
||||
.containsEntry("password", "") |
||||
.containsEntry("email", ""); |
||||
} |
||||
|
||||
@WithResource(name = "config.json", content = """ |
||||
{ |
||||
"credsStore": "desktop", |
||||
"credHelpers": { |
||||
"gcr.io": "" |
||||
} |
||||
} |
||||
""") |
||||
@Test |
||||
void getAuthHeaderWhenEmptyCredHelperReturnsFallbackAndDoesNotUseCredStore(@ResourcesRoot Path directory) |
||||
throws Exception { |
||||
this.environment.put("DOCKER_CONFIG", directory.toString()); |
||||
ImageReference imageReference = ImageReference.of("gcr.io/ubuntu:latest"); |
||||
String authHeader = getAuthHeader(imageReference, DockerRegistryAuthentication.EMPTY_USER); |
||||
assertThat(decode(authHeader)).hasSize(4) |
||||
.containsEntry("serveraddress", "") |
||||
.containsEntry("username", "") |
||||
.containsEntry("password", "") |
||||
.containsEntry("email", ""); |
||||
} |
||||
|
||||
private String getAuthHeader(ImageReference imageReference) { |
||||
return getAuthHeader(imageReference, null); |
||||
} |
||||
|
||||
private String getAuthHeader(ImageReference imageReference, DockerRegistryAuthentication fallback) { |
||||
DockerRegistryConfigAuthentication authentication = getAuthentication(fallback); |
||||
return authentication.getAuthHeader(imageReference); |
||||
} |
||||
|
||||
private DockerRegistryConfigAuthentication getAuthentication(DockerRegistryAuthentication fallback) { |
||||
return new DockerRegistryConfigAuthentication(fallback, this.helperExceptions::put, this.environment::get, |
||||
this.credentialHelpers::get); |
||||
} |
||||
|
||||
private void mockHelper(String name, String serverUrl, String credentialsResourceName) throws Exception { |
||||
CredentialHelper helper = mockHelper(name); |
||||
given(helper.get(serverUrl)).willReturn(getCredentials(credentialsResourceName)); |
||||
} |
||||
|
||||
private CredentialHelper mockHelper(String name) { |
||||
CredentialHelper helper = mock(CredentialHelper.class); |
||||
this.credentialHelpers.put(name, helper); |
||||
return helper; |
||||
} |
||||
|
||||
private Credential getCredentials(String resourceName) throws Exception { |
||||
try (InputStream inputStream = new ClassPathResource(resourceName).getInputStream()) { |
||||
return new Credential(SharedObjectMapper.get().readTree(inputStream)); |
||||
} |
||||
} |
||||
|
||||
private Map<String, String> decode(String authHeader) throws Exception { |
||||
assertThat(authHeader).isNotNull(); |
||||
return SharedObjectMapper.get().readValue(Base64.getDecoder().decode(authHeader), new TypeReference<>() { |
||||
}); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
|
||||
@echo off |
||||
|
||||
set /p registryUrl= |
||||
|
||||
if "%registryUrl%" == "user.example.com" ( |
||||
echo { |
||||
echo "ServerURL": "%registryUrl%", |
||||
echo "Username": "username", |
||||
echo "Secret": "secret" |
||||
echo } |
||||
exit /b 0 |
||||
) |
||||
|
||||
if "%registryUrl%" == "token.example.com" ( |
||||
echo { |
||||
echo "ServerURL": "%registryUrl%", |
||||
echo "Username": "<token>", |
||||
echo "Secret": "secret" |
||||
echo } |
||||
exit /b 0 |
||||
) |
||||
|
||||
if "%registryUrl%" == "url.missing.example.com" ( |
||||
echo no credentials server URL >&2 |
||||
exit /b 1 |
||||
) |
||||
|
||||
if "%registryUrl%" == "username.missing.example.com" ( |
||||
echo no credentials username >&2 |
||||
exit /b 1 |
||||
) |
||||
|
||||
if "%registryUrl%" == "credentials.missing.example.com" ( |
||||
echo credentials not found in native keychain >&2 |
||||
exit /b 1 |
||||
) |
||||
|
||||
echo Unknown error >&2 |
||||
exit /b 1 |
||||
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
#!/bin/sh |
||||
|
||||
read -r registryUrl |
||||
|
||||
if [ "$registryUrl" = "user.example.com" ]; then |
||||
cat <<EOF |
||||
{ |
||||
"ServerURL": "${registryUrl}", |
||||
"Username": "username", |
||||
"Secret": "secret" |
||||
} |
||||
EOF |
||||
exit 0 |
||||
fi |
||||
|
||||
if [ "$registryUrl" = "token.example.com" ]; then |
||||
cat <<EOF |
||||
{ |
||||
"ServerURL": "${registryUrl}", |
||||
"Username": "<token>", |
||||
"Secret": "secret" |
||||
} |
||||
EOF |
||||
exit 0 |
||||
fi |
||||
|
||||
if [ "$registryUrl" = "url.missing.example.com" ]; then |
||||
echo "no credentials server URL" >&2 |
||||
exit 1 |
||||
fi |
||||
|
||||
if [ "$registryUrl" = "username.missing.example.com" ]; then |
||||
echo "no credentials username" >&2 |
||||
exit 1 |
||||
fi |
||||
|
||||
if [ "$registryUrl" = "credentials.missing.example.com" ]; then |
||||
echo "credentials not found in native keychain" >&2 |
||||
exit 1 |
||||
fi |
||||
|
||||
echo "Unknown error" >&2 |
||||
exit 1 |
||||
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
{ |
||||
"auths": { |
||||
"https://index.docker.io/v1/": { |
||||
"auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", |
||||
"email": "test@gmail.com" |
||||
}, |
||||
"custom-registry.example.com": { |
||||
"auth": "Y3VzdG9tVXNlcjpjdXN0b21QYXNz" |
||||
}, |
||||
"my-registry.example.com": { |
||||
"username": "user", |
||||
"password": "password" |
||||
} |
||||
}, |
||||
"credsStore": "desktop", |
||||
"credHelpers": { |
||||
"gcr.io": "gcr", |
||||
"ecr.us-east-1.amazonaws.com": "ecr-login", |
||||
"azurecr.io": "acr-env" |
||||
} |
||||
} |
||||
Loading…
Reference in new issue