Support Docker configuration authentication including helper support

Add `DockerRegistryAuthentication` implementation that uses standard
Docker config to authenticate requests.

Prior to this commit, we only supported username/password and token
based authentication. This commit allows authentication based on
the contents of the Docker configuration file, including support
for executing credential helpers.

See gh-45269

Signed-off-by: Dmytro Nosan <dimanosan@gmail.com>
Co-authored-by: Phillip Webb <phil.webb@broadcom.com>
This commit is contained in:
Dmytro Nosan
2025-04-24 11:57:53 -07:00
committed by Phillip Webb
parent 2a9e30a358
commit e6fbccef7e
13 changed files with 1022 additions and 12 deletions

View File

@@ -246,13 +246,13 @@ public class Builder {
private void pushImage(ImageReference reference) throws IOException {
Consumer<TotalProgressEvent> progressConsumer = this.log.pushingImage(reference);
TotalProgressPushListener listener = new TotalProgressPushListener(progressConsumer);
String authHeader = authHeader(this.dockerConfiguration.publishRegistryAuthentication());
String authHeader = authHeader(this.dockerConfiguration.publishRegistryAuthentication(), reference);
this.docker.image().push(reference, listener, authHeader);
this.log.pushedImage(reference);
}
private static String authHeader(DockerRegistryAuthentication authentication) {
return (authentication != null) ? authentication.getAuthHeader() : null;
private static String authHeader(DockerRegistryAuthentication authentication, ImageReference reference) {
return (authentication != null) ? authentication.getAuthHeader(reference) : null;
}
/**
@@ -279,7 +279,7 @@ public class Builder {
Image fetchImage(ImageType type, ImageReference reference) throws IOException {
Assert.notNull(type, "'type' must not be null");
Assert.notNull(reference, "'reference' must not be null");
String authHeader = authHeader(this.registryAuthentication);
String authHeader = authHeader(this.registryAuthentication, reference);
Assert.state(authHeader == null || reference.getDomain().equals(this.domain),
() -> String.format("%s '%s' must be pulled from the '%s' authenticated registry",
StringUtils.capitalize(type.getDescription()), reference, this.domain));
@@ -300,7 +300,7 @@ public class Builder {
private Image pullImage(ImageReference reference, ImageType imageType) throws IOException {
TotalProgressPullListener listener = new TotalProgressPullListener(
Builder.this.log.pullingImage(reference, this.defaultPlatform, imageType));
String authHeader = authHeader(this.registryAuthentication);
String authHeader = authHeader(this.registryAuthentication, reference);
Image image = Builder.this.docker.image().pull(reference, this.defaultPlatform, listener, authHeader);
Builder.this.log.pulledImage(image, imageType);
if (this.defaultPlatform == null) {

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -63,6 +63,8 @@ final class DockerConfigurationMetadata {
private static final String CONTEXT_FILE_NAME = "meta.json";
private static volatile DockerConfigurationMetadata systemEnvironmentConfigurationMetadata;
private final String configLocation;
private final DockerConfig config;
@@ -88,11 +90,24 @@ final class DockerConfigurationMetadata {
}
static DockerConfigurationMetadata from(Environment environment) {
String configLocation = (environment.get(DOCKER_CONFIG) != null) ? environment.get(DOCKER_CONFIG)
: Path.of(System.getProperty("user.home"), CONFIG_DIR).toString();
DockerConfigurationMetadata dockerConfigurationMetadata = (environment == Environment.SYSTEM)
? DockerConfigurationMetadata.systemEnvironmentConfigurationMetadata : null;
if (dockerConfigurationMetadata != null) {
return dockerConfigurationMetadata;
}
String configLocation = environment.get(DOCKER_CONFIG);
configLocation = (configLocation != null) ? configLocation : getUserHomeConfigLocation();
DockerConfig dockerConfig = createDockerConfig(configLocation);
DockerContext dockerContext = createDockerContext(configLocation, dockerConfig.getCurrentContext());
return new DockerConfigurationMetadata(configLocation, dockerConfig, dockerContext);
dockerConfigurationMetadata = new DockerConfigurationMetadata(configLocation, dockerConfig, dockerContext);
if (environment == Environment.SYSTEM) {
DockerConfigurationMetadata.systemEnvironmentConfigurationMetadata = dockerConfigurationMetadata;
}
return dockerConfigurationMetadata;
}
private static String getUserHomeConfigLocation() {
return Path.of(System.getProperty("user.home"), CONFIG_DIR).toString();
}
private static DockerConfig createDockerConfig(String configLocation) {

View File

@@ -16,12 +16,17 @@
package org.springframework.boot.buildpack.platform.docker.configuration;
import java.util.function.BiConsumer;
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
/**
* Docker registry authentication configuration.
*
* @author Scott Frederick
* @since 2.4.0
*/
@FunctionalInterface
public interface DockerRegistryAuthentication {
/**
@@ -30,6 +35,17 @@ public interface DockerRegistryAuthentication {
*/
DockerRegistryAuthentication EMPTY_USER = DockerRegistryAuthentication.user("", "", "", "");
/**
* Returns the auth header that should be used for docker authentication for the given
* image reference.
* @param imageReference the image reference or {@code null}
* @return the auth header
* @since 3.5.0
*/
default String getAuthHeader(ImageReference imageReference) {
return getAuthHeader();
}
/**
* Returns the auth header that should be used for docker authentication.
* @return the auth header
@@ -63,4 +79,33 @@ public interface DockerRegistryAuthentication {
return new DockerRegistryUserAuthentication(username, password, serverAddress, email);
}
/**
* Factory method that returns a new {@link DockerRegistryAuthentication} instance
* that uses the standard docker JSON config (including support for credential
* helpers) to generate auth headers.
* @param fallback the fallback authentication to use if no suitable config is found
* @return a new {@link DockerRegistryAuthentication} instance
* @since 3.5.0
* @see #configuration(DockerRegistryAuthentication, BiConsumer)
*/
static DockerRegistryAuthentication configuration(DockerRegistryAuthentication fallback) {
return configuration(fallback, (message, ex) -> System.out.println(message));
}
/**
* Factory method that returns a new {@link DockerRegistryAuthentication} instance
* that uses the standard docker JSON config (including support for credential
* helpers) to generate auth headers.
* @param fallback the fallback authentication to use if no suitable config is found
* @param credentialHelperExceptionHandler callback that should handle credential
* helper exceptions
* @return a new {@link DockerRegistryAuthentication} instance
* @since 3.5.0
* @see #configuration(DockerRegistryAuthentication, BiConsumer)
*/
static DockerRegistryAuthentication configuration(DockerRegistryAuthentication fallback,
BiConsumer<String, Exception> credentialHelperExceptionHandler) {
return new DockerRegistryConfigAuthentication(fallback, credentialHelperExceptionHandler);
}
}

View File

@@ -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;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 the original author or authors.
* 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.
@@ -19,7 +19,8 @@ package org.springframework.boot.buildpack.platform.docker.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Docker registry authentication configuration using a token.
* {@link DockerRegistryAuthentication} for
* {@link DockerRegistryAuthentication#user(String, String, String, String)}.
*
* @author Scott Frederick
*/

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 the original author or authors.
* 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.
@@ -19,7 +19,8 @@ package org.springframework.boot.buildpack.platform.docker.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Docker registry authentication configuration using user credentials.
* {@link DockerRegistryAuthentication} for
* {@link DockerRegistryAuthentication#token(String)}.
*
* @author Scott Frederick
*/

View File

@@ -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);
}
}

View File

@@ -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));
}
}
}

View File

@@ -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<>() {
});
}
}

View File

@@ -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

View File

@@ -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