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")