Skip to content

Commit e6fbcce

Browse files
nosanphilwebb
andcommitted
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 <[email protected]> Co-authored-by: Phillip Webb <[email protected]>
1 parent 2a9e30a commit e6fbcce

File tree

13 files changed

+1022
-12
lines changed

13 files changed

+1022
-12
lines changed

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,13 +246,13 @@ private void pushImages(ImageReference name, List<ImageReference> tags) throws I
246246
private void pushImage(ImageReference reference) throws IOException {
247247
Consumer<TotalProgressEvent> progressConsumer = this.log.pushingImage(reference);
248248
TotalProgressPushListener listener = new TotalProgressPushListener(progressConsumer);
249-
String authHeader = authHeader(this.dockerConfiguration.publishRegistryAuthentication());
249+
String authHeader = authHeader(this.dockerConfiguration.publishRegistryAuthentication(), reference);
250250
this.docker.image().push(reference, listener, authHeader);
251251
this.log.pushedImage(reference);
252252
}
253253

254-
private static String authHeader(DockerRegistryAuthentication authentication) {
255-
return (authentication != null) ? authentication.getAuthHeader() : null;
254+
private static String authHeader(DockerRegistryAuthentication authentication, ImageReference reference) {
255+
return (authentication != null) ? authentication.getAuthHeader(reference) : null;
256256
}
257257

258258
/**
@@ -279,7 +279,7 @@ private class ImageFetcher {
279279
Image fetchImage(ImageType type, ImageReference reference) throws IOException {
280280
Assert.notNull(type, "'type' must not be null");
281281
Assert.notNull(reference, "'reference' must not be null");
282-
String authHeader = authHeader(this.registryAuthentication);
282+
String authHeader = authHeader(this.registryAuthentication, reference);
283283
Assert.state(authHeader == null || reference.getDomain().equals(this.domain),
284284
() -> String.format("%s '%s' must be pulled from the '%s' authenticated registry",
285285
StringUtils.capitalize(type.getDescription()), reference, this.domain));
@@ -300,7 +300,7 @@ Image fetchImage(ImageType type, ImageReference reference) throws IOException {
300300
private Image pullImage(ImageReference reference, ImageType imageType) throws IOException {
301301
TotalProgressPullListener listener = new TotalProgressPullListener(
302302
Builder.this.log.pullingImage(reference, this.defaultPlatform, imageType));
303-
String authHeader = authHeader(this.registryAuthentication);
303+
String authHeader = authHeader(this.registryAuthentication, reference);
304304
Image image = Builder.this.docker.image().pull(reference, this.defaultPlatform, listener, authHeader);
305305
Builder.this.log.pulledImage(image, imageType);
306306
if (this.defaultPlatform == null) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.buildpack.platform.docker.configuration;
18+
19+
import java.lang.invoke.MethodHandles;
20+
21+
import com.fasterxml.jackson.databind.JsonNode;
22+
23+
import org.springframework.boot.buildpack.platform.json.MappedObject;
24+
25+
/**
26+
* A class that represents credentials for a server as returned from a
27+
* {@link CredentialHelper}.
28+
*
29+
* @author Dmytro Nosan
30+
*/
31+
class Credential extends MappedObject {
32+
33+
/**
34+
* If the secret being stored is an identity token, the username should be set to
35+
* {@code <token>}.
36+
*/
37+
private static final String TOKEN_USERNAME = "<token>";
38+
39+
private final String username;
40+
41+
private final String secret;
42+
43+
private String serverUrl;
44+
45+
Credential(JsonNode node) {
46+
super(node, MethodHandles.lookup());
47+
this.username = valueAt("/Username", String.class);
48+
this.secret = valueAt("/Secret", String.class);
49+
this.serverUrl = valueAt("/ServerURL", String.class);
50+
}
51+
52+
String getUsername() {
53+
return this.username;
54+
}
55+
56+
String getSecret() {
57+
return this.secret;
58+
}
59+
60+
String getServerUrl() {
61+
return this.serverUrl;
62+
}
63+
64+
boolean isIdentityToken() {
65+
return TOKEN_USERNAME.equals(this.username);
66+
}
67+
68+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.buildpack.platform.docker.configuration;
18+
19+
import java.io.IOException;
20+
import java.io.InputStream;
21+
import java.io.OutputStream;
22+
import java.nio.charset.StandardCharsets;
23+
import java.util.ArrayList;
24+
import java.util.List;
25+
import java.util.Set;
26+
27+
import com.sun.jna.Platform;
28+
29+
import org.springframework.boot.buildpack.platform.json.SharedObjectMapper;
30+
31+
/**
32+
* Invokes a Docker credential helper executable that can be used to get {@link Credential
33+
* credentials}.
34+
*
35+
* @author Dmytro Nosan
36+
* @author Phillip Webb
37+
*/
38+
class CredentialHelper {
39+
40+
private static final String USR_LOCAL_BIN = "/usr/local/bin/";
41+
42+
Set<String> CREDENTIAL_NOT_FOUND_MESSAGES = Set.of("credentials not found in native keychain",
43+
"no credentials server URL", "no credentials username");
44+
45+
private final String executable;
46+
47+
CredentialHelper(String executable) {
48+
this.executable = executable;
49+
}
50+
51+
Credential get(String serverUrl) throws IOException {
52+
ProcessBuilder processBuilder = processBuilder("get");
53+
Process process = start(processBuilder);
54+
try (OutputStream request = process.getOutputStream()) {
55+
request.write(serverUrl.getBytes(StandardCharsets.UTF_8));
56+
}
57+
try {
58+
int exitCode = process.waitFor();
59+
try (InputStream response = process.getInputStream()) {
60+
if (exitCode == 0) {
61+
return new Credential(SharedObjectMapper.get().readTree(response));
62+
}
63+
String errorMessage = new String(response.readAllBytes(), StandardCharsets.UTF_8);
64+
if (!isCredentialsNotFoundError(errorMessage)) {
65+
throw new IOException("%s' exited with code %d: %s".formatted(process, exitCode, errorMessage));
66+
}
67+
return null;
68+
}
69+
}
70+
catch (InterruptedException ex) {
71+
Thread.currentThread().interrupt();
72+
return null;
73+
}
74+
}
75+
76+
private ProcessBuilder processBuilder(String string) {
77+
ProcessBuilder processBuilder = new ProcessBuilder().redirectErrorStream(true);
78+
if (Platform.isWindows()) {
79+
processBuilder.command("cmd", "/c");
80+
}
81+
processBuilder.command(this.executable, string);
82+
return processBuilder;
83+
}
84+
85+
private Process start(ProcessBuilder processBuilder) throws IOException {
86+
try {
87+
return processBuilder.start();
88+
}
89+
catch (IOException ex) {
90+
if (!Platform.isMac()) {
91+
throw ex;
92+
}
93+
List<String> command = new ArrayList<>(processBuilder.command());
94+
command.set(0, USR_LOCAL_BIN + command.get(0));
95+
return processBuilder.command(command).start();
96+
}
97+
}
98+
99+
private boolean isCredentialsNotFoundError(String message) {
100+
return this.CREDENTIAL_NOT_FOUND_MESSAGES.contains(message.trim());
101+
}
102+
103+
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadata.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ final class DockerConfigurationMetadata {
6363

6464
private static final String CONTEXT_FILE_NAME = "meta.json";
6565

66+
private static volatile DockerConfigurationMetadata systemEnvironmentConfigurationMetadata;
67+
6668
private final String configLocation;
6769

6870
private final DockerConfig config;
@@ -88,11 +90,24 @@ DockerContext forContext(String context) {
8890
}
8991

9092
static DockerConfigurationMetadata from(Environment environment) {
91-
String configLocation = (environment.get(DOCKER_CONFIG) != null) ? environment.get(DOCKER_CONFIG)
92-
: Path.of(System.getProperty("user.home"), CONFIG_DIR).toString();
93+
DockerConfigurationMetadata dockerConfigurationMetadata = (environment == Environment.SYSTEM)
94+
? DockerConfigurationMetadata.systemEnvironmentConfigurationMetadata : null;
95+
if (dockerConfigurationMetadata != null) {
96+
return dockerConfigurationMetadata;
97+
}
98+
String configLocation = environment.get(DOCKER_CONFIG);
99+
configLocation = (configLocation != null) ? configLocation : getUserHomeConfigLocation();
93100
DockerConfig dockerConfig = createDockerConfig(configLocation);
94101
DockerContext dockerContext = createDockerContext(configLocation, dockerConfig.getCurrentContext());
95-
return new DockerConfigurationMetadata(configLocation, dockerConfig, dockerContext);
102+
dockerConfigurationMetadata = new DockerConfigurationMetadata(configLocation, dockerConfig, dockerContext);
103+
if (environment == Environment.SYSTEM) {
104+
DockerConfigurationMetadata.systemEnvironmentConfigurationMetadata = dockerConfigurationMetadata;
105+
}
106+
return dockerConfigurationMetadata;
107+
}
108+
109+
private static String getUserHomeConfigLocation() {
110+
return Path.of(System.getProperty("user.home"), CONFIG_DIR).toString();
96111
}
97112

98113
private static DockerConfig createDockerConfig(String configLocation) {

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerRegistryAuthentication.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@
1616

1717
package org.springframework.boot.buildpack.platform.docker.configuration;
1818

19+
import java.util.function.BiConsumer;
20+
21+
import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
22+
1923
/**
2024
* Docker registry authentication configuration.
2125
*
2226
* @author Scott Frederick
2327
* @since 2.4.0
2428
*/
29+
@FunctionalInterface
2530
public interface DockerRegistryAuthentication {
2631

2732
/**
@@ -30,6 +35,17 @@ public interface DockerRegistryAuthentication {
3035
*/
3136
DockerRegistryAuthentication EMPTY_USER = DockerRegistryAuthentication.user("", "", "", "");
3237

38+
/**
39+
* Returns the auth header that should be used for docker authentication for the given
40+
* image reference.
41+
* @param imageReference the image reference or {@code null}
42+
* @return the auth header
43+
* @since 3.5.0
44+
*/
45+
default String getAuthHeader(ImageReference imageReference) {
46+
return getAuthHeader();
47+
}
48+
3349
/**
3450
* Returns the auth header that should be used for docker authentication.
3551
* @return the auth header
@@ -63,4 +79,33 @@ static DockerRegistryAuthentication user(String username, String password, Strin
6379
return new DockerRegistryUserAuthentication(username, password, serverAddress, email);
6480
}
6581

82+
/**
83+
* Factory method that returns a new {@link DockerRegistryAuthentication} instance
84+
* that uses the standard docker JSON config (including support for credential
85+
* helpers) to generate auth headers.
86+
* @param fallback the fallback authentication to use if no suitable config is found
87+
* @return a new {@link DockerRegistryAuthentication} instance
88+
* @since 3.5.0
89+
* @see #configuration(DockerRegistryAuthentication, BiConsumer)
90+
*/
91+
static DockerRegistryAuthentication configuration(DockerRegistryAuthentication fallback) {
92+
return configuration(fallback, (message, ex) -> System.out.println(message));
93+
}
94+
95+
/**
96+
* Factory method that returns a new {@link DockerRegistryAuthentication} instance
97+
* that uses the standard docker JSON config (including support for credential
98+
* helpers) to generate auth headers.
99+
* @param fallback the fallback authentication to use if no suitable config is found
100+
* @param credentialHelperExceptionHandler callback that should handle credential
101+
* helper exceptions
102+
* @return a new {@link DockerRegistryAuthentication} instance
103+
* @since 3.5.0
104+
* @see #configuration(DockerRegistryAuthentication, BiConsumer)
105+
*/
106+
static DockerRegistryAuthentication configuration(DockerRegistryAuthentication fallback,
107+
BiConsumer<String, Exception> credentialHelperExceptionHandler) {
108+
return new DockerRegistryConfigAuthentication(fallback, credentialHelperExceptionHandler);
109+
}
110+
66111
}

0 commit comments

Comments
 (0)