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 <[email protected]>
This commit is contained in:
Alano Terblanche
2026-10-07 14:24:28 +02:00
committed by Nicolas De loof
parent 524a36d2cf
commit fd4dae8351
14 changed files with 695 additions and 21 deletions
+4
View File
@@ -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
+6
View File
@@ -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=
+5 -5
View File
@@ -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
}
+53
View File
@@ -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")
+224
View File
@@ -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)
}
+336
View File
@@ -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, "")
}
+9 -1
View File
@@ -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
+21
View File
@@ -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 {
+3 -3
View File
@@ -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
}
+11 -2
View File
@@ -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
}
+2 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+6 -7
View File
@@ -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
}
+14 -1
View File
@@ -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 {