forked from oras-project/oras-java
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAuthStore.java
More file actions
356 lines (320 loc) · 13.9 KB
/
AuthStore.java
File metadata and controls
356 lines (320 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
/*-
* =LICENSE=
* ORAS Java SDK
* ===
* Copyright (C) 2024 - 2026 ORAS
* ===
* 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
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* =LICENSEEND=
*/
package land.oras.auth;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import land.oras.ContainerRef;
import land.oras.OrasModel;
import land.oras.exception.OrasException;
import land.oras.utils.JsonUtils;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Implements a credentials store using a configuration file
* to keep the credentials in plain-text.
* Reference: <a href="https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties">Docker config</a>
*/
@NullMarked
public class AuthStore {
private static final Logger LOG = LoggerFactory.getLogger(AuthStore.class);
/**
* Credentials helper for all registries
*/
private static final String ALL_REGISTRIES_HELPER = "*";
/**
* The internal config
*/
private final Config config;
/**
* Constructor for FileStore.
*
* @param config configuration instance.
*/
AuthStore(Config config) {
this.config = Objects.requireNonNull(config, "Config cannot be null");
}
/**
* Creates a new FileStore based on the given configuration file path.
*
* @param configPaths Path to the configuration files.
* @return FileStore instance.
*/
public static AuthStore newStore(List<Path> configPaths) {
List<ConfigFile> files = new ArrayList<>();
for (Path configPath : configPaths) {
if (Files.exists(configPath)) {
ConfigFile configFile = JsonUtils.fromJson(configPath, ConfigFile.class);
LOG.debug("Loaded auth config file: {}", configPath);
files.add(configFile);
}
}
return new AuthStore(Config.load(files));
}
/**
* Creates a new FileStore from default location
* @return FileStore instance.
*/
public static AuthStore newStore() {
Path dockerPath = Path.of(System.getProperty("user.home"), ".docker", "config.json");
List<Path> paths = List.of(
dockerPath,
// default podman with fallback on docker config
// https://docs.podman.io/en/stable/markdown/podman-login.1.html#description
System.getenv("XDG_RUNTIME_DIR") != null
? Path.of(System.getenv("XDG_RUNTIME_DIR"), "containers", "auth.json")
: dockerPath);
return newStore(paths);
}
/**
* Retrieves credentials for the given containerRef.
*
* @param containerRef ContainerRef.
* @return Credential object or null if no credential is found.
*/
public @Nullable Credential get(ContainerRef containerRef) throws OrasException {
return config.getCredential(containerRef);
}
/**
* Get the credential helper binary for the given containerRef.
* @param containerRef ContainerRef.
* @return Credential helper binary name or null if not found.
*/
public @Nullable String getCredentialHelperBinary(ContainerRef containerRef) {
String helper = config.credentialHelperStore.get(containerRef.getRegistry());
if (helper == null) {
return null;
}
return "docker-credential-" + helper;
}
/**
* Nested ConfigFile class to represent the configuration file.
* @param auths The auths map.
* @param credHelpers The credential helpers map.
* @param credsStore The credentials store.
*/
@OrasModel
record ConfigFile(
Map<String, Map<String, String>> auths,
@Nullable Map<String, String> credHelpers,
@Nullable String credsStore) {
/**
* Constructs a new {@code ConfigFile} object with the specified auths.
* @param credential The credential.
* @return ConfigFile object.
*/
static ConfigFile fromCredential(Credential credential) {
return new ConfigFile(
Map.of(
"auths",
Map.of(
"auth",
java.util.Base64.getEncoder()
.encodeToString(
(credential.username + ":" + credential.password).getBytes()))),
Map.of(),
null);
}
}
/**
* Nested Config class for configuration management.
*/
static class Config {
/**
* Private constructor to prevent instantiation.
*/
private Config() {}
/**
* Stores the credentials
*/
private final ConcurrentHashMap<String, Credential> credentialStore = new ConcurrentHashMap<>();
/**
* Stores the credential helpers binaries
*/
private final ConcurrentHashMap<String, String> credentialHelperStore = new ConcurrentHashMap<>();
/**
* Loads the configuration from a JSON file at the specified path and populates the credential store.
*
* @param configFiles The config files
* @return A {@code Config} object populated with the credentials from the JSON file.
* @throws OrasException If an error occurs while reading or parsing the JSON file.
*/
public static Config load(List<ConfigFile> configFiles) throws OrasException {
Config config = new Config();
for (ConfigFile configFile : configFiles) {
config.credentialHelperStore.putAll(configFile.credHelpers != null ? configFile.credHelpers : Map.of());
config.credentialHelperStore.putAll(
configFile.credsStore != null
? Map.of(ALL_REGISTRIES_HELPER, configFile.credsStore)
: Map.of());
configFile.auths.forEach((host, value) -> {
String auth = value.get("auth");
if (auth != null) {
String base64Decoded =
new String(java.util.Base64.getDecoder().decode(auth));
String[] parts = base64Decoded.split(":");
if (parts.length != 2) {
throw new OrasException("Invalid credential format");
}
config.credentialStore.put(host, new Credential(parts[0], parts[1]));
}
});
}
return config;
}
/**
* Retrieves the {@code Credential} associated with the specified containerRef.
* Implements hierarchical credential lookup from most-specific to least-specific.
* For example, "my-registry.local/namespace/user/image:latest" is looked up as:
* <ol>
* <li>my-registry.local/namespace/user/image</li>
* <li>my-registry.local/namespace/user</li>
* <li>my-registry.local/namespace</li>
* <li>my-registry.local</li>
* </ol>
*
* @param containerRef The containerRef whose credential is to be retrieved.
* @return The {@code Credential} associated with the containerRef, or {@code null} if no credential is found.
*/
public @Nullable Credential getCredential(ContainerRef containerRef) throws OrasException {
String registry = containerRef.getRegistry();
// Start at the most specific key: registry/namespace/repository (or registry/repository)
String key = registry + "/" + containerRef.getFullRepository();
LOG.debug("Looking for credentials for containerRef starting at key '{}'", key);
// Iterate from most-specific to least-specific, stopping when only the registry remains
while (!key.equals(registry)) {
Credential cred = credentialStore.get(key);
if (cred != null) {
LOG.debug("Found credential for key '{}'", key);
return cred;
}
// Remove the last path segment and continue with the less specific key
key = key.substring(0, key.lastIndexOf('/'));
}
// Check the registry-only key
Credential registryCred = credentialStore.get(key);
if (registryCred != null) {
LOG.debug("Found credential for registry '{}'", key);
return registryCred;
}
// Try credential helper scoped to the registry
String helperSuffix = credentialHelperStore.get(registry);
if (helperSuffix != null) {
try {
LOG.debug("Using credential helper '{}' for registry '{}'", helperSuffix, registry);
return getFromCredentialHelper(helperSuffix, registry);
} catch (OrasException e) {
LOG.warn("Failed to get credential from helper for registry {}: {}", registry, e.getMessage());
}
}
// Finally, try all-registries helper
helperSuffix = credentialHelperStore.get(ALL_REGISTRIES_HELPER);
if (helperSuffix != null) {
try {
LOG.debug("Using all-registries credential helper for registry '{}'", registry);
return getFromCredentialHelper(helperSuffix, registry);
} catch (OrasException e) {
LOG.warn(
"Failed to get credential from all-registries helper for registry {}: {}",
registry,
e.getMessage());
}
}
return null;
}
private static Credential getFromCredentialHelper(String suffix, String hostname) throws OrasException {
LOG.debug("Looking for credential helper 'docker-credential-{}' for hostname '{}'", suffix, hostname);
String binary = "docker-credential-" + suffix;
ProcessBuilder pb = new ProcessBuilder(binary, "get");
try {
Process proc = pb.start();
// Hostname is in stdin
try (OutputStream os = proc.getOutputStream()) {
os.write(hostname.getBytes(StandardCharsets.UTF_8));
os.flush();
}
// Wait
int exit = proc.waitFor();
if (exit != 0) {
String stderr = new String(proc.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
String stdout = new String(proc.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
String message = "Credential helper '%s' exited with code %d and error: '%s' and stdout '%s'."
.formatted(binary, exit, stderr.trim(), stdout.trim());
LOG.warn(message);
throw new OrasException(message);
}
return JsonUtils.fromJson(proc.getInputStream(), CredentialHelperResponse.class)
.asCredential();
} catch (IOException e) {
LOG.warn("Failed to execute credential helper '{}': {}", binary, e.getMessage());
throw new OrasException("Credential helper '" + binary + "' not found or IO error", e);
} catch (InterruptedException e) {
LOG.warn("Credential helper execution interrupted: {}", e.getMessage());
throw new OrasException("Credential helper execution interrupted", e);
}
}
}
/**
* Nested Credential class to represent username and password pairs.
* @param username The username for the credential.
* @param password The password for the credential.
*/
@OrasModel
public record Credential(String username, String password) {
/**
* Constructs a new {@code Credential} object with the specified username and password.
*
* @param username The username for the credential. Must not be {@code null}.
* @param password The password for the credential. Must not be {@code null}.
*/
public Credential(String username, String password) {
this.username = Objects.requireNonNull(username, "Username cannot be null");
this.password = Objects.requireNonNull(password, "Password cannot be null");
}
}
/**
* Credential helper response
* @param serverUrl The server URL
* @param username The username
* @param secret The secret (password or token)
*/
public record CredentialHelperResponse(
@JsonProperty("ServerURL") String serverUrl,
@JsonProperty("Username") String username,
@JsonProperty("Secret") String secret) {
/**
* Convert to Credential
* @return Credential
*/
public Credential asCredential() {
return new Credential(username, secret);
}
}
}