Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions src/main/java/land/oras/auth/AuthStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -209,22 +209,45 @@ public static Config load(List<ConfigFile> configFiles) throws OrasException {

/**
* 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();

LOG.debug("Looking for credentials for registry '{}'", registry);
// Start at the most specific key: registry/namespace/repository (or registry/repository)
String key = registry + "/" + containerRef.getFullRepository();

// Check direct credential first
Credential cred = credentialStore.get(registry);
if (cred != null) {
return cred;
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('/'));
}

// Then, try credential helper
// 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 {
Expand All @@ -234,6 +257,7 @@ public static Config load(List<ConfigFile> configFiles) throws OrasException {
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) {
Expand Down
84 changes: 84 additions & 0 deletions src/test/java/land/oras/auth/AuthStoreTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,90 @@ void testShouldReadCredentialsFromPodManConfig() throws Exception {
});
}

// language=json
public static final String SAMPLE_HIERARCHICAL_CONFIG =
"""
{
"auths": {
"my-registry.local/namespace/user/image": {
"auth": "dXNlcjE6cGFzczE="
},
"my-registry.local/namespace": {
"auth": "dXNlcjM6cGFzczM="
},
"my-registry.local": {
"auth": "dXNlcjI6cGFzczI="
}
}
}
""";

@Test
void testHierarchicalCredentialLookupMostSpecific() throws Exception {
Path configFile = tempDir.resolve("hierarchical-config.json");
Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG);
AuthStore store = AuthStore.newStore(List.of(configFile));

// Most specific key: my-registry.local/namespace/user/image
AuthStore.Credential credential =
store.get(ContainerRef.parse("my-registry.local/namespace/user/image:latest"));
assertNotNull(credential);
assertEquals("user1", credential.username());
assertEquals("pass1", credential.password());
}

@Test
void testHierarchicalCredentialLookupNamespaceOnly() throws Exception {
Path configFile = tempDir.resolve("hierarchical-config.json");
Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG);
AuthStore store = AuthStore.newStore(List.of(configFile));

// Credential stored at namespace level: my-registry.local/namespace
// Image under that namespace but not exact-matched should fall back to namespace credential
AuthStore.Credential credential =
store.get(ContainerRef.parse("my-registry.local/namespace/other-image:latest"));
assertNotNull(credential);
assertEquals("user3", credential.username());
assertEquals("pass3", credential.password());
}

@Test
void testHierarchicalCredentialLookupFallsBackToRegistry() throws Exception {
Path configFile = tempDir.resolve("hierarchical-config.json");
Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG);
AuthStore store = AuthStore.newStore(List.of(configFile));

// Different image under the same registry falls back to registry-level credential
AuthStore.Credential credential = store.get(ContainerRef.parse("my-registry.local/other/repo:latest"));
assertNotNull(credential);
assertEquals("user2", credential.username());
assertEquals("pass2", credential.password());
}

@Test
void testHierarchicalCredentialLookupRegistryOnly() throws Exception {
Path configFile = tempDir.resolve("hierarchical-config.json");
Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG);
AuthStore store = AuthStore.newStore(List.of(configFile));

// Image without namespace falls back to registry-level credential
AuthStore.Credential credential = store.get(ContainerRef.parse("my-registry.local/image:latest"));
assertNotNull(credential);
assertEquals("user2", credential.username());
assertEquals("pass2", credential.password());
}

@Test
void testHierarchicalCredentialLookupNoMatch() throws Exception {
Path configFile = tempDir.resolve("hierarchical-config.json");
Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG);
AuthStore store = AuthStore.newStore(List.of(configFile));

// Unknown registry returns null
AuthStore.Credential credential = store.get(ContainerRef.parse("unknown-registry.local/foo/bar:latest"));
assertNull(credential);
}

@Test
void testWithoutXdgRuntimeDir() throws Exception {
new EnvironmentVariables().remove("XDG_RUNTIME_DIR").execute(() -> {
Expand Down