From fd4dae83515da8e9df733868a0c7a853a799477f Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:02:35 +0200 Subject: [PATCH] feat(registry): use the Docker Desktop Hub session for Docker Hub credentials Docker Hub credentials are now read first from the session of the account signed in to Docker Desktop, through the Docker Desktop secrets engine (github.com/docker/secrets-engine/client), and from the Docker CLI config file and its credential helpers otherwise. Other registries keep using the config file only. The OAuth access token is passed as the password of the signed-in account, never as an IdentityToken, which containerd sends as a refresh token. It flows unchanged to the engine in X-Registry-Auth and through the OCI resolver as an OAuth password grant. Compose falls back to the config file when Desktop is not running, nobody is signed in, the session is expired or about to expire, or the lookup fails (the last logged as a warning). The session is shared process wide, cached until shortly before it expires, and an unusable session is not asked for again. Lookups are bounded by a 5s timeout, as the SDK applies none by default. Used by pull, push, publish, config --resolve-image-digests, oci:// remote resources and dry-run. The classic builder and use_api_socket keep config file credentials, as a short-lived token copied into a container would go stale. Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- go.mod | 4 + go.sum | 6 + internal/oci/resolver.go | 10 +- internal/oci/resolver_test.go | 53 +++++ internal/registry/desktop.go | 224 ++++++++++++++++++++ internal/registry/desktop_test.go | 336 ++++++++++++++++++++++++++++++ internal/registry/registry.go | 10 +- pkg/compose/compose.go | 21 ++ pkg/compose/publish.go | 6 +- pkg/compose/pull.go | 13 +- pkg/compose/pull_test.go | 3 +- pkg/compose/push.go | 2 +- pkg/dryrun/dryrunclient.go | 13 +- pkg/remote/oci.go | 15 +- 14 files changed, 695 insertions(+), 21 deletions(-) create mode 100644 internal/registry/desktop.go create mode 100644 internal/registry/desktop_test.go 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 {