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
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,9 @@ public String getAuthHeader(ContainerRef registry) {
public AuthScheme getAuthScheme() {
return AuthScheme.BASIC;
}

@Override
public String getIdentity(ContainerRef registry) {
return "BASIC:" + username;
}
}
10 changes: 10 additions & 0 deletions src/main/java/land/oras/auth/AuthProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,14 @@ public interface AuthProvider {
* @return The authentication scheme
*/
AuthScheme getAuthScheme();

/**
* Get an opaque identity marker for the credentials this provider resolves for the given registry. This marker is
* used to key {@link TokenCache} entries alongside the requested {@link Scopes}
* @param registry The registry
* @return A non-null identity marker
*/
default String getIdentity(ContainerRef registry) {
return getAuthScheme().name();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,10 @@ public String getAuthHeader(ContainerRef registry) {
public AuthScheme getAuthScheme() {
return AuthScheme.BASIC;
}

@Override
public String getIdentity(ContainerRef registry) {
Credential credential = authStore.get(registry);
return credential == null ? "BASIC:anonymous" : "BASIC:" + credential.username();
}
}
24 changes: 24 additions & 0 deletions src/main/java/land/oras/auth/BearerTokenProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@

package land.oras.auth;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import land.oras.ContainerRef;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
Expand Down Expand Up @@ -84,4 +87,25 @@ public void setToken(HttpClient.TokenResponse token) {
public AuthScheme getAuthScheme() {
return AuthScheme.BEARER;
}

@Override
public String getIdentity(ContainerRef registry) {
if (token == null) {
return "BEARER:none";
}
return "BEARER:" + fingerprint(token.token());
}

private static String fingerprint(String secret) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 6; i++) {
sb.append(String.format("%02x", hash[i]));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
2 changes: 2 additions & 0 deletions src/main/java/land/oras/auth/HttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -694,8 +694,10 @@ private <T> ResponseWrapper<T> executeRequest(
case "DELETE" -> scopes.withAddedRegistryScopes(Scope.DELETE);
default -> throw new OrasException("Unsupported HTTP method: " + method);
};
newScopes = newScopes.withIdentity(authProvider.getIdentity(containerRef));
LOG.debug("Existing scopes: {}", scopes.getScopes());
LOG.debug("New scopes: {}", newScopes.getScopes());
LOG.debug("With identity {}", newScopes.getIdentity());

int maxAttempts = retryEnabled ? this.maxRetries : 1;

Expand Down
78 changes: 58 additions & 20 deletions src/main/java/land/oras/auth/Scopes.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,25 +49,37 @@ public final class Scopes {
private final ContainerRef containerRef;

/**
* Private constructor
* Opaque identity marker for the {@link AuthProvider} that resolved (or will resolve) this scope. Two
* {@link AuthProvider} instances that are not equivalent
*/
private final @Nullable String identity;

/**
* Canonical private constructor
* @param containerRef The container reference
* @param service The service
* @param identity The auth provider identity marker
* @param scopes The scopes
*/
private Scopes(ContainerRef containerRef, @Nullable String service, Scope... scopes) {
this(containerRef, service, ScopeUtils.appendRepositoryScope(List.of(), containerRef, scopes));
private Scopes(
ContainerRef containerRef, @Nullable String service, @Nullable String identity, List<String> scopes) {
this.containerRef = containerRef;
this.service = service;
this.identity = identity;
this.scopes = scopes;
}

/**
* Private constructor
* Return Scopes with new scopes
* @param containerRef The container reference
* @param service The service
* @param identity The auth provider identity marker
* @param scopes The scopes
*/
private Scopes(ContainerRef containerRef, @Nullable String service, List<String> scopes) {
this.containerRef = containerRef;
this.service = service;
this.scopes = scopes;
private static Scopes withRepositoryScopes(
ContainerRef containerRef, @Nullable String service, @Nullable String identity, Scope... scopes) {
return new Scopes(
containerRef, service, identity, ScopeUtils.appendRepositoryScope(List.of(), containerRef, scopes));
}

/**
Expand All @@ -77,7 +89,7 @@ private Scopes(ContainerRef containerRef, @Nullable String service, List<String>
* @return A new Scopes object
*/
public static Scopes of(ContainerRef containerRef, Scope... scopes) {
return new Scopes(containerRef, null, scopes);
return withRepositoryScopes(containerRef, null, null, scopes);
}

/**
Expand All @@ -88,7 +100,7 @@ public static Scopes of(ContainerRef containerRef, Scope... scopes) {
* @return A new Scopes object
*/
public static Scopes of(String service, ContainerRef containerRef, Scope... scopes) {
return new Scopes(containerRef, service, scopes);
return withRepositoryScopes(containerRef, service, null, scopes);
}

/**
Expand All @@ -98,7 +110,7 @@ public static Scopes of(String service, ContainerRef containerRef, Scope... scop
* @return A new Scopes object with no scopes
*/
public static Scopes empty(ContainerRef containerRef, String service) {
return new Scopes(containerRef, service, List.of());
return new Scopes(containerRef, service, null, List.of());
}

/**
Expand All @@ -107,7 +119,7 @@ public static Scopes empty(ContainerRef containerRef, String service) {
* @return A new Scopes object with the given scopes
*/
public Scopes withRegistryScopes(Scope... scopes) {
return new Scopes(containerRef, service, scopes);
return withRepositoryScopes(containerRef, service, identity, scopes);
}

/**
Expand All @@ -117,7 +129,10 @@ public Scopes withRegistryScopes(Scope... scopes) {
*/
public Scopes withAddedRegistryScopes(Scope... newScopes) {
return new Scopes(
containerRef, service, ScopeUtils.appendRepositoryScope(this.scopes, containerRef, newScopes));
containerRef,
service,
identity,
ScopeUtils.appendRepositoryScope(this.scopes, containerRef, newScopes));
}

/**
Expand All @@ -129,7 +144,10 @@ public Scopes withAddedGlobalScopes(String... globalScopes) {
List<String> newScopes = new LinkedList<>(scopes);
newScopes.addAll(List.of(globalScopes));
return new Scopes(
containerRef, service, newScopes.stream().sorted().distinct().toList());
containerRef,
service,
identity,
newScopes.stream().sorted().distinct().toList());
}

/**
Expand All @@ -142,7 +160,7 @@ public Scopes withOnlyGlobalScopes() {
.sorted()
.distinct()
.toList();
return new Scopes(containerRef, service, globalScopes);
return new Scopes(containerRef, service, identity, globalScopes);
}

/**
Expand All @@ -155,7 +173,7 @@ public Scopes withoutGlobalScopes() {
.sorted()
.distinct()
.toList();
return new Scopes(containerRef, service, nonGlobalScopes);
return new Scopes(containerRef, service, identity, nonGlobalScopes);
}

/**
Expand All @@ -166,7 +184,7 @@ public Scopes withoutGlobalScopes() {
public Scopes withNewScope(String scope) {
List<String> newScopes = new LinkedList<>(scopes);
newScopes.add(scope);
return new Scopes(containerRef, service, ScopeUtils.cleanScopes(newScopes));
return new Scopes(containerRef, service, identity, ScopeUtils.cleanScopes(newScopes));
}

/**
Expand All @@ -175,7 +193,16 @@ public Scopes withNewScope(String scope) {
* @return A new Scopes object with the given service
*/
public Scopes withService(@Nullable String service) {
return new Scopes(containerRef, service, scopes);
return new Scopes(containerRef, service, identity, scopes);
}

/**
* Return a new copy of the Scopes object bound to the given auth provider identity
* @param identity The identity
* @return The new Scopes object
*/
public Scopes withIdentity(@Nullable String identity) {
return new Scopes(containerRef, service, identity, scopes);
}

/**
Expand All @@ -186,6 +213,14 @@ public Scopes withService(@Nullable String service) {
return service;
}

/**
* Get the auth provider identity marker bound to this scope, or {@code null} if not bound to any.
* @return The identity marker
*/
public @Nullable String getIdentity() {
return identity;
}

/**
* Get the scopes
* @return The scopes
Expand Down Expand Up @@ -241,21 +276,24 @@ public boolean equals(Object o) {
Scopes scopes1 = (Scopes) o;
return Objects.equals(getScopes(), scopes1.getScopes())
&& Objects.equals(getService(), scopes1.getService())
&& Objects.equals(getIdentity(), scopes1.getIdentity())
&& Objects.equals(
getContainerRef().getRegistry(),
scopes1.getContainerRef().getRegistry());
}

@Override
public int hashCode() {
return Objects.hash(getScopes(), getService(), getContainerRef().getRegistry());
return Objects.hash(
getScopes(), getService(), getIdentity(), getContainerRef().getRegistry());
}

@Override
public String toString() {
return "Scopes{" + "scopes="
+ scopes + ", service='"
+ service + '\'' + ", registry="
+ service + '\'' + ", identity='"
+ identity + '\'' + ", registry="
+ containerRef.getRegistry() + '}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,28 @@ void testGetNullAuthHeader() throws Exception {
void testDefaultLocation() {
new AuthStoreAuthenticationProvider();
}

@Test
void identityShouldFollowTheResolvedCredentialPerRegistry() {
ContainerRef alpine = ContainerRef.parse("%s/%s".formatted(REGISTRY, "alpine"));
ContainerRef busybox = ContainerRef.parse("%s/%s".formatted(REGISTRY, "busybox"));

doReturn(new Credential("alice", "alice-pass")).when(mockAuthStore).get(alpine);
doReturn(new Credential("bob", "bob-pass")).when(mockAuthStore).get(busybox);

AuthStoreAuthenticationProvider authProvider = new AuthStoreAuthenticationProvider(mockAuthStore);

assertEquals("BASIC:alice", authProvider.getIdentity(alpine));
assertEquals("BASIC:bob", authProvider.getIdentity(busybox));
}

@Test
void identityShouldFallBackToAnonymousWhenNoCredentialIsStored() {
doReturn(null).when(mockAuthStore).get(any(ContainerRef.class));

AuthStoreAuthenticationProvider authProvider = new AuthStoreAuthenticationProvider(mockAuthStore);

assertEquals(
"BASIC:anonymous", authProvider.getIdentity(ContainerRef.parse("%s/%s".formatted(REGISTRY, "alpine"))));
}
}
26 changes: 26 additions & 0 deletions src/test/java/land/oras/auth/BearerTokenProviderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@

package land.oras.auth;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

import land.oras.ContainerRef;
Expand All @@ -39,4 +42,27 @@ void shouldHaveNoAuthHeader() {
BearerTokenProvider provider = new BearerTokenProvider();
assertNull(provider.getAuthHeader(containerRef), "No token should be returned");
}

@Test
void identityShouldDependOnTheTokenValueAndNeverLeakIt() {
BearerTokenProvider noToken = new BearerTokenProvider();
assertEquals("BEARER:none", noToken.getIdentity(containerRef));

BearerTokenProvider tokenA = new BearerTokenProvider("secret-token-a");
BearerTokenProvider sameTokenA = new BearerTokenProvider("secret-token-a");
BearerTokenProvider tokenB = new BearerTokenProvider("secret-token-b");

assertNotEquals(noToken.getIdentity(containerRef), tokenA.getIdentity(containerRef));
assertEquals(
tokenA.getIdentity(containerRef),
sameTokenA.getIdentity(containerRef),
"Same token value should yield the same identity");
assertNotEquals(
tokenA.getIdentity(containerRef),
tokenB.getIdentity(containerRef),
"Different token values are different identities");
assertFalse(
tokenA.getIdentity(containerRef).contains("secret-token-a"),
"Identity must never contain the raw token");
}
}
12 changes: 12 additions & 0 deletions src/test/java/land/oras/auth/NoAuthProviderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

package land.oras.auth;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

import land.oras.ContainerRef;
Expand All @@ -35,4 +36,15 @@ void shouldHaveNoAuthHeader() {
NoAuthProvider authProvider = new NoAuthProvider();
assertNull(authProvider.getAuthHeader(ContainerRef.parse("localhost:5000/foo/bar")));
}

@Test
void shouldHaveDistinctIdentityFromCredentialedProviders() {
NoAuthProvider authProvider = new NoAuthProvider();
ContainerRef registry = ContainerRef.parse("localhost:5000/foo/bar");
assertEquals(AuthScheme.NONE.name(), authProvider.getIdentity(registry));
assertEquals(
authProvider.getIdentity(registry),
authProvider.getIdentity(ContainerRef.parse("localhost:5000/other/repo")),
"Anonymous identity should not depend on the target repository");
}
}
Loading
Loading