feat(oci): container request builder and token-exchange flow

Step 12 of the S16 container remote plan: the container upstream auth handshake, the remote.RequestBuilder over it, and the VirtualRepositoriesConfig to remote.Config mapping the handshake wiring needs.

The stated merge order was satisfied: !1455 (merged) merged on 2026-08-12, so the four S16 statements this code deliberately disagreed with are corrected on main — the token-exchange body cap, the half-set credential branch, the request-builder hoistability row, and the challenge-discovery verdict. Reviewing this against the pre-!1455 spec text would have surfaced all four as defects; against main today it does not. The plan's Step 12 Depends on line records that order and the window it closed.

What this adds

UpstreamAuthenticator owns the three verdict paths off container_remote_repositories.auth_status. A memoized none goes straight to an unauthenticated call, bearer reaches the token cache and exchanges against auth_url on a miss, and unknown runs the unauthenticated discovery HEAD and persists whichever verdict the response justifies. Both discovery writes are conditional on the url the request observed.

The two non-applying outcomes are handled differently on purpose. A no-token verdict that did not apply still lets this request read unauthenticated, since no credential is involved either way. A bearer verdict that did not apply fails the request: exchanging there would read the credentials at exchange time, possibly the pair supplied for the new url, and send them to the old host's token endpoint, which is the leak the conditional write exists to prevent.

Only a 2xx, a 3xx, or a 401 carrying no usable challenge establishes the none verdict. A 5xx, a 429, or a 4xx other than 401 fails the acquisition and writes nothing. That asymmetry is load-bearing rather than fussy: auth_status = none is cleared only by a token-endpoint 404, and reaching that needs an exchange the none verdict never starts, so a verdict memoized from one transient 503 would send every later pull out unauthenticated until an operator changed the row's url. This is the fourth statement !1455 (merged) amends.

Reauthorize forces RemoteTokenMiss on what it returns rather than reporting the cache's own outcome. A concurrent request can store a fresh entry between the evict and the lookup that follows, and a caller told it hit the cache would be licensed to retry again, which is the unbounded exchange traffic the single-retry rule exists to stop.

readTokenExchange refuses a token carrying a byte an Authorization value cannot hold, before the cache sees it, alongside its refusals for a body carrying no token and a token past maxAcceptedToken. It is the one bad token that never self-heals: net/http rejects the request inside the transport, so the upstream never answers the 401 that Reauthorize evicts on, and a stored entry would serve transport failures for its whole TTL. The refused bytes stay out of the error, for the same reason the JSON decoder's cause is dropped.

The unreadable-challenge case and a no-token write that did not apply are both logged, so neither is indistinguishable from healthy operation before the metrics step lands. Challenge discovery itself is not coalesced: only the token exchange is single-flighted, so a cold burst costs one discovery HEAD and one conditional write per concurrent request. The plan records that gap, and Authorize's doc says "in steady state" rather than claiming one HEAD per remote unconditionally. S16 described one leader running discovery and exchange together; !1522 (merged) narrows that paragraph to the token exchange and tracks closing the gap in #612. It is docs-only and need not merge first.

UpstreamRequestBuilder inverts Maven's credential handling per the plan's Template entry. It resolves its Authorization inside BuildRequest on every call rather than capturing a pair at construction.

config.RemoteConfigFrom is a behavior-preserving relocation of remoteConfigFrom and int32sToInts out of cmd/artifact-registry/wire_remote.go. The body is unchanged.

Acceptance criteria

Criteria are the plan's ownership table assignments for Step 12, numbered within their own spec group.

Group # Criterion Test
Auth-challenge 1 No usable challenge needs no token TestUpstreamAuthenticator_NoUsableChallengeNeedsNoToken
Auth-challenge 2 Discovery memoized for an anonymous upstream TestUpstreamAuthenticator_DiscoveryIsMemoizedForAnAnonymousUpstream
Auth-challenge 3 Challenge discovery persists auth_url TestUpstreamAuthenticator_ChallengeDiscoveryPersistsAuthURL
Auth-challenge 4 A cached auth_url skips discovery TestUpstreamAuthenticator_CachedAuthURLSkipsDiscovery
Auth-challenge 5 Cold cache miss issues exactly three calls TestUpstreamAuthenticator_ColdAuthCacheMissIssuesThreeUpstreamCalls
Auth-challenge 6, 13 token wins over access_token TestUpstreamAuthenticator_TokenFieldSelection
Auth-challenge 7, 12 Basic when both set, no header when neither TestUpstreamAuthenticator_ExchangeCredentials
Auth-challenge 14 scope is always repository:<name>:pull TestUpstreamTokenScope, TestUpstreamAuthenticator_ExchangeCarriesTheComputedScope
Auth-challenge 18 One budget covers discovery and exchange together TestUpstreamAuthenticator_BudgetsDiscoveryAndExchangeTogether
Auth-challenge 19 A re-exchange gets a fresh budget TestUpstreamAuthenticator_ReauthorizeGetsAFreshBudget
Auth-challenge 21 A token-endpoint 404 clears and re-discovers TestUpstreamAuthenticator_RediscoversWhenTheTokenEndpointIs404
Auth-challenge 25 A 2xx/3xx discovery response needs no token TestUpstreamAuthenticator_NoUsableChallengeNeedsNoToken
Auth-challenge 25 A discovery status establishing no verdict writes none TestUpstreamAuthenticator_InconclusiveDiscoveryWritesNoVerdict
Token caching 5, 6 Retry-on-401 exactly once TestUpstreamAuthenticator_ReauthorizeRetriesExactlyOnce, TestUpstreamRequestBuilder_RetryUnauthorizedRebuildsWithAFreshToken
Token caching 7 Handshake and transfer budgets are independent TestUpstreamRequestBuilder_ArtifactTransferOutlivesTheHandshakeBudget
Token caching 9 Concurrent cold requests do not coalesce discovery TestUpstreamAuthenticator_ConcurrentColdRequestsDoNotCoalesceDiscovery
Cache integrity/caps 3 Accept-Encoding: identity on every Fetch TestUpstreamRequestBuilder_RequestsIdentityRepresentation

