diff --git a/cmd/root/debug_auth.go b/cmd/root/debug_auth.go index b8b40ff3fe..19c7671c28 100644 --- a/cmd/root/debug_auth.go +++ b/cmd/root/debug_auth.go @@ -1,11 +1,14 @@ package root import ( + "cmp" "encoding/json" + "errors" "fmt" "io" "time" + secretsengine "github.com/docker/secrets-engine/client" "github.com/golang-jwt/jwt/v5" "github.com/spf13/cobra" @@ -24,6 +27,22 @@ type authInfo struct { Expired bool `json:"expired"` Username string `json:"username,omitempty"` Email string `json:"email,omitempty"` + + SecretsEngine *secretsEngineInfo `json:"secrets_engine,omitempty"` +} + +// secretsEngineInfo says why the secrets engine supplied no token, with the SDK's reason, socket and hint. +type secretsEngineInfo struct { + Error string `json:"error"` + Reason string `json:"reason,omitempty"` + Socket string `json:"socket,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// noTokenInfo is the JSON output when no token is found. +type noTokenInfo struct { + Error string `json:"error"` + SecretsEngine *secretsEngineInfo `json:"secrets_engine,omitempty"` } func newDebugAuthCmd() *cobra.Command { @@ -43,13 +62,16 @@ func newDebugAuthCmd() *cobra.Command { w := cmd.OutOrStdout() token, source := desktop.GetTokenWithSource(ctx) + engine := newSecretsEngineInfo(desktop.SecretsEngineError(), token != "") if token == "" { if jsonOutput { - return json.NewEncoder(w).Encode(map[string]string{ - "error": "no token found (is Docker Desktop running, or are you logged in with `docker login`?)", + return json.NewEncoder(w).Encode(noTokenInfo{ + Error: "no token found (is Docker Desktop running, or are you logged in with `docker login`?)", + SecretsEngine: engine, }) } fmt.Fprintln(w, "No token found. Is Docker Desktop running, or are you logged in with `docker login`?") + printSecretsEngineText(w, engine) return nil } @@ -58,6 +80,7 @@ func newDebugAuthCmd() *cobra.Command { return fmt.Errorf("failed to parse JWT: %w", err) } info.Source = string(source) + info.SecretsEngine = engine userInfo := desktop.GetUserInfo(ctx) info.Username = userInfo.Username @@ -141,4 +164,36 @@ func printAuthInfoText(w io.Writer, info *authInfo) { } else { fmt.Fprintln(w, "Status: ✅ Valid") } + + printSecretsEngineText(w, info.SecretsEngine) +} + +// newSecretsEngineInfo describes why the secrets engine failed, leaving out "not running" when another source supplied a token. +func newSecretsEngineInfo(err error, tokenFound bool) *secretsEngineInfo { + if err == nil || (tokenFound && errors.Is(err, secretsengine.ErrSecretsEngineNotRunning)) { + return nil + } + info := &secretsEngineInfo{Error: err.Error()} + if connErr, ok := errors.AsType[*secretsengine.ConnectError](err); ok { + if connErr.Reason != secretsengine.ReasonUnknown { + info.Reason = connErr.Reason.String() + } + info.Socket = connErr.SocketPath + info.Hint = connErr.Hint() + } + return info +} + +func printSecretsEngineText(w io.Writer, info *secretsEngineInfo) { + if info == nil { + return + } + fmt.Fprintln(w) + fmt.Fprintf(w, "Secrets engine: %s\n", cmp.Or(info.Reason, info.Error)) + if info.Socket != "" { + fmt.Fprintf(w, "Socket: %s\n", info.Socket) + } + if info.Hint != "" { + fmt.Fprintf(w, "Hint: %s\n", info.Hint) + } } diff --git a/cmd/root/debug_auth_test.go b/cmd/root/debug_auth_test.go index 214605f0f1..796616b59e 100644 --- a/cmd/root/debug_auth_test.go +++ b/cmd/root/debug_auth_test.go @@ -4,10 +4,13 @@ import ( "bytes" "encoding/base64" "encoding/json" + "errors" "fmt" "testing" "time" + secretsengine "github.com/docker/secrets-engine/client" + "github.com/docker/secrets-engine/x/api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -89,6 +92,84 @@ func TestPrintAuthInfoText(t *testing.T) { assert.Contains(t, output, "✅ Valid") } +func TestPrintAuthInfoText_SecretsEngine(t *testing.T) { + t.Parallel() + + info := &authInfo{ + Token: "eyJhbGciOiJIUzI1NiJ9.xxxxxxxxxxxx.yyyyyyyy1234567890", + SecretsEngine: &secretsEngineInfo{ + Error: "retrieve default account metadata: permission denied connecting to the secrets engine at /run/engine.sock: dial failed", + Reason: "permission denied", + Socket: "/run/engine.sock", + Hint: "Grant access and retry.", + }, + } + + var buf bytes.Buffer + printAuthInfoText(&buf, info) + + assert.Contains(t, buf.String(), "✅ Valid\n\nSecrets engine: permission denied\nSocket: /run/engine.sock\nHint: Grant access and retry.\n") +} + +func TestPrintSecretsEngineText_WithoutReason(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + printSecretsEngineText(&buf, &secretsEngineInfo{Error: "retrieve default account metadata: access denied"}) + + assert.Equal(t, "\nSecrets engine: retrieve default account metadata: access denied\n", buf.String()) +} + +func TestNewSecretsEngineInfo(t *testing.T) { + t.Parallel() + + notRunning := sdkConnectError(secretsengine.ReasonNotRunning) + denied := sdkConnectError(secretsengine.ReasonPermissionDenied) + require.NotEmpty(t, notRunning.Hint()) + require.NotEmpty(t, denied.Hint()) + + unknown := sdkConnectError(secretsengine.ReasonUnknown) + wrappedDenied := fmt.Errorf("retrieve default account metadata: %w", denied) + accessDenied := fmt.Errorf("retrieve default account metadata: %w", errors.New("access denied")) + socket := api.DesktopSocketPath() + + for _, tt := range []struct { + name string + err error + tokenFound bool + want *secretsEngineInfo + }{ + {name: "the engine answered"}, + {name: "not running with a token from elsewhere", err: notRunning, tokenFound: true}, + { + name: "not running without a token", + err: notRunning, + want: &secretsEngineInfo{Error: notRunning.Error(), Reason: secretsengine.ReasonNotRunning.String(), Socket: socket, Hint: notRunning.Hint()}, + }, + { + name: "permission denied with a token from elsewhere", + err: wrappedDenied, + tokenFound: true, + want: &secretsEngineInfo{Error: wrappedDenied.Error(), Reason: secretsengine.ReasonPermissionDenied.String(), Socket: socket, Hint: denied.Hint()}, + }, + { + name: "an unknown connection failure", + err: unknown, + want: &secretsEngineInfo{Error: unknown.Error(), Socket: socket, Hint: unknown.Hint()}, + }, + {name: "not a connection failure", err: accessDenied, want: &secretsEngineInfo{Error: accessDenied.Error()}}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, newSecretsEngineInfo(tt.err, tt.tokenFound)) + }) + } +} + +func sdkConnectError(reason secretsengine.ConnectReason) *secretsengine.ConnectError { + return &secretsengine.ConnectError{Reason: reason, SocketPath: api.DesktopSocketPath(), Err: errors.New("dial failed")} +} + func TestPrintAuthInfoText_Expired(t *testing.T) { t.Parallel() diff --git a/docs/configuration/overview/index.md b/docs/configuration/overview/index.md index 47ce1666e1..df46eef51a 100644 --- a/docs/configuration/overview/index.md +++ b/docs/configuration/overview/index.md @@ -155,7 +155,7 @@ See [Provider Credentials](../../providers/overview/index.md#provider-credential | `DOCKER_AGENT_HIDE_TELEMETRY_BANNER`| Set to `1` to suppress the first-run telemetry notice. | | `DOCKER_AGENT_AUTO_UPDATE` | Set to a truthy value (`1`, `true`, `yes`, `on`) to let standalone release binaries self-update before running. See [Optional Self-Updates](../../getting-started/installation/index.md#optional-self-updates). | | `DOCKER_AGENT_NO_TOKEN_EXCHANGE` | Set to `1` to stop Docker Agent from exchanging the access token stored by `docker login` for a Docker token. See [Docker authentication](../../guides/secrets/index.md#docker-authentication). | -| `DOCKER_AGENT_HUB_LOGIN_URL` | Point the token exchange at a Docker staging environment. Ignored unless it is an HTTPS `docker.com` URL. | +| `DOCKER_AGENT_HUB_LOGIN_URL` | Point the token exchange at a Docker staging environment. Ignored unless it is an HTTPS `docker.com` URL. A staging host, such as `hub-stage.docker.com`, also makes Docker Agent read staging sessions from the Docker secrets engine. | | `DOCKER_AGENT_DISABLE_DESKTOP_PROXY` | Set to a truthy value (`1`, `true`, `yes`, `on`) to bypass Docker Desktop's PAC adapter per request and restore standard `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/`NO_PROXY` routing. | > [!NOTE] diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index 9283710ff0..9726559484 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -797,7 +797,9 @@ $ docker agent debug oauth login agent.yaml github > > The text output of `debug auth` truncates the token to a short preview, but `--json` includes the complete, unredacted JWT in its `token` field. Never paste `debug auth --json` output into logs, issue trackers, or bug reports — anyone with that token can act as you against Docker. Use the plain-text output (or redact the `token` field yourself) when sharing diagnostic output. -The `Source` field says where the token came from: `docker desktop`, or `minted from the stored access token` when it was obtained by exchanging the access token `docker login` stored. See [Docker authentication](../../guides/secrets/index.md#docker-authentication). +The `Source` field says where the token came from: `docker desktop secrets engine` when it was read from the secrets engine Docker Desktop serves, `docker desktop` when Docker Desktop's backend served it, or `minted from the stored access token` when it was obtained by exchanging the access token `docker login` stored. See [Docker authentication](../../guides/secrets/index.md#docker-authentication). + +When the secrets engine couldn't be read, `Secrets engine` says why, `Socket` which socket was tried, and `Hint` how to fix it. The JSON output has them under `secrets_engine` as `reason`, `socket` and `hint`, along with the full `error`. A secrets engine that isn't running is only reported when no token was found, since machines without Docker Desktop don't have one. The `config`, `toolsets`, `tool`, `skills`, and `title` subcommands also accept [runtime configuration flags](#runtime-configuration-flags) (`--working-dir`, `--models-gateway`, …); `title` additionally accepts `--model` to override the model used to resolve the config before generating the title. diff --git a/docs/guides/secrets/index.md b/docs/guides/secrets/index.md index 7accef7fce..56f6bfd1aa 100644 --- a/docs/guides/secrets/index.md +++ b/docs/guides/secrets/index.md @@ -177,7 +177,16 @@ On machines where Docker Desktop is installed, Docker Agent queries Docker Deskt ## Docker Authentication -Routing model traffic through the [Docker models gateway](../../configuration/models/index.md) needs a Docker token. Docker Desktop hands out one that is valid for 15 minutes and cannot be renewed by Docker Agent, so when Desktop has nothing usable to offer — it is signed out, not running, or its own refresh is stuck — Docker Agent exchanges the long-lived access token that `docker login` left in your credential store for a fresh Docker token, the same exchange `docker login` itself performs. Signing in with `docker login` is therefore enough; Docker Desktop is not required. +Routing model traffic through the [Docker models gateway](../../configuration/models/index.md) needs a Docker token. A `DOCKER_TOKEN` you provide yourself — an environment variable, a Compose secret, an env file or a credential helper, as is common in CI — always takes precedence, and none of the sources below are consulted. + +Otherwise, Docker Agent tries these sources in order and uses the first token that is not expired or about to expire: + +1. **The Docker secrets engine**, which Docker Desktop serves on a local socket and which holds the session of the account signed in to Desktop. It is skipped when it is unavailable (Docker Desktop is not installed or not running, or predates the engine) or holds no session. A request waits for the engine for at most five seconds, and never more than half of its own time limit, so the other sources still get a turn. An engine that fails or does not answer is skipped for the next 30 seconds. Production sessions are read unless `DOCKER_AGENT_HUB_LOGIN_URL` points at a Docker Hub staging host, such as `hub-stage.docker.com`; staging sessions are read instead. Docker Agent cannot tell which environment Docker Desktop is signed in to, so set that variable when Desktop is signed in to staging. +2. **Docker Desktop's backend.** +3. **The access token `docker login` stored.** Docker Agent exchanges the long-lived access token in your credential store for a fresh Docker token, the same exchange `docker login` itself performs. Signing in with `docker login` is therefore enough; Docker Desktop is not required. +4. **A refresh of Docker Desktop's session**, as a last resort. + +The tokens Docker Desktop hands out are valid for 15 minutes, and only Desktop can renew them. That is why the exchange exists: it keeps Docker Agent working when Desktop has nothing usable to offer because it is signed out, not running, or its own refresh is stuck. Tokens are looked up again as they near expiry, so a long-running session keeps working. Only Docker access tokens are exchanged — the `dckr_…` secrets `docker login` stores — never an account password, and the exchange goes to Docker Hub over HTTPS. The resulting bearer token is cached in a private file under Docker Agent's cache directory so sibling processes reuse it instead of minting their own, and it stops being used within seconds of a `docker logout` or an account switch. Run `docker agent debug auth` to see which token is in use and where it came from. diff --git a/go.mod b/go.mod index d5fd229cdd..00c35d5889 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,8 @@ require ( github.com/docker/cli v29.8.1+incompatible github.com/docker/go-units v0.5.0 github.com/docker/portcullis v1.1.0 + github.com/docker/secrets-engine/client v0.2.0 + github.com/docker/secrets-engine/x v0.8.3 github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b github.com/expr-lang/expr v1.17.8 github.com/fatih/color v1.19.0 @@ -106,6 +108,7 @@ require ( cloud.google.com/go/auth v0.23.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + connectrpc.com/connect v1.19.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect diff --git a/go.sum b/go.sum index 4a56db766b..bbddd60b04 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= @@ -180,6 +182,10 @@ 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/portcullis v1.1.0 h1:c45WCRlsQfRxX3hLBqsaKI5/AbA+mdiNNgyeSl4bQZg= github.com/docker/portcullis v1.1.0/go.mod h1:Mi8911/f4iLlXzAPVR/FFU9Hflze+7PDoxVzyQqHzGs= +github.com/docker/secrets-engine/client v0.2.0 h1:69Q63N5VDbXbxPPgm0LjUtRJ4O41q4cQeFpUxsHZUJo= +github.com/docker/secrets-engine/client v0.2.0/go.mod h1:dL3y9wh+HVHMTInwOOmAmjai6oARC8T+se30RnWgrLs= +github.com/docker/secrets-engine/x v0.8.3 h1:0rt91D54Ah6vI96qbc0Qcaxbkd0n36QAkQm+2614FzA= +github.com/docker/secrets-engine/x v0.8.3/go.mod h1:+s6rb56pq1B376jzzkGqZreFJ3mUtkz+kaYlMuj7/Qs= github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b h1:UMDLDHFR1Chu3qnsPNCrVxq0lZgG6JqHpLL5+iqfSkw= github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= diff --git a/pkg/desktop/login.go b/pkg/desktop/login.go index 26b6a1ac60..c4e4233182 100644 --- a/pkg/desktop/login.go +++ b/pkg/desktop/login.go @@ -20,9 +20,10 @@ type DockerHubInfo struct { type Source string const ( - SourceNone Source = "none" - SourceDesktop Source = "docker desktop" - SourceMinted Source = "minted from the stored access token" + SourceNone Source = "none" + SourceSecretsEngine Source = "docker desktop secrets engine" + SourceDesktop Source = "docker desktop" + SourceMinted Source = "minted from the stored access token" ) // mintToken exchanges the stored access token for a fresh one. A var so tests @@ -58,18 +59,16 @@ func GetToken(ctx context.Context) string { return token } -// GetTokenWithSource returns the user's Docker access token and where it came -// from. Docker Desktop's newer auth stack (auth v2) serves whatever its -// in-memory token source holds and never refreshes on GET, so a stuck -// background refresher makes it return the same expired JWT forever — or -// nothing at all when its read-time refresh failed. When that happens we mint -// a token ourselves from the access token `docker login` stored, and only then -// fall back to nudging Desktop. +// GetTokenWithSource returns the user's Docker access token and where it came from. func GetTokenWithSource(ctx context.Context) (string, Source) { if token, source, ok := cached(); ok { return token, source } + if token, ok := secretsEngineToken(ctx); ok { + return token, SourceSecretsEngine + } + token, err := fetchToken(ctx) if err == nil && usable(token) && remember(token, SourceDesktop) { return token, SourceDesktop @@ -217,20 +216,15 @@ func expiresIn(token string) string { return time.Until(exp).Round(time.Second).String() } -// GetUserInfo returns the signed-in account. Docker Desktop knows it best, but -// it is not always around: the token itself carries the same information. +// GetUserInfo returns the account of the token [GetToken] serves, else Docker Desktop's. func GetUserInfo(ctx context.Context) DockerHubInfo { - var info DockerHubInfo - _ = ClientBackend.Get(ctx, "/registry/info", &info) - if info.Username != "" { - return info + if identity, ok := hubauth.IdentityFromToken(GetToken(ctx)); ok { + return DockerHubInfo{Username: identity.Username, Email: identity.Email} } - identity, ok := hubauth.IdentityFromToken(GetToken(ctx)) - if !ok { - return info - } - return DockerHubInfo{Username: identity.Username, Email: identity.Email} + var info DockerHubInfo + _ = ClientBackend.Get(ctx, "/registry/info", &info) + return info } func fetchToken(ctx context.Context) (string, error) { diff --git a/pkg/desktop/login_test.go b/pkg/desktop/login_test.go index 90f0c8b23b..53de83ab08 100644 --- a/pkg/desktop/login_test.go +++ b/pkg/desktop/login_test.go @@ -359,21 +359,38 @@ func TestEveryRefusedTokenStaysRefused(t *testing.T) { } func TestGetUserInfo(t *testing.T) { - t.Run("prefers what Docker Desktop reports", func(t *testing.T) { + desktopAccount := DockerHubInfo{Username: "desktop-user", Email: "desktop@example.com"} + + t.Run("names the account of the token in use", func(t *testing.T) { backend := &fakeBackend{token: makeIdentityToken(t, "claims-user", "claims@example.com")} - backend.info = &DockerHubInfo{Username: "desktop-user", Email: "desktop@example.com"} + backend.info = &desktopAccount installFakeBackend(t, backend) - assert.Equal(t, DockerHubInfo{Username: "desktop-user", Email: "desktop@example.com"}, GetUserInfo(t.Context())) + assert.Equal(t, DockerHubInfo{Username: "claims-user", Email: "claims@example.com"}, GetUserInfo(t.Context())) }) - t.Run("falls back to the token claims", func(t *testing.T) { - // Docker Desktop is not around (or not signed in): the token itself - // says who we are. - backend := &fakeBackend{token: makeIdentityToken(t, "claims-user", "claims@example.com")} + t.Run("falls back to Docker Desktop for a token without an account", func(t *testing.T) { + backend := &fakeBackend{token: makeToken(t, time.Now().Add(time.Hour))} + backend.info = &desktopAccount installFakeBackend(t, backend) - assert.Equal(t, DockerHubInfo{Username: "claims-user", Email: "claims@example.com"}, GetUserInfo(t.Context())) + assert.Equal(t, desktopAccount, GetUserInfo(t.Context())) + }) + + t.Run("falls back to Docker Desktop for a personal access token", func(t *testing.T) { + backend := &fakeBackend{token: "dckr_pat_0123456789abcdef", loggedIn: true} + backend.info = &desktopAccount + installFakeBackend(t, backend) + + assert.Equal(t, desktopAccount, GetUserInfo(t.Context())) + }) + + t.Run("falls back to Docker Desktop without a token", func(t *testing.T) { + backend := &fakeBackend{} + backend.info = &desktopAccount + installFakeBackend(t, backend) + + assert.Equal(t, desktopAccount, GetUserInfo(t.Context())) }) t.Run("reports nothing without a token", func(t *testing.T) { @@ -500,6 +517,8 @@ func installFakeBackend(t *testing.T, backend *fakeBackend) { } t.Cleanup(func() { mintToken = oldMint }) + installNoSecretsEngine(t) + clearCache := func() { cache.Lock() defer cache.Unlock() diff --git a/pkg/desktop/secrets_engine.go b/pkg/desktop/secrets_engine.go new file mode 100644 index 0000000000..2ad54615e8 --- /dev/null +++ b/pkg/desktop/secrets_engine.go @@ -0,0 +1,223 @@ +//go:build darwin || linux || windows + +package desktop + +import ( + "cmp" + "context" + "errors" + "log/slog" + "sync" + "time" + + secretsengine "github.com/docker/secrets-engine/client" + "github.com/docker/secrets-engine/client/dockerhub" + "github.com/docker/secrets-engine/x/api" + + "github.com/docker/docker-agent/pkg/hubauth" +) + +const ( + // secretsEngineBudget bounds a session lookup: the SDK has no default timeout. + secretsEngineBudget = 5 * time.Second + + // secretsEngineCooldown is how long a failing engine is skipped. + secretsEngineCooldown = 30 * time.Second +) + +var ( + errSecretsEngineCoolingDown = errors.New("secrets engine lookup failed recently, not retrying yet") + errSecretsEngineSlow = errors.New("secrets engine did not answer in time") +) + +const secretsEngineFailureMsg = "Could not read the Docker Hub session from the secrets engine" + +// defaultSecretsEngine is the engine [GetTokenWithSource] reads, created on first use. +var defaultSecretsEngine = sync.OnceValue(newSecretsEngine) + +// newSecretsEngine connects to Docker Desktop's secrets engine, reading staging sessions on staging. +func newSecretsEngine() *secretsEngine { + client, err := secretsengine.New(secretsengine.WithSocketPath(api.DesktopSocketPath())) + if err != nil { + return &secretsEngine{clientErr: err} + } + var opts []dockerhub.Option + if hubauth.Staging() { + opts = append(opts, dockerhub.Staging()) + } + return &secretsEngine{client: client.HubAuth(opts...)} +} + +// secretsEngine reads Docker Hub sessions from the secrets engine, sharing lookups and cooling down after failures. +type secretsEngine struct { + client dockerhub.ClientAuth + clientErr error + logger *slog.Logger // nil logs to slog.Default() + + mu sync.Mutex + nextAttempt time.Time // earliest time the engine may be asked again + reported bool // the current run of failures was logged as a warning + inflight *secretsEngineLookup // the lookup callers share, nil when none runs + lastErr error // why the last lookup failed, nil when it didn't +} + +// secretsEngineLookup is one session lookup shared by concurrent callers. +type secretsEngineLookup struct { + done chan struct{} // closed once token and err are set + token string + err error +} + +// secretsEngineToken returns a usable token from the secrets engine and caches it. +func secretsEngineToken(ctx context.Context) (string, bool) { + token, err := defaultSecretsEngine().fetch(ctx) + switch { + case err != nil: + slog.DebugContext(ctx, "Skipping the secrets engine", "error", err) + return "", false + case token == "": + slog.DebugContext(ctx, "No Docker Hub session in the secrets engine") + return "", false + case !usable(token): + slog.DebugContext(ctx, "The secrets engine served a token that expired, is about to, or was refused", + "fingerprint", tokenFingerprint(token), + "expires_in", expiresIn(token)) + return "", false + case !remember(token, SourceSecretsEngine): + return "", false + } + return token, true +} + +// SecretsEngineError returns why the last secrets engine lookup failed, or nil, once any running lookup ends. +func SecretsEngineError() error { + return defaultSecretsEngine().lastError() +} + +// fetch waits at most half of ctx's remaining time for the shared lookup. +func (e *secretsEngine) fetch(ctx context.Context) (string, error) { + lookup, err := e.join(ctx) + if err != nil { + return "", err + } + + wait, cancel := context.WithTimeout(ctx, secretsEngineWait(ctx)) + defer cancel() + + select { + case <-lookup.done: + return lookup.token, lookup.err + case <-wait.Done(): + if err := ctx.Err(); err != nil { + return "", err + } + return "", errSecretsEngineSlow + } +} + +// secretsEngineWait is how long ctx's caller may wait for the engine. +func secretsEngineWait(ctx context.Context) time.Duration { + wait := secretsEngineBudget + if deadline, ok := ctx.Deadline(); ok { + wait = min(wait, time.Until(deadline)/2) + } + return wait +} + +// join returns the running lookup, or starts one. +func (e *secretsEngine) join(ctx context.Context) (*secretsEngineLookup, error) { + e.mu.Lock() + defer e.mu.Unlock() + + if lookup := e.inflight; lookup != nil { + return lookup, nil + } + if time.Now().Before(e.nextAttempt) { + return nil, errSecretsEngineCoolingDown + } + + lookup := &secretsEngineLookup{done: make(chan struct{})} + e.inflight = lookup + go e.run(context.WithoutCancel(ctx), lookup) + return lookup, nil +} + +// run reads the session; a failure is logged and starts the cooldown. +func (e *secretsEngine) run(ctx context.Context, lookup *secretsEngineLookup) { + token, err := e.session(ctx) + if err != nil { + e.logError(ctx, err) + } + + e.mu.Lock() + defer e.mu.Unlock() + if err != nil { + e.nextAttempt = time.Now().Add(secretsEngineCooldown) + } else { + e.reported = false + } + e.lastErr = err + e.inflight = nil + lookup.token, lookup.err = token, err + close(lookup.done) +} + +// wait blocks until the running lookup, if any, ends. +func (e *secretsEngine) wait() { + e.mu.Lock() + lookup := e.inflight + e.mu.Unlock() + if lookup != nil { + <-lookup.done + } +} + +// lastError returns why the last lookup failed, or nil, once any running lookup ends. +func (e *secretsEngine) lastError() error { + e.wait() + e.mu.Lock() + defer e.mu.Unlock() + return e.lastErr +} + +// session returns the default account's access token, or "" when signed out. +func (e *secretsEngine) session(ctx context.Context) (string, error) { + if e.clientErr != nil { + return "", e.clientErr + } + + ctx, cancel := context.WithTimeout(ctx, secretsEngineBudget) + defer cancel() + + session, err := e.client.GetDefaultSession(ctx) + switch { + case err == nil: + return session.AccessToken, nil + case errors.Is(err, dockerhub.ErrNoSession): + return "", nil + default: + return "", err + } +} + +// logError warns once per run of failures, except for a missing engine. +func (e *secretsEngine) logError(ctx context.Context, err error) { + level := slog.LevelWarn + if errors.Is(err, secretsengine.ErrSecretsEngineNotRunning) || e.failureReported() { + level = slog.LevelDebug + } + args := []any{"error", err} + if connErr, ok := errors.AsType[*secretsengine.ConnectError](err); ok && connErr.Hint() != "" { + args = append(args, "hint", connErr.Hint()) + } + cmp.Or(e.logger, slog.Default()).Log(ctx, level, secretsEngineFailureMsg, args...) +} + +// failureReported reports whether this run of failures was already warned about. +func (e *secretsEngine) failureReported() bool { + e.mu.Lock() + defer e.mu.Unlock() + reported := e.reported + e.reported = true + return reported +} diff --git a/pkg/desktop/secrets_engine_logging_test.go b/pkg/desktop/secrets_engine_logging_test.go new file mode 100644 index 0000000000..cafb8da7eb --- /dev/null +++ b/pkg/desktop/secrets_engine_logging_test.go @@ -0,0 +1,118 @@ +//go:build darwin || linux || windows + +package desktop + +import ( + "bytes" + "errors" + "log/slog" + "net" + "os" + "strings" + "syscall" + "testing" + "testing/synctest" + + secretsengine "github.com/docker/secrets-engine/client" + "github.com/docker/secrets-engine/x/api" + "github.com/docker/secrets-engine/x/secrets" + "github.com/stretchr/testify/assert" +) + +func sdkDialError(reason secretsengine.ConnectReason, err error) error { + return &secretsengine.ConnectError{ + Reason: reason, + SocketPath: api.DesktopSocketPath(), + Err: &net.OpError{Op: "dial", Net: "unix", Err: os.NewSyscallError("connect", err)}, + } +} + +func TestSecretsEngineFailureLogging(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + err error + warn bool + }{ + {name: "a missing engine", err: sdkDialError(secretsengine.ReasonNotRunning, syscall.ENOENT)}, + {name: "permission denied", err: sdkDialError(secretsengine.ReasonPermissionDenied, syscall.EACCES), warn: true}, + {name: "a dial timeout", err: sdkDialError(secretsengine.ReasonTimeout, os.ErrDeadlineExceeded), warn: true}, + {name: "an unknown dial failure", err: sdkDialError(secretsengine.ReasonUnknown, syscall.ENOTSOCK), warn: true}, + {name: "access denied by the engine", err: secrets.ErrAccessDenied, warn: true}, + } { + t.Run(tt.name+" is logged at the right level", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + engine, logs := newLoggingEngine(t, &fakeEngine{err: tt.err}) + + _, _ = engine.fetch(t.Context()) + if tt.warn { + assert.Equal(t, 1, countFailureLogs(logs, "WARN")) + } else { + assert.Equal(t, 0, countFailureLogs(logs, "WARN")) + assert.Equal(t, 1, countFailureLogs(logs, "DEBUG")) + } + + if connErr, ok := errors.AsType[*secretsengine.ConnectError](tt.err); ok && connErr.Hint() != "" { + assert.Contains(t, logs.String(), "hint=", "the SDK's hint is logged") + } else { + assert.NotContains(t, logs.String(), "hint=") + } + }) + }) + } + + t.Run("a missing Docker Desktop logs the SDK's hint", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + engine, logs := newLoggingEngine(t, &fakeEngine{err: sdkDialError(secretsengine.ReasonNotRunning, syscall.ENOENT)}) + + _, _ = engine.fetch(t.Context()) + assert.Contains(t, logs.String(), `hint="Start Docker Desktop and retry`) + }) + }) + + t.Run("other failures are warned about once per run", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + fake := &fakeEngine{err: secrets.ErrAccessDenied} + engine, logs := newLoggingEngine(t, fake) + + lookUp := func() { + endCooldown(engine) + _, _ = engine.fetch(t.Context()) + } + + lookUp() + lookUp() + assert.Equal(t, 1, countFailureLogs(logs, "WARN"), "a failure that persists is warned about once") + assert.Equal(t, 1, countFailureLogs(logs, "DEBUG")) + + fake.setErr(nil) + lookUp() + fake.setErr(secrets.ErrAccessDenied) + lookUp() + assert.Equal(t, 2, countFailureLogs(logs, "WARN")) + }) + }) +} + +func newLoggingEngine(t *testing.T, fake *fakeEngine) (*secretsEngine, *bytes.Buffer) { + t.Helper() + + var logs bytes.Buffer + engine := newTestEngine(t, fake) + engine.logger = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + return engine, &logs +} + +func countFailureLogs(logs *bytes.Buffer, level string) int { + n := 0 + for line := range strings.Lines(logs.String()) { + if strings.Contains(line, "level="+level) && strings.Contains(line, `msg="`+secretsEngineFailureMsg+`"`) { + n++ + } + } + return n +} diff --git a/pkg/desktop/secrets_engine_other.go b/pkg/desktop/secrets_engine_other.go new file mode 100644 index 0000000000..c3c60e3195 --- /dev/null +++ b/pkg/desktop/secrets_engine_other.go @@ -0,0 +1,10 @@ +//go:build !darwin && !linux && !windows + +package desktop + +import "context" + +// secretsEngineToken never finds a token: the SDK doesn't support this platform. +func secretsEngineToken(context.Context) (string, bool) { + return "", false +} diff --git a/pkg/desktop/secrets_engine_other_test.go b/pkg/desktop/secrets_engine_other_test.go new file mode 100644 index 0000000000..4cdb909099 --- /dev/null +++ b/pkg/desktop/secrets_engine_other_test.go @@ -0,0 +1,7 @@ +//go:build !darwin && !linux && !windows + +package desktop + +import "testing" + +func installNoSecretsEngine(*testing.T) {} diff --git a/pkg/desktop/secrets_engine_test.go b/pkg/desktop/secrets_engine_test.go new file mode 100644 index 0000000000..7d34f24b55 --- /dev/null +++ b/pkg/desktop/secrets_engine_test.go @@ -0,0 +1,429 @@ +//go:build darwin || linux || windows + +package desktop + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/docker/secrets-engine/client/dockerhub" + "github.com/docker/secrets-engine/x/secrets" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetTokenFromSecretsEngine(t *testing.T) { + t.Run("the engine session is preferred to Docker Desktop's backend", func(t *testing.T) { + fromEngine := makeToken(t, time.Now().Add(time.Hour)) + installFakeBackend(t, &fakeBackend{token: makeToken(t, time.Now().Add(time.Hour))}) + installFakeEngine(t, &fakeEngine{token: fromEngine}) + + token, source := GetTokenWithSource(t.Context()) + assert.Equal(t, fromEngine, token) + assert.Equal(t, SourceSecretsEngine, source) + }) + + t.Run("an engine token is served from memory", func(t *testing.T) { + fromEngine := makeToken(t, time.Now().Add(time.Hour)) + installFakeBackend(t, &fakeBackend{}) + engine := &fakeEngine{token: fromEngine} + installFakeEngine(t, engine) + + require.Equal(t, fromEngine, GetToken(t.Context())) + lookups := engine.lookupCount() + + token, source := GetTokenWithSource(t.Context()) + assert.Equal(t, fromEngine, token) + assert.Equal(t, SourceSecretsEngine, source) + assert.Equal(t, lookups, engine.lookupCount(), "gateway clients call this per request") + }) + + t.Run("nobody signed in falls back, then picks up a sign-in", func(t *testing.T) { + fromDesktop := makeToken(t, time.Now().Add(time.Hour)) + installFakeBackend(t, &fakeBackend{token: fromDesktop}) + engine := &fakeEngine{} + installFakeEngine(t, engine) + + token, source := GetTokenWithSource(t.Context()) + assert.Equal(t, fromDesktop, token) + assert.Equal(t, SourceDesktop, source) + + fromEngine := makeToken(t, time.Now().Add(time.Hour)) + engine.setToken(fromEngine) + expireCache() + + token, source = GetTokenWithSource(t.Context()) + assert.Equal(t, fromEngine, token) + assert.Equal(t, SourceSecretsEngine, source) + }) + + t.Run("a failing engine falls back to Docker Desktop", func(t *testing.T) { + fromDesktop := makeToken(t, time.Now().Add(time.Hour)) + installFakeBackend(t, &fakeBackend{token: fromDesktop}) + installFakeEngine(t, &fakeEngine{err: secrets.ErrAccessDenied}) + + token, source := GetTokenWithSource(t.Context()) + assert.Equal(t, fromDesktop, token) + assert.Equal(t, SourceDesktop, source) + }) + + t.Run("an expiring engine token falls back to Docker Desktop", func(t *testing.T) { + fromDesktop := makeToken(t, time.Now().Add(time.Hour)) + installFakeBackend(t, &fakeBackend{token: fromDesktop}) + installFakeEngine(t, &fakeEngine{token: makeToken(t, time.Now().Add(10*time.Second))}) + + token, source := GetTokenWithSource(t.Context()) + assert.Equal(t, fromDesktop, token) + assert.Equal(t, SourceDesktop, source) + }) + + t.Run("an expired engine token falls through to minting", func(t *testing.T) { + expired := makeToken(t, time.Now().Add(-time.Hour)) + minted := makeToken(t, time.Now().Add(time.Hour)) + backend := &fakeBackend{token: expired} + installFakeBackend(t, backend) + installFakeEngine(t, &fakeEngine{token: expired}) + mintToken = func(context.Context) (string, error) { return minted, nil } + + token, source := GetTokenWithSource(t.Context()) + assert.Equal(t, minted, token) + assert.Equal(t, SourceMinted, source) + assert.Equal(t, 0, backend.refreshes()) + }) + + t.Run("GetUserInfo names the account of an engine token", func(t *testing.T) { + backend := &fakeBackend{token: makeIdentityToken(t, "claims-user", "claims@example.com")} + backend.info = &DockerHubInfo{Username: "desktop-user", Email: "desktop@example.com"} + installFakeBackend(t, backend) + installFakeEngine(t, &fakeEngine{token: makeIdentityToken(t, "engine-user", "engine@example.com")}) + + assert.Equal(t, DockerHubInfo{Username: "engine-user", Email: "engine@example.com"}, GetUserInfo(t.Context())) + }) + + t.Run("a refused engine token is not served again", func(t *testing.T) { + fromEngine := makeToken(t, time.Now().Add(time.Hour)) + fromDesktop := makeToken(t, time.Now().Add(time.Hour)) + installFakeBackend(t, &fakeBackend{token: fromDesktop}) + installFakeEngine(t, &fakeEngine{token: fromEngine}) + require.Equal(t, fromEngine, GetToken(t.Context())) + + InvalidateToken(fromEngine) + + token, source := GetTokenWithSource(t.Context()) + assert.Equal(t, fromDesktop, token) + assert.Equal(t, SourceDesktop, source) + }) +} + +func TestSecretsEngineCooldown(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + err error + }{ + {name: "engine not running", err: errors.New("secrets engine is not running at engine.sock: dial unix engine.sock: connect: no such file or directory")}, + {name: "access denied", err: secrets.ErrAccessDenied}, + {name: "lookup failed", err: errors.New("internal error")}, + } { + t.Run(tt.name+" cools down", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + fake := &fakeEngine{err: tt.err} + engine := newTestEngine(t, fake) + + _, err := engine.fetch(t.Context()) + require.ErrorIs(t, err, tt.err) + lookups := fake.lookupCount() + require.Positive(t, lookups) + + _, err = engine.fetch(t.Context()) + require.ErrorIs(t, err, errSecretsEngineCoolingDown) + assert.Equal(t, lookups, fake.lookupCount(), "a failing engine is left alone for a while") + + endCooldown(engine) + _, err = engine.fetch(t.Context()) + require.ErrorIs(t, err, tt.err) + assert.Greater(t, fake.lookupCount(), lookups, "the engine is asked again after the cooldown") + }) + }) + } + + t.Run("nobody signed in does not cool down", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + fake := &fakeEngine{} + engine := newTestEngine(t, fake) + + token, err := engine.fetch(t.Context()) + require.NoError(t, err) + assert.Empty(t, token) + lookups := fake.lookupCount() + + _, err = engine.fetch(t.Context()) + require.NoError(t, err) + assert.Greater(t, fake.lookupCount(), lookups) + }) + }) + + t.Run("an engine client that can't be created fails the lookup", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + errClient := errors.New("could not create the secrets engine client") + engine := &secretsEngine{clientErr: errClient, logger: slog.New(slog.DiscardHandler)} + + _, err := engine.fetch(t.Context()) + require.ErrorIs(t, err, errClient) + }) + }) +} + +func TestSecretsEngineCallerDeadline(t *testing.T) { + t.Parallel() + + t.Run("an unresponsive engine delays the lookup by its budget only", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + fake := &fakeEngine{block: true} + engine := newTestEngine(t, fake) + + start := time.Now() + _, err := engine.fetch(t.Context()) + require.Error(t, err) + assert.Equal(t, secretsEngineBudget, time.Since(start)) + synctest.Wait() + + lookups := fake.lookupCount() + start = time.Now() + _, err = engine.fetch(t.Context()) + require.ErrorIs(t, err, errSecretsEngineCoolingDown) + assert.Zero(t, time.Since(start), "an unresponsive engine is not waited on again right away") + assert.Equal(t, lookups, fake.lookupCount()) + }) + }) + + t.Run("an unresponsive engine leaves the fallbacks half the caller's time", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + engine := newTestEngine(t, &fakeEngine{block: true}) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + start := time.Now() + _, err := engine.fetch(ctx) + + require.ErrorIs(t, err, errSecretsEngineSlow) + assert.Equal(t, 2500*time.Millisecond, time.Since(start)) + require.NoError(t, ctx.Err(), "the caller has time left for its request") + }) + }) + + t.Run("an engine that outlives its callers still cools down", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + fake := &fakeEngine{block: true} + engine := newTestEngine(t, fake) + + lookUp := func() time.Duration { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + start := time.Now() + _, _ = engine.fetch(ctx) + return time.Since(start) + } + + lookUp() + lookups := fake.lookupCount() + time.Sleep(secretsEngineBudget) //nolint:forbidigo // Fake time: the abandoned lookup times out. + synctest.Wait() + + assert.Zero(t, lookUp(), "the next caller doesn't wait for the engine") + assert.Equal(t, lookups, fake.lookupCount()) + }) + }) + + t.Run("a canceled caller doesn't cancel the lookup for the others", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + fromEngine := makeToken(t, time.Now().Add(time.Hour)) + fake := &fakeEngine{token: fromEngine, delay: time.Second} + engine := newTestEngine(t, fake) + + canceled, cancel := context.WithCancel(t.Context()) + go func() { + time.Sleep(100 * time.Millisecond) //nolint:forbidigo // Fake time: cancel while the lookup runs. + cancel() + }() + _, err := engine.fetch(canceled) + require.ErrorIs(t, err, context.Canceled) + + token, err := engine.fetch(t.Context()) + require.NoError(t, err) + assert.Equal(t, fromEngine, token) + assert.Equal(t, 2, fake.lookupCount(), "one profile and one session read") + }) + }) +} + +func TestSecretsEngineConcurrentLookups(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + fromEngine := makeToken(t, time.Now().Add(time.Hour)) + fake := &fakeEngine{token: fromEngine, delay: time.Second} + engine := newTestEngine(t, fake) + + tokens := make([]string, 32) + errs := make([]error, 32) + var wg sync.WaitGroup + for i := range tokens { + wg.Go(func() { + tokens[i], errs[i] = engine.fetch(t.Context()) + }) + } + wg.Wait() + + require.NoError(t, errors.Join(errs...)) + for _, token := range tokens { + assert.Equal(t, fromEngine, token) + } + + assert.Equal(t, 2, fake.lookupCount(), "concurrent callers share one lookup") + }) +} + +func TestSecretsEngineLastError(t *testing.T) { + t.Parallel() + + t.Run("a failed lookup is recorded until the engine answers", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + fake := &fakeEngine{err: secrets.ErrAccessDenied} + engine := newTestEngine(t, fake) + require.NoError(t, engine.lastError()) + + _, _ = engine.fetch(t.Context()) + require.ErrorIs(t, engine.lastError(), secrets.ErrAccessDenied) + + fake.setErr(nil) + endCooldown(engine) + _, _ = engine.fetch(t.Context()) + require.NoError(t, engine.lastError()) + }) + }) + + t.Run("a slow lookup reports the error it ends with", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + engine := newTestEngine(t, &fakeEngine{block: true}) + + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + _, err := engine.fetch(ctx) + require.ErrorIs(t, err, errSecretsEngineSlow) + + require.ErrorIs(t, engine.lastError(), context.DeadlineExceeded) + }) + }) +} + +func newTestEngine(t *testing.T, fake *fakeEngine) *secretsEngine { + t.Helper() + + engine := &secretsEngine{client: dockerhub.New(fake), logger: slog.New(slog.DiscardHandler)} + t.Cleanup(engine.wait) + return engine +} + +func installNoSecretsEngine(t *testing.T) { + t.Helper() + installFakeEngine(t, &fakeEngine{}) +} + +func installFakeEngine(t *testing.T, fake *fakeEngine) { + t.Helper() + + engine := newTestEngine(t, fake) + old := defaultSecretsEngine + defaultSecretsEngine = func() *secretsEngine { return engine } + t.Cleanup(func() { defaultSecretsEngine = old }) +} + +func endCooldown(engine *secretsEngine) { + engine.mu.Lock() + defer engine.mu.Unlock() + engine.nextAttempt = time.Time{} +} + +type fakeEngine struct { + mu sync.Mutex + token string + err error + block bool + delay time.Duration + lookups int +} + +func (e *fakeEngine) setToken(token string) { + e.mu.Lock() + defer e.mu.Unlock() + e.token = token +} + +func (e *fakeEngine) setErr(err error) { + e.mu.Lock() + defer e.mu.Unlock() + e.err = err +} + +func (e *fakeEngine) lookupCount() int { + e.mu.Lock() + defer e.mu.Unlock() + return e.lookups +} + +func (e *fakeEngine) GetSecrets(ctx context.Context, pattern secrets.Pattern) ([]secrets.Envelope, error) { + e.mu.Lock() + e.lookups++ + token, err, block, delay := e.token, e.err, e.block, e.delay + e.mu.Unlock() + + if block { + <-ctx.Done() + return nil, ctx.Err() + } + if delay > 0 { + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if err != nil { + return nil, err + } + if token == "" { + return nil, secrets.ErrNotFound + } + + var value any + switch pattern.String() { + case "docker/auth/metadata/hub/default": + value = dockerhub.Profile{UserID: "docker/auth/hub/testuser", Username: "testuser"} + case "docker/auth/hub/testuser": + value = dockerhub.UserSession{AccessToken: token} + default: + return nil, secrets.ErrNotFound + } + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + return []secrets.Envelope{{ID: secrets.MustParseID(pattern.String()), Value: data}}, nil +} diff --git a/pkg/hubauth/exchange.go b/pkg/hubauth/exchange.go index 150310f84f..f9a60b5225 100644 --- a/pkg/hubauth/exchange.go +++ b/pkg/hubauth/exchange.go @@ -88,6 +88,21 @@ func isDockerHost(host string) bool { return host == "docker.com" || strings.HasSuffix(host, ".docker.com") } +// Staging reports whether the token exchange targets a Docker Hub staging host. +func Staging() bool { + return isStagingEndpoint(loginEndpoint()) +} + +// isStagingEndpoint reports whether endpoint's host is a "*-stage" Docker host. +func isStagingEndpoint(endpoint string) bool { + u, err := url.Parse(endpoint) + if err != nil || !isDockerHost(u.Hostname()) { + return false + } + label, _, _ := strings.Cut(u.Hostname(), ".") + return strings.HasSuffix(label, "-stage") +} + func exchangeDisabled() bool { switch strings.ToLower(os.Getenv(envNoExchange)) { case "", "0", "false": diff --git a/pkg/hubauth/exchange_test.go b/pkg/hubauth/exchange_test.go index c70ca23bab..4f2624f6f8 100644 --- a/pkg/hubauth/exchange_test.go +++ b/pkg/hubauth/exchange_test.go @@ -188,3 +188,25 @@ func TestLoginEndpointOverride(t *testing.T) { }) } } + +func TestIsStagingEndpoint(t *testing.T) { + tests := []struct { + endpoint string + want bool + }{ + {endpoint: defaultLoginEndpoint, want: false}, + {endpoint: "https://hub-stage.docker.com/v2/users/login", want: true}, + {endpoint: "https://login-stage.docker.com/v2/users/login", want: true}, + {endpoint: "https://hub.docker.com/hub-stage/v2/users/login", want: false}, + {endpoint: "https://hub-stage.example.com/v2/users/login", want: false}, + {endpoint: "https://hub-stage.docker.com.example.com/login", want: false}, + {endpoint: "http://127.0.0.1:1234/v2/users/login", want: false}, + {endpoint: "://not a url", want: false}, + } + + for _, tt := range tests { + t.Run(tt.endpoint, func(t *testing.T) { + assert.Equal(t, tt.want, isStagingEndpoint(tt.endpoint)) + }) + } +}