Skip to content

Commit 07ca408

Browse files
committed
Improve Docker registry authentication
This commit includes Docker CLI default authentication, leveraging credential helpers, the credential store, and static credentials. Previously, empty credentials were used by default. With this commit, a DefaultDockerRegistryAuthentication is introduced. It reads the Docker configuration file and, based on its contents, determines the appropriate authentication details for the requested ImageReference Signed-off-by: Dmytro Nosan <dimanosan@gmail.com>
1 parent 424d9b6 commit 07ca408

File tree

24 files changed

+1328
-149
lines changed

24 files changed

+1328
-149
lines changed

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

+22-21
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.springframework.boot.buildpack.platform.docker.TotalProgressPushListener;
2828
import org.springframework.boot.buildpack.platform.docker.UpdateListener;
2929
import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
30+
import org.springframework.boot.buildpack.platform.docker.configuration.DockerRegistryAuthentication;
3031
import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost;
3132
import org.springframework.boot.buildpack.platform.docker.transport.DockerEngineException;
3233
import org.springframework.boot.buildpack.platform.docker.type.Binding;
@@ -102,9 +103,8 @@ public void build(BuildRequest request) throws DockerEngineException, IOExceptio
102103
Assert.notNull(request, "'request' must not be null");
103104
this.log.start(request);
104105
validateBindings(request.getBindings());
105-
String domain = request.getBuilder().getDomain();
106106
PullPolicy pullPolicy = request.getPullPolicy();
107-
ImageFetcher imageFetcher = new ImageFetcher(domain, getBuilderAuthHeader(), pullPolicy,
107+
ImageFetcher imageFetcher = new ImageFetcher(getBuilderRegistryAuthentication(), pullPolicy,
108108
request.getImagePlatform());
109109
Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder());
110110
BuilderMetadata builderMetadata = BuilderMetadata.fromImage(builderImage);
@@ -203,64 +203,61 @@ private void pushImages(ImageReference name, List<ImageReference> tags) throws I
203203
private void pushImage(ImageReference reference) throws IOException {
204204
Consumer<TotalProgressEvent> progressConsumer = this.log.pushingImage(reference);
205205
TotalProgressPushListener listener = new TotalProgressPushListener(progressConsumer);
206-
this.docker.image().push(reference, listener, getPublishAuthHeader());
206+
this.docker.image().push(reference, listener, getPublishAuthHeader(reference));
207207
this.log.pushedImage(reference);
208208
}
209209

