Skip to content

Commit f9db4b6

Browse files
authored
registry: implement getHttpAuthHeader in OciRegistryClient (#291)
1 parent d1628a3 commit f9db4b6

File tree

7 files changed

+1050
-9
lines changed

7 files changed

+1050
-9
lines changed

registry/registry-client/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@
3232
<version>5.12.1</version>
3333
<scope>test</scope>
3434
</dependency>
35+
<dependency>
36+
<groupId>org.junit.jupiter</groupId>
37+
<artifactId>junit-jupiter-params</artifactId>
38+
<version>5.12.1</version>
39+
<scope>test</scope>
40+
</dependency>
3541
<dependency>
3642
<groupId>org.mockito</groupId>
3743
<artifactId>mockito-junit-jupiter</artifactId>
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package com.salesforce.multicloudj.registry.driver;
2+
3+
import lombok.Getter;
4+
import org.apache.commons.lang3.StringUtils;
5+
import org.apache.http.HttpHeaders;
6+
import org.apache.http.HttpStatus;
7+
import org.apache.http.client.methods.CloseableHttpResponse;
8+
import org.apache.http.client.methods.HttpGet;
9+
import org.apache.http.impl.client.CloseableHttpClient;
10+
11+
import java.io.IOException;
12+
import java.util.HashMap;
13+
import java.util.Map;
14+
import java.util.regex.Matcher;
15+
import java.util.regex.Pattern;
16+
17+
/**
18+
* Parses HTTP WWW-Authenticate header to determine authentication requirements.
19+
* Supports both Basic and Bearer authentication schemes.
20+
* Also supports anonymous access when no authentication is required.
21+
*/
22+
@Getter
23+
public final class AuthChallenge {
24+
25+
private static final String ANONYMOUS_SCHEME = "Anonymous";
26+
private static final String BEARER_SCHEME = "Bearer";
27+
private static final Pattern SCHEME_PATTERN = Pattern.compile("^(Basic|Bearer)\\s*", Pattern.CASE_INSENSITIVE);
28+
private static final Pattern PARAM_PATTERN = Pattern.compile("(\\w+)=\"([^\"]*)\"");
29+
30+
/** The authentication scheme (Basic, Bearer, or Anonymous). */
31+
private final String scheme;
32+
33+
/** The realm URL for token exchange (Bearer auth). */
34+
private final String realm;
35+
36+
/** The service identifier. */
37+
private final String service;
38+
39+
/** The requested scope. */
40+
private final String scope;
41+
42+
private AuthChallenge(String scheme, String realm, String service, String scope) {
43+
this.scheme = scheme;
44+
this.realm = realm;
45+
this.service = service;
46+
this.scope = scope;
47+
}
48+
49+
/**
50+
* Creates an AuthChallenge representing anonymous access (no auth required).
51+
*
52+
* @return an anonymous AuthChallenge
53+
*/
54+
public static AuthChallenge anonymous() {
55+
return new AuthChallenge(ANONYMOUS_SCHEME, null, null, null);
56+
}
57+
58+
/**
59+
* Discovers authentication requirements by pinging the registry.
60+
* Sends GET /v2/ and parses the WWW-Authenticate header from 401 response.
61+
*
62+
* @param httpClient the HTTP client to use for the request
63+
* @param registryEndpoint the registry base URL
64+
* @return AuthChallenge describing the authentication requirements (including anonymous)
65+
* @throws IOException if the request fails
66+
*/
67+
public static AuthChallenge discover(CloseableHttpClient httpClient, String registryEndpoint) throws IOException {
68+
String url = registryEndpoint + "/v2/";
69+
HttpGet request = new HttpGet(url);
70+
71+
try (CloseableHttpResponse response = httpClient.execute(request)) {
72+
int statusCode = response.getStatusLine().getStatusCode();
73+
74+
if (statusCode == HttpStatus.SC_OK) {
75+
// No authentication required (anonymous access)
76+
return anonymous();
77+
}
78+
79+
if (statusCode == HttpStatus.SC_UNAUTHORIZED) {
80+
// Parse WWW-Authenticate header
81+
if (response.containsHeader(HttpHeaders.WWW_AUTHENTICATE)) {
82+
String authHeader = response.getFirstHeader(HttpHeaders.WWW_AUTHENTICATE).getValue();
83+
return parse(authHeader);
84+
}
85+
throw new IOException("Registry returned 401 without WWW-Authenticate header");
86+
}
87+
throw new IOException("Unexpected response from registry ping: HTTP " + statusCode);
88+
}
89+
}
90+
91+
/**
92+
* Parses a WWW-Authenticate header value.
93+
*
94+
* @param header the WWW-Authenticate header value
95+
* @return parsed AuthChallenge
96+
* @throws IllegalArgumentException if the header cannot be parsed
97+
*/
98+
public static AuthChallenge parse(String header) {
99+
if (StringUtils.isBlank(header)) {
100+
throw new IllegalArgumentException("WWW-Authenticate header is empty");
101+
}
102+
103+
Matcher schemeMatcher = SCHEME_PATTERN.matcher(header);
104+
if (!schemeMatcher.find()) {
105+
throw new UnsupportedOperationException("Unsupported authentication scheme in: " + header);
106+
}
107+
108+
String scheme = schemeMatcher.group(1);
109+
String paramsPart = header.substring(schemeMatcher.end());
110+
111+
Map<String, String> params = new HashMap<>();
112+
Matcher paramMatcher = PARAM_PATTERN.matcher(paramsPart);
113+
while (paramMatcher.find()) {
114+
params.put(paramMatcher.group(1).toLowerCase(), paramMatcher.group(2));
115+
}
116+
117+
return new AuthChallenge(
118+
scheme,
119+
params.get("realm"),
120+
params.get("service"),
121+
params.get("scope")
122+
);
123+
}
124+
125+
/**
126+
* Returns true if this is a Bearer authentication challenge.
127+
*/
128+
public boolean isBearer() {
129+
return BEARER_SCHEME.equalsIgnoreCase(scheme);
130+
}
131+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.salesforce.multicloudj.registry.driver;
2+
3+
import com.google.gson.JsonObject;
4+
import com.google.gson.JsonParser;
5+
import com.google.gson.JsonSyntaxException;
6+
import org.apache.commons.lang3.StringUtils;
7+
import org.apache.http.HttpHeaders;
8+
import org.apache.http.HttpStatus;
9+
import org.apache.http.client.methods.CloseableHttpResponse;
10+
import org.apache.http.client.methods.HttpGet;
11+
import org.apache.http.impl.client.CloseableHttpClient;
12+
import org.apache.http.util.EntityUtils;
13+
14+
import java.io.IOException;
15+
import java.net.URI;
16+
import java.net.URISyntaxException;
17+
import java.nio.charset.StandardCharsets;
18+
19+
import org.apache.http.client.utils.URIBuilder;
20+
21+
/**
22+
* Handles OAuth2 Bearer Token exchange for OCI registries.
23+
* Exchanges identity tokens for registry-scoped bearer tokens.
24+
*/
25+
public class BearerTokenExchange {
26+
27+
private final CloseableHttpClient httpClient;
28+
29+
/**
30+
* Creates a new BearerTokenExchange instance.
31+
*
32+
* @param httpClient the HTTP client to use for requests
33+
*/
34+
public BearerTokenExchange(CloseableHttpClient httpClient) {
35+
this.httpClient = httpClient;
36+
}
37+
38+
/**
39+
* Exchanges an identity token for a registry-scoped bearer token.
40+
*
41+
* @param challenge the authentication challenge from AuthChallenge.discover()
42+
* @param identityToken the identity token (e.g., OAuth2 access token from GCP)
43+
* @param repository the repository to request access for
44+
* @param actions the actions to request (e.g., "pull", "push")
45+
* @return the bearer token for use in Authorization header
46+
* @throws IOException if the token exchange fails
47+
*/
48+
public String getBearerToken(AuthChallenge challenge, String identityToken,
49+
String repository, String... actions) throws IOException {
50+
if (challenge == null || !challenge.isBearer()) {
51+
throw new IllegalArgumentException("Bearer token exchange requires a Bearer challenge");
52+
}
53+
54+
String realm = challenge.getRealm();
55+
if (StringUtils.isBlank(realm)) {
56+
throw new IOException("Bearer challenge missing realm");
57+
}
58+
59+
// Build token request URL using URIBuilder for proper encoding
60+
URI tokenUri = buildTokenUri(realm, challenge, repository, actions);
61+
62+
HttpGet request = new HttpGet(tokenUri);
63+
// Use identity token as Bearer auth for the token endpoint
64+
request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + identityToken);
65+
66+
try (CloseableHttpResponse response = httpClient.execute(request)) {
67+
int statusCode = response.getStatusLine().getStatusCode();
68+
if (statusCode != HttpStatus.SC_OK) {
69+
String errorBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
70+
throw new IOException("Token exchange failed: HTTP " + statusCode + " - " + errorBody);
71+
}
72+
73+
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
74+
75+
JsonObject json;
76+
try {
77+
json = JsonParser.parseString(responseBody).getAsJsonObject();
78+
} catch (JsonSyntaxException e) {
79+
throw new IOException("Invalid JSON response from token endpoint: " + responseBody, e);
80+
}
81+
82+
// Token can be in "token" (Docker Hub, AWS ECR) or "access_token" (GCP Artifact Registry) field
83+
if (json.has("token") && !json.get("token").isJsonNull()) {
84+
return json.get("token").getAsString();
85+
} else if (json.has("access_token") && !json.get("access_token").isJsonNull()) {
86+
return json.get("access_token").getAsString();
87+
}
88+
89+
throw new IOException("Token response missing token field");
90+
}
91+
}
92+
93+
private URI buildTokenUri(String realm, AuthChallenge challenge, String repository, String[] actions)
94+
throws IOException {
95+
try {
96+
URIBuilder uriBuilder = new URIBuilder(realm);
97+
98+
if (challenge.getService() != null) {
99+
uriBuilder.addParameter("service", challenge.getService());
100+
}
101+
102+
// Build scope: repository:<name>:<actions>
103+
if (repository != null && actions.length > 0) {
104+
String scope = "repository:" + repository + ":" + String.join(",", actions);
105+
uriBuilder.addParameter("scope", scope);
106+
} else if (challenge.getScope() != null) {
107+
// Fallback: use scope from the original WWW-Authenticate header
108+
uriBuilder.addParameter("scope", challenge.getScope());
109+
}
110+
111+
return uriBuilder.build();
112+
} catch (URISyntaxException e) {
113+
throw new IOException("Invalid token endpoint URL: " + realm, e);
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)