feat(registry): use the Docker Desktop staging session for the Hub staging registry

Docker Desktop keeps the Docker Hub staging session in separate secrets engine realms, read with dockerhub.Staging(). The environment is picked per registry host rather than with a global stage flag: the engine resolves docker.io to the production registry even when Desktop runs in stage mode, so the staging session is only used for registry-1-stage.docker.io and the production session only for the production Hub hosts. A token is never sent to the registry of the other environment.

Each environment has its own process-wide session and cache, and falls back to the Docker CLI config file independently. Staging credentials use https://registry-1-stage.docker.io/ as server address.

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 816c83e401
commit 2f92ff84a5
3 changed files with 173 additions and 37 deletions
+79 -32
View File
@@ -49,8 +49,8 @@ const (
)
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?)")
errNoUsername = errors.New("no username for the session")
errSessionExpired = errors.New("the session has expired (not signed in to Docker Desktop?)")
)
// hubSessions is the subset of the secrets engine Docker Hub accessor
@@ -62,22 +62,40 @@ type hubSessions interface {
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,
// The shared sessions back every provider returned by NewDesktopAuthProvider.
// A 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 them and Docker Desktop is asked once per process
// rather than once by each of them.
//
// Docker Desktop keeps production and staging sessions in separate secrets
// engine realms. Each is only used for the registry of its own environment:
// the engine resolves docker.io to the production registry even when Desktop
// runs in stage mode, so a staging token must never be sent there, nor a
// production token to the staging registry.
var (
sharedHubSession = newDesktopSession("Docker Hub", IndexServer)
sharedStagingHubSession = newDesktopSession("Docker Hub staging", StagingIndexServer, dockerhub.Staging())
)
func newDesktopSession(name, serverAddress string, opts ...dockerhub.Option) *desktopSession {
return &desktopSession{
name: name,
serverAddress: serverAddress,
connect: func() (hubSessions, error) {
return connectDesktop(opts...)
},
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.
// in, or the session cannot be used. The Docker Hub staging registry
// (StagingRegistryHost) is resolved the same way from the staging session.
//
// The Desktop OAuth access token is returned as the password of the signed-in
// account, so it travels like any username/password credential: containerd
@@ -87,10 +105,14 @@ var sharedDesktopSession = &desktopSession{
//
// The engine is contacted lazily, on the first Docker Hub lookup.
func NewDesktopAuthProvider(fallback AuthProvider) AuthProvider {
return &desktopAuthProvider{fallback: fallback, session: sharedDesktopSession}
return &desktopAuthProvider{
fallback: fallback,
hub: sharedHubSession,
stagingHub: sharedStagingHubSession,
}
}
func connectDesktop() (hubSessions, error) {
func connectDesktop(opts ...dockerhub.Option) (hubSessions, error) {
c, err := seclient.New(
seclient.WithSocketPath(api.DesktopSocketPath()),
seclient.WithTimeout(desktopLookupTimeout),
@@ -98,33 +120,58 @@ func connectDesktop() (hubSessions, error) {
if err != nil {
return nil, err
}
return c.HubAuth(), nil
return c.HubAuth(opts...), 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.
// session of the matching environment, and every other registry, or Docker
// Hub whenever Desktop has no usable session, from the fallback provider.
type desktopAuthProvider struct {
fallback AuthProvider
session *desktopSession
fallback AuthProvider
hub *desktopSession
stagingHub *desktopSession
}
// GetAuthConfig implements AuthProvider.
func (p *desktopAuthProvider) GetAuthConfig(registryHostname string) (clitypes.AuthConfig, error) {
if GetAuthConfigKey(registryHostname) == IndexServer {
if auth, ok := p.session.auth(); ok {
if session := p.sessionFor(registryHostname); session != nil {
if auth, ok := 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.
// sessionFor returns the Desktop session for the environment registryHostname
// belongs to, or nil when it is not a Docker Hub registry.
func (p *desktopAuthProvider) sessionFor(registryHostname string) *desktopSession {
switch {
case GetAuthConfigKey(registryHostname) == IndexServer:
return p.hub
case isStagingRegistry(registryHostname):
return p.stagingHub
default:
return nil
}
}
// isStagingRegistry reports whether registryHostname, a host or an index
// server URL, is the Docker Hub staging registry.
func isStagingRegistry(registryHostname string) bool {
host := strings.TrimPrefix(strings.TrimPrefix(registryHostname, "https://"), "http://")
return strings.TrimSuffix(host, "/") == StagingRegistryHost
}
// desktopSession reads and caches the session of the account signed in to
// Docker Desktop, for one Docker Hub environment.
type desktopSession struct {
connect func() (hubSessions, error)
timeout time.Duration
now func() time.Time
// name identifies the environment in logs.
name string
// serverAddress is the credentials key of the environment.
serverAddress string
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.
@@ -154,10 +201,10 @@ func (s *desktopSession) auth() (clitypes.AuthConfig, bool) {
if err != nil {
s.unavailable = true
s.cached = nil
logDesktopFallback(err)
logDesktopFallback(s.name, err)
return clitypes.AuthConfig{}, false
}
logrus.Debugf("using the Docker Hub session of %q from Docker Desktop", auth.Username)
logrus.Debugf("using the %s session of %q from Docker Desktop", s.name, auth.Username)
s.cached, s.validUntil = &auth, validUntil
return auth, true
}
@@ -208,7 +255,7 @@ func (s *desktopSession) fetch() (clitypes.AuthConfig, time.Time, error) {
return clitypes.AuthConfig{
Username: username,
Password: session.AccessToken,
ServerAddress: IndexServer,
ServerAddress: s.serverAddress,
}, validUntil, nil
}
@@ -250,10 +297,10 @@ func jwtExpiry(token string) (time.Time, bool) {
// 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) {
func logDesktopFallback(name string, 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)
logrus.Debugf("no %s session available from Docker Desktop, using Docker CLI credentials: %v", name, err)
return
}
logrus.Warnf("Could not use the Docker Hub session from Docker Desktop, using Docker CLI credentials instead: %v", err)
logrus.Warnf("Could not use the %s session from Docker Desktop, using Docker CLI credentials instead: %v", name, err)
}
+88 -5
View File
@@ -107,16 +107,22 @@ type testProvider struct {
fallback *recordingFallback
clock *fakeClock
connects atomic.Int32
// staging stands in for the staging realms; by default nobody is signed
// in to staging.
staging *fakeHub
}
func newTestProvider(hub hubSessions, connectErr error) *testProvider {
tp := &testProvider{
fallback: &recordingFallback{},
clock: &fakeClock{t: testNow},
staging: &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile},
}
tp.desktopAuthProvider = &desktopAuthProvider{
fallback: tp.fallback,
session: &desktopSession{
hub: &desktopSession{
name: "Docker Hub",
serverAddress: IndexServer,
connect: func() (hubSessions, error) {
tp.connects.Add(1)
if connectErr != nil {
@@ -127,22 +133,46 @@ func newTestProvider(hub hubSessions, connectErr error) *testProvider {
timeout: time.Second,
now: tp.clock.now,
},
stagingHub: &desktopSession{
name: "Docker Hub staging",
serverAddress: StagingIndexServer,
connect: func() (hubSessions, error) {
return tp.staging, nil
},
timeout: time.Second,
now: tp.clock.now,
},
}
return tp
}
func TestNewDesktopAuthProvider_SharesOneSession(t *testing.T) {
// signInToStaging gives the staging realms a valid session.
func (tp *testProvider) signInToStaging() {
tp.staging.sessionErr = nil
tp.staging.session = hubSession("stageuser", testNow.Add(time.Hour))
tp.staging.session.AccessToken = "staging-oauth-token"
}
func stagingCredentials() clitypes.AuthConfig {
return clitypes.AuthConfig{Username: "stageuser", Password: "staging-oauth-token", ServerAddress: StagingIndexServer}
}
func TestNewDesktopAuthProvider_SharesOneSessionPerEnvironment(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")
assert.Assert(t, a.hub == b.hub, "providers must share the process-wide Docker Hub session")
assert.Assert(t, a.stagingHub == b.stagingHub, "providers must share the process-wide Docker Hub staging session")
assert.Assert(t, a.hub != a.stagingHub, "production and staging sessions must be separate")
assert.Equal(t, a.hub.serverAddress, IndexServer)
assert.Equal(t, a.stagingHub.serverAddress, StagingIndexServer)
}
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}
other := &desktopAuthProvider{fallback: &recordingFallback{}, hub: p.hub, stagingHub: p.stagingHub}
for _, provider := range []AuthProvider{p, other} {
got, err := provider.GetAuthConfig("docker.io")
@@ -168,19 +198,72 @@ func TestDesktopAuthProvider_DockerHubUsesDesktopSession(t *testing.T) {
}
func TestDesktopAuthProvider_OtherRegistriesUseFallback(t *testing.T) {
for _, host := range []string{"ghcr.io", "localhost:5000", "registry.example.com"} {
hosts := []string{
"ghcr.io", "localhost:5000", "registry.example.com",
// look-alikes of the staging registry
StagingRegistryHost + ".example.com", "example.com/" + StagingRegistryHost,
}
for _, host := range hosts {
t.Run(host, func(t *testing.T) {
hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))}
p := newTestProvider(hub, nil)
p.signInToStaging()
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")
assert.Equal(t, p.staging.sessionCalls.Load(), int32(0), "Docker Desktop must only be contacted for Docker Hub")
})
}
}
func TestDesktopAuthProvider_StagingRegistryUsesStagingSession(t *testing.T) {
for _, host := range []string{StagingRegistryHost, StagingIndexServer, "https://" + StagingRegistryHost} {
t.Run(host, func(t *testing.T) {
hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))}
p := newTestProvider(hub, nil)
p.signInToStaging()
got, err := p.GetAuthConfig(host)
assert.NilError(t, err)
assert.DeepEqual(t, got, stagingCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(0), "the production session must not be read for the staging registry")
})
}
}
// TestDesktopAuthProvider_EnvironmentsDoNotMix guards that a token is only
// ever sent to the registry of the environment that issued it.
func TestDesktopAuthProvider_EnvironmentsDoNotMix(t *testing.T) {
t.Run("staging session is not used for docker.io", func(t *testing.T) {
// Desktop in stage mode: signed in to staging only.
p := newTestProvider(&fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile}, nil)
p.signInToStaging()
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials("docker.io"))
got, err = p.GetAuthConfig(StagingRegistryHost)
assert.NilError(t, err)
assert.DeepEqual(t, got, stagingCredentials())
})
t.Run("production session is not used for the staging registry", func(t *testing.T) {
hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))}
p := newTestProvider(hub, nil)
got, err := p.GetAuthConfig(StagingRegistryHost)
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials(StagingRegistryHost))
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
})
}
func TestDesktopAuthProvider_UsernameFromProfile(t *testing.T) {
hub := &fakeHub{
session: hubSession("", testNow.Add(time.Hour)),
+6
View File
@@ -39,6 +39,12 @@ const (
IndexServer = "https://" + IndexHostname + "/v1/"
// IndexName is the name of the index
IndexName = "docker.io"
// StagingRegistryHost is the registry of the Docker Hub staging
// environment, which Docker Desktop uses in stage mode.
StagingRegistryHost = "registry-1-stage.docker.io"
// StagingIndexServer is the credentials key of the Docker Hub staging
// environment.
StagingIndexServer = "https://" + StagingRegistryHost + "/"
)
// GetAuthConfigKey special-cases using the full index address of the official