The upstream fixture counts calls by (method, target) together, not by URL and not by host. The spec calls out both shortcuts as failing a correct implementation. The fixture serves the artifact and the token endpoint from one httptest server, which is deliberately stricter than a real registry: a realm may name a different host than url, and Docker Hub does exactly that, answering auth.docker.io for registry-1.docker.io. Collapsing them is what leaves (method, target) as the only discriminator.

Two clauses beyond the table are worth naming:

  • Body caps. Kind-to-cap selection and the clamp of an override against body_size_cap_blob are already pinned on main by internal/remote/upstreamhttp/bodycap_test.go. What this MR adds is the bound the handshake itself picks: TestUpstreamAuthenticator_ExchangeBodyTakesTheHandshakeCap brackets maxTokenExchangeBody from both sides, with the fixture's metadata cap at 16 MB so a refusal can only be the per-call override binding. This is the clause !1455 (merged) amends the spec for.
  • The UpstreamDoer seam. Covered by S13 Step 11's own remotetest/conformance.go assertion, so nothing here re-asserts it.
  • Error paths. Five tables cover what the handshake does when a collaborator does not answer: all three discovery-write sites, the moved-url asymmetry in both directions, eight unusable auth_url values, four credential faults, and eight token-response bodies that yield no usable token. Each also asserts through assertNoAuthMaterial, so no failure path can leak a token or a credential into a message.

Security

This MR makes a tracked, accepted exposure reachable rather than theoretical: #263 (GA-blocking) is the realm-host allowlist. tokenExchangeURL re-validates the memoized auth_url for absolute-https, no userinfo, a non-empty host, and a re-readable query, but nothing constrains the realm's host to the upstream's own, and exchangeRequest attaches the configured Basic credentials to a request aimed at that upstream-named host. S13's covers stop SSRF, not exfiltration. Nothing here widens the gap, but this is the MR that first sends a credentialed exchange, so #263's priority is worth re-reading against it.

The redaction discipline is asserted rather than assumed: UpstreamAuthError renders no token, credential, or upstream response header; the JSON decoder's cause is dropped because it quotes body bytes; and the token endpoint's own WWW-Authenticate travels as a status rather than as text.

Gated

Accept-Encoding: identity is asserted on every Fetch and is not gated. Its rejection half is Step 13's, per the plan's Gated entry, and depends on an S13-side per-request no-transparent-decode switch.

Size

+4,233 −191 across 11 files, measured against the base at 9494350d, and against the plan's ~870 estimate. 2,687 of the added lines are tests across four files, nearly all of them in remote_auth_test.go; production Go is 1,416 across six files; the remaining 130 are in the plan file. This is the shape the plan documents for the slice: the five landed non-schema steps came in at 2.0 to 3.5 times their estimate with the miss entirely in tests, which are 74 to 83 percent of the Go. Per development-model.md this paragraph is the written justification an MR over 500 lines owes.

internal/config/remotemapping.go is a new file under internal/config/** with no configuration-reference.md change, and none is owed: it is a verbatim relocation that adds no field, default, or constraint, and token_exchange_timeout is already documented there. Saying so here because the pairing guardrail keys off the path rather than the content.

The step is the most novel branching surface in the slice, with 27 auth criteria and no sibling that negotiates upstream auth at all, so there is no reviewed template to lean on. The config mapping is the one extractable piece and is too small to be its own MR. Reviewing remote_auth.go and remote_requestbuilder.go first, then the fixture layer at the top of remote_auth_test.go, is the order that reads fastest.

e2e scenarios

No docs/testing/ change. This step ships no client-reachable route: the handshake and the builder are seams the later Track B steps compose, and docs/testing/e2e/oci.md still lists remote proxy/cache repositories under Out of scope until the capability ships, which the plan's final step moves. The plan's Step 18 owns the hermetic proxy harness that exercises a token-auth upstream end to end.

Verification

go build ./... clean. go test -race -count=1 green over internal/format/oci, internal/config, cmd/artifact-registry, and internal/remote/.... golangci-lint run --max-same-issues=0 --max-issues-per-linter=0 at 0 issues across those packages. Rebased onto main at 9494350d with no conflicts.

Related to #288

Edited by Sylvia Shen

Merge request reports

Loading
Loading