From e87f7b79df7452cd6f5e76d6123754c32e76b2cb Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Wed, 24 Jun 2026 15:37:46 +0200 Subject: [PATCH] fix(oci): route authorizer token fetches through provided transport The transport passed to NewResolver was applied only via docker.WithClient. Pass it via docker.WithAuthClient too so the authorizer's OAuth token fetches use it instead of http.DefaultClient. Signed-off-by: Guillaume Lours --- internal/oci/resolver.go | 35 ++++++++++++++++------------ internal/oci/resolver_test.go | 44 ++++++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/internal/oci/resolver.go b/internal/oci/resolver.go index 317fa23a3..ad2cf3162 100644 --- a/internal/oci/resolver.go +++ b/internal/oci/resolver.go @@ -38,23 +38,28 @@ import ( // NewResolver sets up an OCI Resolver based on docker/cli config to provide // registry credentials. When transport is non-nil it is used as the HTTP -// transport for all registry calls (e.g. to route through Docker Desktop's -// PAC-aware proxy); nil falls back to containerd's default transport. +// transport for both registry calls and the authorizer's token fetches +// (e.g. to route both through Docker Desktop's PAC-aware proxy); nil falls +// back to containerd's default transport. func NewResolver(config *configfile.ConfigFile, transport http.RoundTripper, insecureRegistries ...string) remotes.Resolver { + authOpts := []docker.AuthorizerOpt{ + docker.WithAuthCreds(func(host string) (string, string, error) { + host = registry.GetAuthConfigKey(host) + auth, err := config.GetAuthConfig(host) + if err != nil { + return "", "", err + } + if auth.IdentityToken != "" { + return "", auth.IdentityToken, nil + } + return auth.Username, auth.Password, nil + }), + } + if transport != nil { + authOpts = append(authOpts, docker.WithAuthClient(&http.Client{Transport: transport})) + } opts := []docker.RegistryOpt{ - docker.WithAuthorizer(docker.NewDockerAuthorizer( - docker.WithAuthCreds(func(host string) (string, string, error) { - host = registry.GetAuthConfigKey(host) - auth, err := config.GetAuthConfig(host) - if err != nil { - return "", "", err - } - if auth.IdentityToken != "" { - return "", auth.IdentityToken, nil - } - return auth.Username, auth.Password, nil - }), - )), + docker.WithAuthorizer(docker.NewDockerAuthorizer(authOpts...)), docker.WithPlainHTTP(func(domain string) (bool, error) { // Should be used for testing **only** return slices.Contains(insecureRegistries, domain), nil diff --git a/internal/oci/resolver_test.go b/internal/oci/resolver_test.go index 514132e83..9289a97c9 100644 --- a/internal/oci/resolver_test.go +++ b/internal/oci/resolver_test.go @@ -19,6 +19,7 @@ package oci import ( "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" @@ -27,14 +28,20 @@ import ( ) // recordingRoundTripper counts RoundTrip invocations on a delegate so tests -// can verify a supplied transport is actually used by the resolver. +// can verify a supplied transport is actually used by the resolver. It also +// tracks token-endpoint calls separately so tests can assert the authorizer's +// token fetch goes through the same transport. type recordingRoundTripper struct { - delegate http.RoundTripper - calls atomic.Int32 + delegate http.RoundTripper + calls atomic.Int32 + authCalls atomic.Int32 } func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { r.calls.Add(1) + if strings.HasSuffix(req.URL.Path, "/token") { + r.authCalls.Add(1) + } return r.delegate.RoundTrip(req) } @@ -64,6 +71,37 @@ func TestNewResolver_UsesProvidedTransport(t *testing.T) { "resolver did not invoke the supplied transport — wiring is broken") } +// TestNewResolver_AuthorizerUsesProvidedTransport guards that the docker +// authorizer's token fetch goes through the supplied transport, not +// http.DefaultClient. +func TestNewResolver_AuthorizerUsesProvidedTransport(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/token") { + _, _ = w.Write([]byte(`{"token":"fake","access_token":"fake","expires_in":300}`)) + return + } + if r.Header.Get("Authorization") == "" { + w.Header().Set("Www-Authenticate", `Bearer realm="`+server.URL+`/token",service="test"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + // Authenticated retry — fail fast, we only care the auth dance went + // through the supplied transport. + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + + host := server.Listener.Addr().String() + rec := &recordingRoundTripper{delegate: &http.Transport{}} + + resolver := NewResolver(&configfile.ConfigFile{}, rec, host) + _, _, _ = resolver.Resolve(t.Context(), host+"/test/image:latest") + + assert.Assert(t, rec.authCalls.Load() > 0, + "authorizer token fetch did not go through the supplied transport (bypassed via http.DefaultClient)") +} + func TestNewResolver_NilTransportIsValid(t *testing.T) { resolver := NewResolver(&configfile.ConfigFile{}, nil) assert.Assert(t, resolver != nil, "NewResolver must return a non-nil resolver when transport is nil")