210-
private String getBuilderAuthHeader() {
211-
return (this.dockerConfiguration != null && this.dockerConfiguration.getBuilderRegistryAuthentication() != null)
212-
? this.dockerConfiguration.getBuilderRegistryAuthentication().getAuthHeader() : null;
210+
private DockerRegistryAuthentication getBuilderRegistryAuthentication() {
211+
if (this.dockerConfiguration != null) {
212+
return this.dockerConfiguration.getBuilderRegistryAuthentication();
213+
}
214+
return null;
213215
}
214216

215-
private String getPublishAuthHeader() {
217+
private String getPublishAuthHeader(ImageReference imageReference) {
216218
return (this.dockerConfiguration != null && this.dockerConfiguration.getPublishRegistryAuthentication() != null)
217-
? this.dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader() : null;
219+
? this.dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader(imageReference) : null;
218220
}
219221

220222
/**
221223
* Internal utility class used to fetch images.
222224
*/
223225
private class ImageFetcher {
224226

225-
private final String domain;
226-
227-
private final String authHeader;
227+
private final DockerRegistryAuthentication authentication;
228228

229229
private final PullPolicy pullPolicy;
230230

231231
private ImagePlatform defaultPlatform;
232232

233-
ImageFetcher(String domain, String authHeader, PullPolicy pullPolicy, ImagePlatform platform) {
234-
this.domain = domain;
235-
this.authHeader = authHeader;
233+
ImageFetcher(DockerRegistryAuthentication authentication, PullPolicy pullPolicy, ImagePlatform platform) {
234+
this.authentication = authentication;
236235
this.pullPolicy = pullPolicy;
237236
this.defaultPlatform = platform;
238237
}
239238

240239
Image fetchImage(ImageType type, ImageReference reference) throws IOException {
241240
Assert.notNull(type, "'type' must not be null");
242241
Assert.notNull(reference, "'reference' must not be null");
243-
Assert.state(this.authHeader == null || reference.getDomain().equals(this.domain),
244-
() -> String.format("%s '%s' must be pulled from the '%s' authenticated registry",
245-
StringUtils.capitalize(type.getDescription()), reference, this.domain));
242+
String authHeader = getAuthHeader(reference);
246243
if (this.pullPolicy == PullPolicy.ALWAYS) {
247-
return checkPlatformMismatch(pullImage(reference, type), reference);
244+
return checkPlatformMismatch(pullImage(authHeader, reference, type), reference);
248245
}
249246
try {
250247
return checkPlatformMismatch(Builder.this.docker.image().inspect(reference), reference);
251248
}
252249
catch (DockerEngineException ex) {
253250
if (this.pullPolicy == PullPolicy.IF_NOT_PRESENT && ex.getStatusCode() == 404) {
254-
return checkPlatformMismatch(pullImage(reference, type), reference);
251+
return checkPlatformMismatch(pullImage(authHeader, reference, type), reference);
255252
}
256253
throw ex;
257254
}
258255
}
259256

260-
private Image pullImage(ImageReference reference, ImageType imageType) throws IOException {
257+
private Image pullImage(String authHeader, ImageReference reference, ImageType imageType) throws IOException {
261258
TotalProgressPullListener listener = new TotalProgressPullListener(
262259
Builder.this.log.pullingImage(reference, this.defaultPlatform, imageType));
263-
Image image = Builder.this.docker.image().pull(reference, this.defaultPlatform, listener, this.authHeader);
260+
Image image = Builder.this.docker.image().pull(reference, this.defaultPlatform, listener, authHeader);
264261
Builder.this.log.pulledImage(image, imageType);
265262
if (this.defaultPlatform == null) {
266263
this.defaultPlatform = ImagePlatform.from(image);
@@ -278,6 +275,10 @@ private Image checkPlatformMismatch(Image image, ImageReference imageReference)
278275
return image;
279276
}
280277

278+
private String getAuthHeader(ImageReference reference) {
279+
return (this.authentication != null) ? this.authentication.getAuthHeader(reference) : null;
280+
}
281+
281282
}
282283

283284
private static final class PlatformMismatchException extends RuntimeException {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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.
27+
*
28+
* @author Dmytro Nosan
29+
*/
30+
class Credentials extends MappedObject {
31+
32+
/**
33+
* If the secret being stored is an identity token, the username should be set to
34+
* {@code <token>}.
35+
*/
36+
private static final String TOKEN_USERNAME = "<token>";
37+
38+
private final String serverUrl;
39+
40+
private final String username;
41+
42+
private final String secret;
43+
44+
/**
45+
* Create a new {@link Credentials} instance from the given JSON node.
46+
* @param node the JSON node to read from
47+
*/
48+
Credentials(JsonNode node) {
49+
super(node, MethodHandles.lookup());
50+
this.serverUrl = valueAt("/ServerURL", String.class);
51+
this.username = valueAt("/Username", String.class);
52+
this.secret = valueAt("/Secret", String.class);
53+
}
54+
55+
/**
56+
* Checks if the secret being stored is an identity token.
57+
* @return true if the secret is an identity token, false otherwise
58+
*/
59+
boolean isIdentityToken() {
60+
return TOKEN_USERNAME.equals(this.username);
61+
}
62+
63+
/**
64+
* Returns the server URL associated with this credential.
65+
* @return the server URL
66+
*/
67+
String getServerUrl() {
68+
return this.serverUrl;
69+
}
70+
71+
/**
72+
* Returns the username associated with the credential.
73+
* @return the username
74+
*/
75+
String getUsername() {
76+
return this.username;
77+
}
78+
79+
/**
80+
* Returns the secret associated with this credential.
81+
* @return the secret
82+
*/
83+
String getSecret() {
84+
return this.secret;
85+
}
86+
87+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
26+
import com.sun.jna.Platform;
27+
28+
import org.springframework.boot.buildpack.platform.json.SharedObjectMapper;
29+
30+
/**
31+
* Default implementation of the {@link DockerCredentialHelper} that retrieves Docker
32+
* credentials using a specified credential helper.
33+
*
34+
* @author Dmytro Nosan
35+
*/
36+
class DefaultDockerCredentialHelper implements DockerCredentialHelper {
37+
38+
private static final String USR_LOCAL_BIN = "/usr/local/bin/";
39+
40+
private static final String CREDENTIALS_NOT_FOUND = "credentials not found in native keychain";
41+
42+
private static final String CREDENTIALS_URL_MISSING = "no credentials server URL";
43+
44+
private static final String CREDENTIALS_USERNAME_MISSING = "no credentials username";
45+
46+
private final String name;
47+
48+
/**
49+
* Creates a new {@link DefaultDockerCredentialHelper} instance using the specified
50+
* credential helper name.
51+
* @param name the full name of the Docker credential helper, e.g.,
52+
* {@code docker-credential-osxkeychain}, {@code docker-credential-desktop}, etc.
53+
*/
54+
DefaultDockerCredentialHelper(String name) {
55+
this.name = name;
56+
}
57+
58+
@Override
59+
public Credentials get(String serverUrl) throws IOException {
60+
ProcessBuilder processBuilder = new ProcessBuilder().redirectErrorStream(true);
61+
if (Platform.isWindows()) {
62+
processBuilder.command("cmd", "/c");
63+
}
64+
processBuilder.command(this.name, "get");
65+
Process process = startProcess(processBuilder);
66+
try (OutputStream os = process.getOutputStream()) {
67+
os.write(serverUrl.getBytes(StandardCharsets.UTF_8));
68+
}
69+
int exitCode;
70+
try {
71+
exitCode = process.waitFor();
72+
}
73+
catch (InterruptedException ex) {
74+
Thread.currentThread().interrupt();
75+
return null;
76+
}
77+
if (exitCode != 0) {
78+
try (InputStream is = process.getInputStream()) {
79+
String message = new String(is.readAllBytes(), StandardCharsets.UTF_8);
80+
if (isCredentialsNotFoundError(message)) {
81+
return null;
82+
}
83+
throw new IOException("%s' exited with code %d: %s".formatted(process, exitCode, message));
84+
}
85+
}
86+
try (InputStream is = process.getInputStream()) {
87+
return new Credentials(SharedObjectMapper.get().readTree(is));
88+
}
89+
}
90+
91+
private Process startProcess(ProcessBuilder processBuilder) throws IOException {
92+
try {
93+
return processBuilder.start();
94+
}
95+
catch (IOException ex) {
96+
if (!Platform.isMac()) {
97+
throw ex;
98+
}
99+
List<String> command = new ArrayList<>(processBuilder.command());
100+
command.set(0, USR_LOCAL_BIN + command.get(0));
101+
return processBuilder.command(command).start();
102+
}
103+
}
104+
105+
private boolean isCredentialsNotFoundError(String message) {
106+
return switch (message.trim()) {
107+
case CREDENTIALS_NOT_FOUND, CREDENTIALS_URL_MISSING, CREDENTIALS_USERNAME_MISSING -> true;
108+
default -> false;
109+
};
110+
}
111+
112+
}

0 commit comments

Comments
 (0)