diff --git a/go.mod b/go.mod index 83f7fe8e5..1be89cc49 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,8 @@ require ( github.com/docker/cli v29.8.2+incompatible github.com/docker/cli-docs-tool v0.11.0 github.com/docker/go-units v0.5.0 + github.com/docker/secrets-engine/client v0.1.2 + github.com/docker/secrets-engine/x v0.8.2 github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 github.com/fsnotify/fsevents v0.2.0 github.com/google/go-cmp v0.7.0 @@ -58,6 +60,7 @@ require ( ) require ( + connectrpc.com/connect v1.19.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -119,6 +122,7 @@ require ( go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.56.0 // indirect + golang.org/x/mod v0.41.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.16.0 // indirect diff --git a/go.sum b/go.sum index 7d310bd64..ab2b809c7 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -88,6 +90,10 @@ github.com/docker/go-metrics v0.1.0 h1:r76KPNpstz+IvQKSWpYegSkkyzex0V3A1ZGVx6bhG github.com/docker/go-metrics v0.1.0/go.mod h1:PciI3sONtB051kXALN1JoIlpcu54E1FuPh+4DuqEzyw= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/secrets-engine/client v0.1.2 h1:MFfk2TTo6h25DczTQ2ByQwgg2aVrVnSEis979Xle7KM= +github.com/docker/secrets-engine/client v0.1.2/go.mod h1:5Ocu33awF0dvSOCd9Z21qP0eFEgFn47gJROzas0pghc= +github.com/docker/secrets-engine/x v0.8.2 h1:40pNxy/HjpU9yRRo15s8k8f0Xc7PiPd6ADoJzlksObw= +github.com/docker/secrets-engine/x v0.8.2/go.mod h1:+s6rb56pq1B376jzzkGqZreFJ3mUtkz+kaYlMuj7/Qs= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= diff --git a/internal/oci/resolver.go b/internal/oci/resolver.go index 59b7c1fe1..6348f5126 100644 --- a/internal/oci/resolver.go +++ b/internal/oci/resolver.go @@ -30,23 +30,23 @@ import ( "github.com/containerd/containerd/v2/pkg/labels" "github.com/containerd/errdefs" "github.com/distribution/reference" - "github.com/docker/cli/cli/config/configfile" "github.com/moby/buildkit/util/contentutil" spec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/docker/compose/v5/internal/registry" ) -// 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 +// NewResolver sets up an OCI Resolver that gets registry credentials from +// credentials (the docker/cli config file, or registry.NewDesktopAuthProvider +// layered over it). When transport is non-nil it is used as the HTTP // 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 { +func NewResolver(credentials registry.AuthProvider, 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) + auth, err := credentials.GetAuthConfig(host) if err != nil { return "", "", err } diff --git a/internal/oci/resolver_test.go b/internal/oci/resolver_test.go index 2a1789116..e3538b9ba 100644 --- a/internal/oci/resolver_test.go +++ b/internal/oci/resolver_test.go @@ -25,9 +25,12 @@ import ( "github.com/distribution/reference" "github.com/docker/cli/cli/config/configfile" + clitypes "github.com/docker/cli/cli/config/types" "github.com/opencontainers/go-digest" spec "github.com/opencontainers/image-spec/specs-go/v1" "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/internal/registry" ) // recordingRoundTripper counts RoundTrip invocations on a delegate so tests @@ -105,6 +108,56 @@ func TestNewResolver_AuthorizerUsesProvidedTransport(t *testing.T) { "authorizer token fetch did not go through the supplied transport (bypassed via http.DefaultClient)") } +// TestNewResolver_SendsPasswordGrant guards how credentials reach the token +// endpoint: a username/password pair, which is how a Docker Desktop session +// is provided, must be exchanged with an OAuth password grant. Without a +// username containerd would send the secret as a refresh token instead. +func TestNewResolver_SendsPasswordGrant(t *testing.T) { + type tokenRequest struct { + GrantType, Username, Password string + } + requests := make(chan tokenRequest, 1) + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/token") { + if r.Method == http.MethodPost && r.ParseForm() == nil { + select { + case requests <- tokenRequest{ + GrantType: r.PostForm.Get("grant_type"), + Username: r.PostForm.Get("username"), + Password: r.PostForm.Get("password"), + }: + default: + } + } + _, _ = 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 + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + + host := server.Listener.Addr().String() + credentials := registry.AuthProviderFunc(func(string) (clitypes.AuthConfig, error) { + return clitypes.AuthConfig{Username: "hubuser", Password: "desktop-oauth-token"}, nil + }) + resolver := NewResolver(credentials, &http.Transport{}, host) + _, _, _ = resolver.Resolve(t.Context(), host+"/test/image:latest") + + select { + case got := <-requests: + assert.DeepEqual(t, got, tokenRequest{GrantType: "password", Username: "hubuser", Password: "desktop-oauth-token"}) + default: + t.Fatal("the resolver never requested a token with the provided credentials") + } +} + 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") diff --git a/internal/registry/desktop.go b/internal/registry/desktop.go new file mode 100644 index 000000000..fa1569778 --- /dev/null +++ b/internal/registry/desktop.go @@ -0,0 +1,224 @@ +/* + Copyright 2026 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package registry + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + clitypes "github.com/docker/cli/cli/config/types" + seclient "github.com/docker/secrets-engine/client" + "github.com/docker/secrets-engine/client/dockerhub" + "github.com/docker/secrets-engine/x/api" + "github.com/sirupsen/logrus" +) + +const ( + // desktopLookupTimeout bounds a Docker Hub session lookup against the + // Docker Desktop secrets engine. The engine may hold a request open on + // user interaction and the SDK applies no timeout by default, while some + // callers (containerd's credential callback) have no context to cancel + // with, so the lookup carries its own deadline. + desktopLookupTimeout = 5 * time.Second + // sessionExpirySkew treats a session as expired slightly early, so a + // token is not handed out just before it lapses mid-request. + sessionExpirySkew = 30 * time.Second + // sessionTTL bounds how long a session without an expiry claim is reused + // before it is fetched again. + sessionTTL = time.Minute +) + +var ( + errNoUsername = errors.New("no username for the Docker Hub session") + errSessionExpired = errors.New("the Docker Hub session has expired (not signed in to Docker Desktop?)") +) + +// hubSessions is the subset of the secrets engine Docker Hub accessor +// (dockerhub.ClientAuth) used to read the signed-in account. +type hubSessions interface { + GetDefaultSession(ctx context.Context) (dockerhub.UserSession, error) + GetDefaultProfile(ctx context.Context) (dockerhub.Profile, error) +} + +var _ hubSessions = dockerhub.ClientAuth(nil) + +// sharedDesktopSession backs every provider returned by +// NewDesktopAuthProvider. The session belongs to the user signed in to Docker +// Desktop rather than to a Docker CLI config file, so the compose service, the +// oci:// loader and the dry-run client share it and Docker Desktop is asked +// once per process rather than once by each of them. +var sharedDesktopSession = &desktopSession{ + connect: connectDesktop, + timeout: desktopLookupTimeout, + now: time.Now, +} + +// NewDesktopAuthProvider returns an AuthProvider that resolves Docker Hub +// credentials from the Docker Desktop secrets engine, and falls back to +// fallback (typically the Docker CLI config file and its credential helpers) +// for every other registry, or when Desktop is not running, nobody is signed +// in, or the session cannot be used. +// +// The Desktop OAuth access token is returned as the password of the signed-in +// account, so it travels like any username/password credential: containerd +// exchanges it with an OAuth password grant, and the engine receives it in +// X-Registry-Auth. It is deliberately not returned as an IdentityToken, which +// containerd treats as a refresh token. +// +// The engine is contacted lazily, on the first Docker Hub lookup. +func NewDesktopAuthProvider(fallback AuthProvider) AuthProvider { + return &desktopAuthProvider{fallback: fallback, session: sharedDesktopSession} +} + +func connectDesktop() (hubSessions, error) { + c, err := seclient.New( + seclient.WithSocketPath(api.DesktopSocketPath()), + seclient.WithTimeout(desktopLookupTimeout), + ) + if err != nil { + return nil, err + } + return c.HubAuth(), nil +} + +// desktopAuthProvider resolves Docker Hub credentials from the Docker Desktop +// session, and every other registry, or Docker Hub whenever Desktop has no +// usable session, from the fallback provider. +type desktopAuthProvider struct { + fallback AuthProvider + session *desktopSession +} + +// GetAuthConfig implements AuthProvider. +func (p *desktopAuthProvider) GetAuthConfig(registryHostname string) (clitypes.AuthConfig, error) { + if GetAuthConfigKey(registryHostname) == IndexServer { + if auth, ok := p.session.auth(); ok { + return auth, nil + } + } + return p.fallback.GetAuthConfig(registryHostname) +} + +// desktopSession reads and caches the Docker Hub session of the account +// signed in to Docker Desktop. +type desktopSession struct { + connect func() (hubSessions, error) + timeout time.Duration + now func() time.Time + + // mu serializes lookups, so concurrent pulls share a single round-trip + // to the engine. + mu sync.Mutex + hub hubSessions + // unavailable records that Desktop could not provide a usable session. + // Later lookups go straight to the fallback instead of asking (and + // possibly waiting out the timeout) again for every image. + unavailable bool + cached *clitypes.AuthConfig + validUntil time.Time +} + +// auth returns the Docker Hub credentials of the Desktop session, and whether +// there is a usable one. +func (s *desktopSession) auth() (clitypes.AuthConfig, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.unavailable { + return clitypes.AuthConfig{}, false + } + if s.cached != nil && s.now().Before(s.validUntil) { + return *s.cached, true + } + auth, validUntil, err := s.fetch() + if err != nil { + s.unavailable = true + s.cached = nil + logDesktopFallback(err) + return clitypes.AuthConfig{}, false + } + logrus.Debugf("using the Docker Hub session of %q from Docker Desktop", auth.Username) + s.cached, s.validUntil = &auth, validUntil + return auth, true +} + +// fetch reads the default Desktop session and converts it to credentials, +// along with the time until which they may be reused. +func (s *desktopSession) fetch() (clitypes.AuthConfig, time.Time, error) { + if s.hub == nil { + hub, err := s.connect() + if err != nil { + return clitypes.AuthConfig{}, time.Time{}, err + } + s.hub = hub + } + + ctx, cancel := context.WithTimeout(context.Background(), s.timeout) + defer cancel() + + session, err := s.hub.GetDefaultSession(ctx) + if err != nil { + return clitypes.AuthConfig{}, time.Time{}, err + } + + // containerd reads an empty username as "the secret is a refresh token" + // and switches to a refresh_token grant, so the token is only usable + // together with the account name. + username := session.Claims.Username + if username == "" { + profile, err := s.hub.GetDefaultProfile(ctx) + if err != nil { + return clitypes.AuthConfig{}, time.Time{}, fmt.Errorf("resolving the Docker Hub username: %w", err) + } + username = profile.Username + } + if username == "" { + return clitypes.AuthConfig{}, time.Time{}, errNoUsername + } + + now := s.now() + validUntil := now.Add(sessionTTL) + if exp := session.Claims.ExpiresAt; exp != nil { + validUntil = exp.Add(-sessionExpirySkew) + if !now.Before(validUntil) { + return clitypes.AuthConfig{}, time.Time{}, errSessionExpired + } + } + + return clitypes.AuthConfig{ + Username: username, + Password: session.AccessToken, + ServerAddress: IndexServer, + }, validUntil, nil +} + +// logDesktopFallback reports why Docker Hub credentials come from the +// fallback. Desktop not running and nobody signed in are routine and only +// logged at debug level. That includes an expired session: Desktop keeps the +// stored access token fresh while the user is signed in, so an expired one is +// left over from an earlier sign-in. Anything else is surprising enough to +// warn about. +func logDesktopFallback(err error) { + if errors.Is(err, seclient.ErrSecretsEngineNotAvailable) || errors.Is(err, dockerhub.ErrNoSession) || errors.Is(err, errSessionExpired) { + logrus.Debugf("no Docker Hub session available from Docker Desktop, using Docker CLI credentials: %v", err) + return + } + logrus.Warnf("Could not use the Docker Hub session from Docker Desktop, using Docker CLI credentials instead: %v", err) +} diff --git a/internal/registry/desktop_test.go b/internal/registry/desktop_test.go new file mode 100644 index 000000000..f8b7e0970 --- /dev/null +++ b/internal/registry/desktop_test.go @@ -0,0 +1,336 @@ +/* + Copyright 2026 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package registry + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/distribution/reference" + clitypes "github.com/docker/cli/cli/config/types" + seclient "github.com/docker/secrets-engine/client" + "github.com/docker/secrets-engine/client/dockerhub" + "github.com/moby/moby/api/pkg/authconfig" + "gotest.tools/v3/assert" +) + +var testNow = time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) + +// fakeHub stands in for the Docker Desktop secrets engine and counts session +// round-trips. +type fakeHub struct { + session dockerhub.UserSession + sessionErr error + profile dockerhub.Profile + profileErr error + sessionCalls atomic.Int32 +} + +func (f *fakeHub) GetDefaultSession(context.Context) (dockerhub.UserSession, error) { + f.sessionCalls.Add(1) + return f.session, f.sessionErr +} + +func (f *fakeHub) GetDefaultProfile(context.Context) (dockerhub.Profile, error) { + return f.profile, f.profileErr +} + +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func (c *fakeClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = c.t.Add(d) +} + +// recordingFallback stands in for the Docker CLI config file. +type recordingFallback struct { + mu sync.Mutex + hosts []string +} + +func (f *recordingFallback) GetAuthConfig(registryHostname string) (clitypes.AuthConfig, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.hosts = append(f.hosts, registryHostname) + return cliCredentials(registryHostname), nil +} + +func cliCredentials(registryHostname string) clitypes.AuthConfig { + return clitypes.AuthConfig{Username: "cli-user", Password: "cli-password", ServerAddress: registryHostname} +} + +func desktopCredentials() clitypes.AuthConfig { + return clitypes.AuthConfig{Username: "hubuser", Password: "desktop-oauth-token", ServerAddress: IndexServer} +} + +func hubSession(username string, expiresAt time.Time) dockerhub.UserSession { + s := dockerhub.UserSession{AccessToken: "desktop-oauth-token"} + s.Claims.Username = username + if !expiresAt.IsZero() { + s.Claims.ExpiresAt = &dockerhub.NumericDate{Time: expiresAt} + } + return s +} + +type testProvider struct { + *desktopAuthProvider + fallback *recordingFallback + clock *fakeClock + connects atomic.Int32 +} + +func newTestProvider(hub hubSessions, connectErr error) *testProvider { + tp := &testProvider{ + fallback: &recordingFallback{}, + clock: &fakeClock{t: testNow}, + } + tp.desktopAuthProvider = &desktopAuthProvider{ + fallback: tp.fallback, + session: &desktopSession{ + connect: func() (hubSessions, error) { + tp.connects.Add(1) + if connectErr != nil { + return nil, connectErr + } + return hub, nil + }, + timeout: time.Second, + now: tp.clock.now, + }, + } + return tp +} + +func TestNewDesktopAuthProvider_SharesOneSession(t *testing.T) { + a, ok := NewDesktopAuthProvider(&recordingFallback{}).(*desktopAuthProvider) + assert.Assert(t, ok) + b, ok := NewDesktopAuthProvider(&recordingFallback{}).(*desktopAuthProvider) + assert.Assert(t, ok) + assert.Assert(t, a.session == b.session, "providers must share the process-wide Docker Desktop session") +} + +func TestDesktopAuthProvider_ProvidersOnOneSessionFetchOnce(t *testing.T) { + hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))} + p := newTestProvider(hub, nil) + other := &desktopAuthProvider{fallback: &recordingFallback{}, session: p.session} + + for _, provider := range []AuthProvider{p, other} { + got, err := provider.GetAuthConfig("docker.io") + assert.NilError(t, err) + assert.DeepEqual(t, got, desktopCredentials()) + } + assert.Equal(t, hub.sessionCalls.Load(), int32(1)) +} + +func TestDesktopAuthProvider_DockerHubUsesDesktopSession(t *testing.T) { + for _, host := range []string{"docker.io", "index.docker.io", "registry-1.docker.io", IndexServer} { + t.Run(host, func(t *testing.T) { + hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))} + p := newTestProvider(hub, nil) + + got, err := p.GetAuthConfig(host) + assert.NilError(t, err) + assert.DeepEqual(t, got, desktopCredentials()) + assert.Equal(t, got.IdentityToken, "", "an IdentityToken is sent as a refresh token") + assert.Equal(t, len(p.fallback.hosts), 0) + }) + } +} + +func TestDesktopAuthProvider_OtherRegistriesUseFallback(t *testing.T) { + for _, host := range []string{"ghcr.io", "localhost:5000", "registry.example.com"} { + t.Run(host, func(t *testing.T) { + hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))} + p := newTestProvider(hub, nil) + + got, err := p.GetAuthConfig(host) + assert.NilError(t, err) + assert.DeepEqual(t, got, cliCredentials(host)) + assert.Equal(t, p.connects.Load(), int32(0), "Docker Desktop must only be contacted for Docker Hub") + }) + } +} + +func TestDesktopAuthProvider_UsernameFromProfile(t *testing.T) { + hub := &fakeHub{ + session: hubSession("", testNow.Add(time.Hour)), + profile: dockerhub.Profile{Username: "hubuser"}, + } + p := newTestProvider(hub, nil) + + got, err := p.GetAuthConfig("docker.io") + assert.NilError(t, err) + assert.DeepEqual(t, got, desktopCredentials()) +} + +func TestDesktopAuthProvider_FallsBackWithoutUsableSession(t *testing.T) { + future := testNow.Add(time.Hour) + tests := []struct { + name string + hub *fakeHub + }{ + { + name: "secrets engine not running", + hub: &fakeHub{sessionErr: fmt.Errorf("%w: dial unix engine.sock: connect: no such file or directory", seclient.ErrSecretsEngineNotAvailable)}, + }, + { + name: "nobody signed in", + hub: &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile}, + }, + { + name: "access denied", + hub: &fakeHub{sessionErr: seclient.ErrAccessDenied}, + }, + { + name: "no username", + hub: &fakeHub{session: hubSession("", future)}, + }, + { + name: "profile lookup fails", + hub: &fakeHub{session: hubSession("", future), profileErr: errors.New("boom")}, + }, + { + name: "expired", + hub: &fakeHub{session: hubSession("hubuser", testNow.Add(-time.Minute))}, + }, + { + name: "expires within the skew", + hub: &fakeHub{session: hubSession("hubuser", testNow.Add(sessionExpirySkew/2))}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := newTestProvider(tc.hub, nil) + + for range 2 { + got, err := p.GetAuthConfig("docker.io") + assert.NilError(t, err) + assert.DeepEqual(t, got, cliCredentials("docker.io")) + } + assert.Equal(t, tc.hub.sessionCalls.Load(), int32(1), "an unusable session must not be asked for again") + }) + } +} + +func TestDesktopAuthProvider_FallsBackWhenClientCannotBeCreated(t *testing.T) { + p := newTestProvider(nil, errors.New("no socket path")) + + for range 2 { + got, err := p.GetAuthConfig("docker.io") + assert.NilError(t, err) + assert.DeepEqual(t, got, cliCredentials("docker.io")) + } + assert.Equal(t, p.connects.Load(), int32(1)) +} + +func TestDesktopAuthProvider_ReusesSessionUntilExpiry(t *testing.T) { + expiresAt := testNow.Add(10 * time.Minute) + hub := &fakeHub{session: hubSession("hubuser", expiresAt)} + p := newTestProvider(hub, nil) + + _, err := p.GetAuthConfig("docker.io") + assert.NilError(t, err) + p.clock.advance(5 * time.Minute) + _, err = p.GetAuthConfig("docker.io") + assert.NilError(t, err) + assert.Equal(t, hub.sessionCalls.Load(), int32(1)) + + // Desktop has refreshed the token by the time the cached one is about to + // expire. + hub.session = hubSession("hubuser", expiresAt.Add(time.Hour)) + hub.session.AccessToken = "refreshed-token" + p.clock.advance(5*time.Minute - sessionExpirySkew) + + got, err := p.GetAuthConfig("docker.io") + assert.NilError(t, err) + assert.Equal(t, got.Password, "refreshed-token") + assert.Equal(t, hub.sessionCalls.Load(), int32(2)) +} + +func TestDesktopAuthProvider_RefetchesSessionWithoutExpiry(t *testing.T) { + hub := &fakeHub{session: hubSession("hubuser", time.Time{})} + p := newTestProvider(hub, nil) + + _, err := p.GetAuthConfig("docker.io") + assert.NilError(t, err) + p.clock.advance(sessionTTL - time.Second) + _, err = p.GetAuthConfig("docker.io") + assert.NilError(t, err) + assert.Equal(t, hub.sessionCalls.Load(), int32(1)) + + p.clock.advance(time.Second) + _, err = p.GetAuthConfig("docker.io") + assert.NilError(t, err) + assert.Equal(t, hub.sessionCalls.Load(), int32(2)) +} + +func TestDesktopAuthProvider_ConcurrentLookupsShareOneFetch(t *testing.T) { + hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))} + p := newTestProvider(hub, nil) + + const lookups = 20 + results := make([]clitypes.AuthConfig, lookups) + var wg sync.WaitGroup + for i := range lookups { + wg.Go(func() { + auth, err := p.GetAuthConfig("docker.io") + assert.Check(t, err) + results[i] = auth + }) + } + wg.Wait() + + for _, auth := range results { + assert.DeepEqual(t, auth, desktopCredentials()) + } + assert.Equal(t, hub.sessionCalls.Load(), int32(1)) + assert.Equal(t, p.connects.Load(), int32(1)) +} + +// TestEncodedAuth_DesktopSession guards what the engine receives in +// X-Registry-Auth for a Docker Hub image when Desktop has a session. +func TestEncodedAuth_DesktopSession(t *testing.T) { + hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))} + p := newTestProvider(hub, nil) + ref, err := reference.ParseNormalizedNamed("alpine") + assert.NilError(t, err) + + encoded, err := EncodedAuth(ref, p) + assert.NilError(t, err) + + decoded, err := authconfig.Decode(encoded) + assert.NilError(t, err) + assert.Equal(t, decoded.Username, "hubuser") + assert.Equal(t, decoded.Password, "desktop-oauth-token") + assert.Equal(t, decoded.ServerAddress, IndexServer) + assert.Equal(t, decoded.IdentityToken, "") +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index ea8103c36..121dbe79a 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -56,11 +56,19 @@ func GetAuthConfigKey(indexName string) string { } // AuthProvider provides registry credentials for a registry hostname, as -// implemented by the docker CLI's configfile. +// implemented by the docker CLI's configfile and by NewDesktopAuthProvider. type AuthProvider interface { GetAuthConfig(registryHostname string) (clitypes.AuthConfig, error) } +// AuthProviderFunc adapts a function to the AuthProvider interface. +type AuthProviderFunc func(registryHostname string) (clitypes.AuthConfig, error) + +// GetAuthConfig calls f(registryHostname). +func (f AuthProviderFunc) GetAuthConfig(registryHostname string) (clitypes.AuthConfig, error) { + return f(registryHostname) +} + // EncodedAuth returns the credentials for the registry hosting the given // image reference, base64-encoded as expected by the Docker API's // X-Registry-Auth header. The configfile normalizes the Docker Hub domain to diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index 999ea1139..d0e43939e 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -28,6 +28,7 @@ import ( "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/config/configfile" + clitypes "github.com/docker/cli/cli/config/types" "github.com/docker/cli/cli/flags" "github.com/docker/cli/cli/streams" "github.com/moby/moby/api/types/container" @@ -37,6 +38,7 @@ import ( "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" + "github.com/docker/compose/v5/internal/registry" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/dryrun" ) @@ -69,6 +71,11 @@ func NewComposeService(dockerCli command.Cli, options ...Option) (api.Compose, e maxConcurrency: -1, dryRun: false, } + // Resolve the config file on each lookup rather than now: options such + // as WithDryRun replace s.dockerCli after this point. + s.auth = registry.NewDesktopAuthProvider(registry.AuthProviderFunc(func(registryHostname string) (clitypes.AuthConfig, error) { + return s.configFile().GetAuthConfig(registryHostname) + })) for _, option := range options { if err := option(s); err != nil { return nil, err @@ -288,6 +295,9 @@ type composeService struct { maxConcurrency int dryRun bool + // auth provides registry credentials, see authProvider. + auth registry.AuthProvider + runtimeAPIVersion runtimeVersionCache } @@ -311,6 +321,17 @@ func (s *composeService) configFile() *configfile.ConfigFile { return s.dockerCli.ConfigFile() } +// authProvider returns the source of credentials for a single registry: +// Docker Desktop's Docker Hub session first, then the Docker CLI config file. +// Services not built by NewComposeService (unit tests) use the config file +// only, so they never reach a real Docker Desktop. +func (s *composeService) authProvider() registry.AuthProvider { + if s.auth != nil { + return s.auth + } + return s.configFile() +} + // getContextInfo returns the context info - either custom override or dockerCli adapter func (s *composeService) getContextInfo() api.ContextInfo { if s.contextInfo != nil { diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go index fbb23da64..b3616030d 100644 --- a/pkg/compose/publish.go +++ b/pkg/compose/publish.go @@ -124,7 +124,7 @@ func (s *composeService) pushComposeArtifact(ctx context.Context, project *types insecureRegistries = append(insecureRegistries, reference.Domain(named)) } - resolver := oci.NewResolver(s.configFile(), desktop.ProxyTransportFor(ctx, s.apiClient()), insecureRegistries...) + resolver := oci.NewResolver(s.authProvider(), desktop.ProxyTransportFor(ctx, s.apiClient()), insecureRegistries...) descriptor, didFallback, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion) if err != nil { @@ -331,7 +331,7 @@ func processFile(ctx context.Context, file string, project *types.Project, extFi } func (s *composeService) generateImageDigestsOverride(ctx context.Context, project *types.Project) ([]byte, error) { - project, err := project.WithImagesResolved(ImageDigestResolver(ctx, s.configFile(), s.apiClient())) + project, err := project.WithImagesResolved(imageDigestResolver(ctx, s.authProvider(), s.apiClient())) if err != nil { return nil, err } @@ -346,7 +346,7 @@ func (s *composeService) generateImageDigestsOverride(ctx context.Context, proje ContainerSpec: types.ContainerSpec{Image: job.Image}, } } - jobsAsServices, err = jobsAsServices.WithImagesResolved(ImageDigestResolver(ctx, s.configFile(), s.apiClient())) + jobsAsServices, err = jobsAsServices.WithImagesResolved(imageDigestResolver(ctx, s.authProvider(), s.apiClient())) if err != nil { return nil, err } diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index 2456cbeb0..dec5dbf6d 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -304,7 +304,7 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser return err } - encodedAuth, err := registry.EncodedAuth(ref, s.configFile()) + encodedAuth, err := registry.EncodedAuth(ref, s.authProvider()) if err != nil { return err } @@ -385,9 +385,18 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser // runnable manifest to compare a running container with a fresh build/pull; // never funnel this resolution through the local content-digest producer, and // never pin a published reference with a per-platform digest. +// +// Docker Hub credentials come from the Docker Desktop session when there is +// one, and from file otherwise. func ImageDigestResolver(ctx context.Context, file *configfile.ConfigFile, apiClient client.APIClient) func(named reference.Named) (digest.Digest, error) { + return imageDigestResolver(ctx, registry.NewDesktopAuthProvider(file), apiClient) +} + +// imageDigestResolver is ImageDigestResolver with the source of registry +// credentials given explicitly. +func imageDigestResolver(ctx context.Context, credentials registry.AuthProvider, apiClient client.APIClient) func(named reference.Named) (digest.Digest, error) { return func(named reference.Named) (digest.Digest, error) { - auth, err := registry.EncodedAuth(named, file) + auth, err := registry.EncodedAuth(named, credentials) if err != nil { return "", err } diff --git a/pkg/compose/pull_test.go b/pkg/compose/pull_test.go index b0f622513..f9a353968 100644 --- a/pkg/compose/pull_test.go +++ b/pkg/compose/pull_test.go @@ -326,7 +326,8 @@ func TestImageDigestResolverUsesDistributionDigest(t *testing.T) { named, err := reference.ParseDockerRef("foo:1") assert.NilError(t, err) - resolved, err := ImageDigestResolver(t.Context(), &configfile.ConfigFile{}, mockAPI)(named) + // The unexported variant keeps the test away from a real Docker Desktop. + resolved, err := imageDigestResolver(t.Context(), &configfile.ConfigFile{}, mockAPI)(named) assert.NilError(t, err) assert.Equal(t, resolved.String(), indexDigest) } diff --git a/pkg/compose/push.go b/pkg/compose/push.go index 6a904cc91..8fb768e64 100644 --- a/pkg/compose/push.go +++ b/pkg/compose/push.go @@ -89,7 +89,7 @@ func (s *composeService) pushServiceImage(ctx context.Context, tag string, quiet return err } - encodedAuth, err := registry.EncodedAuth(ref, s.configFile()) + encodedAuth, err := registry.EncodedAuth(ref, s.authProvider()) if err != nil { return err } diff --git a/pkg/dryrun/dryrunclient.go b/pkg/dryrun/dryrunclient.go index 5066c67c0..eb9e16a3f 100644 --- a/pkg/dryrun/dryrunclient.go +++ b/pkg/dryrun/dryrunclient.go @@ -33,7 +33,6 @@ import ( "github.com/distribution/reference" "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/config/configfile" containerType "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/image" "github.com/moby/moby/api/types/jsonstream" @@ -53,7 +52,7 @@ type DryRunClient struct { containers []containerType.Summary containersSeeded bool execs sync.Map - configFile *configfile.ConfigFile + credentials registry.AuthProvider } type execDetails struct { @@ -72,10 +71,10 @@ func (e fakeStreamResult) Close() error { return e.ReadCloser.Clos // NewDryRunClient produces a DryRunClient func NewDryRunClient(apiClient client.APIClient, cli command.Cli) (*DryRunClient, error) { return &DryRunClient{ - apiClient: apiClient, - containers: []containerType.Summary{}, - execs: sync.Map{}, - configFile: cli.ConfigFile(), + apiClient: apiClient, + containers: []containerType.Summary{}, + execs: sync.Map{}, + credentials: registry.NewDesktopAuthProvider(cli.ConfigFile()), }, nil } @@ -88,7 +87,7 @@ func (d *DryRunClient) resolve(ctx context.Context, ref string) error { if err != nil { return err } - auth, err := registry.EncodedAuth(named, d.configFile) + auth, err := registry.EncodedAuth(named, d.credentials) if err != nil { return err } diff --git a/pkg/remote/oci.go b/pkg/remote/oci.go index 46df64e40..943f0811c 100644 --- a/pkg/remote/oci.go +++ b/pkg/remote/oci.go @@ -37,6 +37,7 @@ import ( "github.com/docker/compose/v5/internal/desktop" "github.com/docker/compose/v5/internal/oci" + "github.com/docker/compose/v5/internal/registry" "github.com/docker/compose/v5/pkg/api" ) @@ -101,6 +102,11 @@ type ociRemoteLoader struct { // detection happens once per loader rather than per Load() call. transportOnce sync.Once transport http.RoundTripper + + // Registry credentials, initialized lazily so the Docker Hub session + // is shared by every Load() call of this loader. + credentialsOnce sync.Once + credentials registry.AuthProvider } func (g *ociRemoteLoader) httpTransport(ctx context.Context) http.RoundTripper { @@ -110,6 +116,13 @@ func (g *ociRemoteLoader) httpTransport(ctx context.Context) http.RoundTripper { return g.transport } +func (g *ociRemoteLoader) authProvider() registry.AuthProvider { + g.credentialsOnce.Do(func() { + g.credentials = registry.NewDesktopAuthProvider(g.dockerCli.ConfigFile()) + }) + return g.credentials +} + func (g *ociRemoteLoader) Accept(path string) bool { return strings.HasPrefix(path, OciPrefix) } @@ -146,7 +159,7 @@ func (g *ociRemoteLoader) pullComposeArtifact(ctx context.Context, path string) return "", err } - resolver := oci.NewResolver(g.dockerCli.ConfigFile(), g.httpTransport(ctx), g.insecureRegistries...) + resolver := oci.NewResolver(g.authProvider(), g.httpTransport(ctx), g.insecureRegistries...) descriptor, content, err := oci.Get(ctx, resolver, ref) if err != nil {