Skip to content

Commit 4311bd1

Browse files
authored
feat(nv-boot): discover NGC auth via authenticate challenge (#557)
Signed-off-by: along <along@nvidia.com>
1 parent 6ec8f0d commit 4311bd1

10 files changed

Lines changed: 1117 additions & 136 deletions

File tree

‎src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ngc/MockNgcContainerRegistryServer.java‎

Lines changed: 128 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@
1818
package com.nvidia.boot.mock.ngc;
1919

2020
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
21+
import static com.github.tomakehurst.wiremock.client.WireMock.absent;
22+
import static com.github.tomakehurst.wiremock.client.WireMock.any;
2123
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
2224
import static com.github.tomakehurst.wiremock.client.WireMock.get;
25+
import static com.github.tomakehurst.wiremock.client.WireMock.matching;
2326
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
27+
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching;
2428
import static com.nvidia.boot.mock.BootTestConstants.IMAGE_MEDIA_TYPES;
2529
import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_HASH;
2630
import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_NAME;
@@ -30,6 +34,7 @@
3034
import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ORG_NAME;
3135

3236
import com.github.tomakehurst.wiremock.WireMockServer;
37+
import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder;
3338
import java.net.URI;
3439
import lombok.Getter;
3540
import lombok.SneakyThrows;
@@ -38,9 +43,32 @@
3843

3944
public class MockNgcContainerRegistryServer {
4045

46+
/**
47+
* Token endpoint advertised by the challenge. Deliberately not {@code /proxy_auth}: the
48+
* client must discover this path from the challenge rather than assume a well-known one.
49+
*/
50+
public static final String MOCK_TOKEN_ENDPOINT_URL = "/mock-token-endpoint";
51+
public static final String V2_PING_URL = "/v2/";
52+
public static final String MOCK_BEARER_TOKEN = "mockBearerToken";
53+
public static final String MOCK_INVALID_REGISTRY_CRED = "invalid-registry-credential";
54+
private static final String DOCKER_CONTENT_DIGEST_HEADER = "Docker-Content-Digest";
55+
4156
@Getter
4257
private static WireMockServer ngcContainerRegistryMockServer;
43-
private static final String PROXY_AUTH_URL = "/proxy_auth";
58+
private static final String MANIFEST_URL_PATTERN = "/v2/.+/manifests/.+";
59+
/**
60+
* Scope is templated from the request path, so any repository gets the scope a registry
61+
* would advertise for it: path segments are {@code /v2/{org}/{image}/manifests/{reference}}.
62+
*/
63+
private static final String MANIFEST_CHALLENGE =
64+
"Bearer realm=\"%s\",scope=\"repository:"
65+
+ "{{request.path.[1]}}/{{request.path.[2]}}:pull\"";
66+
private static final String CHALLENGE_TOKEN_RESPONSE = """
67+
{
68+
"expires_in": 3600,
69+
"token": "%s"
70+
}
71+
""".formatted(MOCK_BEARER_TOKEN);
4472
private static final String VALIDATE_MANIFEST_URL =
4573
"/v2/" + TEST_VALID_ORG_NAME + "/" + TEST_VALID_CONTAINER_NAME + "/manifests/" +
4674
TEST_VALID_CONTAINER_TAG;
@@ -53,12 +81,6 @@ public class MockNgcContainerRegistryServer {
5381
private static final String VALIDATE_MANIFEST_NOT_EXISTS_URL =
5482
"/v2/" + TEST_VALID_ORG_NAME + "/" + TEST_VALID_CONTAINER_NAME +
5583
"/manifests/" + TEST_VALID_CONTAINER_NOT_EXIST_TAG;
56-
private static final String PROXY_AUTH = """
57-
{
58-
"expires_in": 600,
59-
"token": "mockBearerToken"
60-
}
61-
""";
6284
private static final String VALIDATE_MANIFEST = """
6385
589386975/mega-dev/mega-scheduler-service@sha256:d3f9786af0f21490f55299ac0af2f2da871f927865b042def17c63a3699d8d51
6486
{
@@ -192,46 +214,113 @@ public class MockNgcContainerRegistryServer {
192214
@SneakyThrows
193215
public static void start(String ngcRegistryBaseUrl) {
194216
stop();
195-
ngcContainerRegistryMockServer = new WireMockServer(URI.create(ngcRegistryBaseUrl).getPort());
217+
var port = URI.create(ngcRegistryBaseUrl).getPort();
218+
ngcContainerRegistryMockServer = new WireMockServer(port);
196219
ngcContainerRegistryMockServer.start();
197220

198-
ngcContainerRegistryMockServer.stubFor(get(urlPathEqualTo(PROXY_AUTH_URL))
199-
.willReturn(aResponse().withStatus(200)
200-
.withHeader(
201-
HttpHeaders.CONTENT_TYPE,
202-
MediaType.APPLICATION_JSON_VALUE)
203-
.withBody(PROXY_AUTH)));
204-
ngcContainerRegistryMockServer.stubFor(get(urlPathEqualTo(VALIDATE_MANIFEST_URL))
205-
.withHeader(HttpHeaders.ACCEPT,
206-
equalTo(IMAGE_MEDIA_TYPES))
207-
.willReturn(aResponse().withStatus(200)
208-
.withHeader(
209-
HttpHeaders.CONTENT_TYPE,
210-
MediaType.APPLICATION_JSON_VALUE)
211-
.withBody(
212-
VALIDATE_MANIFEST)));
221+
// The realm must be a URL the client can actually dereference. Downstream suites pass
222+
// registry hostnames like localhost-ngc:<port> (the unique-hostname convention), which
223+
// do not resolve, so the challenge advertises the loopback address the server binds
224+
// instead of echoing the caller's hostname - which also exercises the client following
225+
// the realm rather than assuming the registry host.
226+
registerChallengeDiscoveryStubs("http://localhost:" + port + MOCK_TOKEN_ENDPOINT_URL);
227+
registerTokenEndpointStubs();
228+
229+
registerAuthenticatedManifestStub(VALIDATE_MANIFEST_URL, manifestFoundResponse());
230+
registerAuthenticatedManifestStub(VALIDATE_MANIFEST_URL_WITH_DIGEST,
231+
manifestFoundResponse());
232+
registerAuthenticatedManifestStub(VALIDATE_MANIFEST_PERMISSION_DENIED_URL,
233+
aResponse().withStatus(403));
234+
registerAuthenticatedManifestStub(VALIDATE_MANIFEST_NOT_EXISTS_URL,
235+
aResponse().withStatus(404));
236+
}
237+
238+
private static ResponseDefinitionBuilder manifestFoundResponse() {
239+
return aResponse().withStatus(200)
240+
.withHeader(DOCKER_CONTENT_DIGEST_HEADER, TEST_VALID_CONTAINER_HASH)
241+
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
242+
.withBody(VALIDATE_MANIFEST);
243+
}
244+
245+
/**
246+
* Answers a manifest request that carries a bearer token. Registered for both GET and HEAD:
247+
* the registry client validates with HEAD, while {@link #setResponse} callers still GET.
248+
*/
249+
private static void registerAuthenticatedManifestStub(String url,
250+
ResponseDefinitionBuilder response) {
251+
ngcContainerRegistryMockServer.stubFor(
252+
any(urlPathEqualTo(url))
253+
.withHeader(HttpHeaders.AUTHORIZATION,
254+
equalTo("Bearer " + MOCK_BEARER_TOKEN))
255+
.withHeader(HttpHeaders.ACCEPT, equalTo(IMAGE_MEDIA_TYPES))
256+
.willReturn(response));
257+
}
258+
259+
/**
260+
* Call 1 - discover the authentication challenge. An unauthenticated {@code /v2/} ping
261+
* answers 401 with a Bearer challenge whose scope is empty and which advertises no service,
262+
* matching what NGC returns on {@code /v2/} today; an unauthenticated manifest request
263+
* answers 401 with a repository-scoped challenge. The realm points at
264+
* {@link #MOCK_TOKEN_ENDPOINT_URL} rather than the legacy {@code /proxy_auth} so that a
265+
* client which hardcodes the old path fails here.
266+
*
267+
* <p>Registries challenge any unauthenticated {@code /v2/} request, including one for a
268+
* repository that does not exist, so the manifest stub deliberately matches before
269+
* existence is considered. The response-template transformer derives the challenge scope
270+
* from the requested path, the way a registry does, instead of needing one stub per image;
271+
* it is applied per-stub so no other fixture's body is ever run through templating.
272+
*/
273+
private static void registerChallengeDiscoveryStubs(String realm) {
213274
ngcContainerRegistryMockServer.stubFor(
214-
get(urlPathEqualTo(VALIDATE_MANIFEST_URL_WITH_DIGEST))
215-
.withHeader(HttpHeaders.ACCEPT,
216-
equalTo(IMAGE_MEDIA_TYPES))
275+
get(urlPathEqualTo(V2_PING_URL))
276+
.willReturn(aResponse().withStatus(401)
277+
.withHeader(HttpHeaders.WWW_AUTHENTICATE,
278+
"Bearer realm=\"%s\",scope=\"\""
279+
.formatted(realm))));
280+
281+
ngcContainerRegistryMockServer.stubFor(
282+
any(urlPathMatching(MANIFEST_URL_PATTERN))
283+
.withHeader(HttpHeaders.AUTHORIZATION, absent())
284+
.willReturn(aResponse().withStatus(401)
285+
.withHeader(HttpHeaders.WWW_AUTHENTICATE,
286+
MANIFEST_CHALLENGE.formatted(realm))
287+
.withTransformers("response-template")));
288+
}
289+
290+
/**
291+
* Call 2 - exchange the credential for a token at the advertised realm. Any request
292+
* carrying an {@code Authorization} header receives a token - downstream suites use
293+
* arbitrary credentials, so no single valid secret is pinned; the exported
294+
* {@link #MOCK_INVALID_REGISTRY_CRED} is rejected with 401 via a higher-priority stub.
295+
*
296+
* <p>A request without the header matches no fixture and draws WireMock's 404 default, so
297+
* a client that drops the header still fails loudly. (Real NGC would instead answer 200
298+
* with an anonymous token - a silent false pass a credential check must never rely on.)
299+
*/
300+
private static void registerTokenEndpointStubs() {
301+
ngcContainerRegistryMockServer.stubFor(
302+
get(urlPathEqualTo(MOCK_TOKEN_ENDPOINT_URL))
303+
.withHeader(HttpHeaders.AUTHORIZATION, matching(".+"))
217304
.willReturn(aResponse().withStatus(200)
218-
.withHeader(
219-
HttpHeaders.CONTENT_TYPE,
220-
MediaType.APPLICATION_JSON_VALUE)
221-
.withBody(
222-
VALIDATE_MANIFEST)));
305+
.withHeader(HttpHeaders.CONTENT_TYPE,
306+
MediaType.APPLICATION_JSON_VALUE)
307+
.withBody(CHALLENGE_TOKEN_RESPONSE)));
308+
223309
ngcContainerRegistryMockServer.stubFor(
224-
get(urlPathEqualTo(VALIDATE_MANIFEST_PERMISSION_DENIED_URL))
225-
.withHeader(HttpHeaders.ACCEPT, equalTo(IMAGE_MEDIA_TYPES))
226-
.willReturn(aResponse().withStatus(403)));
227-
ngcContainerRegistryMockServer.stubFor(get(urlPathEqualTo(VALIDATE_MANIFEST_NOT_EXISTS_URL))
228-
.withHeader(HttpHeaders.ACCEPT,
229-
equalTo(IMAGE_MEDIA_TYPES))
230-
.willReturn(aResponse().withStatus(404)));
310+
get(urlPathEqualTo(MOCK_TOKEN_ENDPOINT_URL))
311+
.withHeader(HttpHeaders.AUTHORIZATION,
312+
equalTo("Basic " + MOCK_INVALID_REGISTRY_CRED))
313+
.atPriority(1)
314+
.willReturn(aResponse().withStatus(401)));
231315
}
232316

317+
/**
318+
* Stubs a successful response for a URL, whatever the request method. Manifest paths are
319+
* validated with HEAD but fetched with GET, and callers only mean "this URL succeeds", so
320+
* matching any method keeps them working either way.
321+
*/
233322
public static void setResponse(String url, byte[] body) {
234-
ngcContainerRegistryMockServer.stubFor(get(urlPathEqualTo(url))
323+
ngcContainerRegistryMockServer.stubFor(any(urlPathEqualTo(url))
235324
.willReturn(aResponse().withStatus(200)
236325
.withHeader(
237326
HttpHeaders.CONTENT_TYPE,

‎src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/WebClientUtils.java‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,14 @@ public static WebClient.Builder builder() {
8585

8686
/**
8787
* Creates a WebClient with standard boot-exception status handlers and
88-
* a timeout filter using an injected builder.
88+
* a timeout filter using an injected builder. The builder is cloned before
89+
* any handler or connector is added, so callers may pass one builder to
90+
* several {@code createWebClient} calls without stacking configurations.
8991
*/
9092
public static WebClient createWebClient(WebClient.Builder webClientBuilder,
9193
String baseUrl,
9294
Duration timeout) {
93-
return webClientBuilder
95+
return webClientBuilder.clone()
9496
.baseUrl(baseUrl)
9597
.defaultStatusHandler(HttpStatusCode::is4xxClientError,
9698
WebClientUtils::handle4xxError)
@@ -104,6 +106,9 @@ public static WebClient createWebClient(WebClient.Builder webClientBuilder,
104106
* Creates a WebClient with granular Reactor Netty timeout configuration and
105107
* optional retry on 5xx / IO errors. Each retry attempt gets its own
106108
* per-attempt timeout (exchangeTimeout). 4xx errors are never retried.
109+
* The builder is cloned before any handler or connector is added, so callers
110+
* may pass one builder to several {@code createWebClient} calls without
111+
* stacking configurations.
107112
*
108113
* @param webClientBuilder injected WebClient builder
109114
* @param baseUrl base URL for all requests
@@ -134,7 +139,7 @@ public static WebClient createWebClient(
134139
.doOnConnected(conn -> conn.addHandlerLast(
135140
new WriteTimeoutHandler(writeTimeout.toSeconds(), TimeUnit.SECONDS)));
136141

137-
var builder = webClientBuilder
142+
var builder = webClientBuilder.clone()
138143
.baseUrl(baseUrl)
139144
.clientConnector(new ReactorClientHttpConnector(httpClient))
140145
.defaultStatusHandler(HttpStatusCode::is4xxClientError,

0 commit comments

Comments
 (0)