diff --git a/internal/registry/desktop.go b/internal/registry/desktop.go index 821eada87..1b54904eb 100644 --- a/internal/registry/desktop.go +++ b/internal/registry/desktop.go @@ -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) } diff --git a/internal/registry/desktop_test.go b/internal/registry/desktop_test.go index d8975e1f7..1b212759d 100644 --- a/internal/registry/desktop_test.go +++ b/internal/registry/desktop_test.go @@ -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)), diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 121dbe79a..dd655d6c2 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -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