Skip to content

feat(desktop): prefer the secrets engine for Docker Hub authentication - #4535

Open
Benehiko wants to merge 16 commits into
docker:mainfrom
Benehiko:feat/secrets-engine-auth
Open

Benehiko wants to merge 16 commits into
docker:mainfrom
Benehiko:feat/secrets-engine-auth

Conversation

@Benehiko

@Benehiko Benehiko commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Warning

Deprecation notice: Docker Desktop's /registry/token endpoint is being deprecated. It is retained here only as a fallback while the secrets-engine SDK becomes the primary source of the Docker Hub session, and will be removed in a future change.

What

docker-agent now reads the default Docker Hub session from the Docker secrets engine before it asks Docker Desktop's backend API for a token. This matches docker/gordon#2510 and docker/cloud#304.

When the Docker Desktop source of the environment chain needs DOCKER_TOKEN, it tries these in order:

  1. Secrets engine (new): HubAuth().GetDefaultSession() over Docker Desktop's engine socket (api.DesktopSocketPath()). The SDK defaults to the standalone daemon socket, which Desktop doesn't serve; this is the same fix as in docker/cloud#304.
  2. Docker Desktop /registry/token (unchanged).
  3. A token minted from the access token docker login stored (unchanged).
  4. A forced Docker Desktop refresh (unchanged).

Also:

  • Staging: when DOCKER_AGENT_HUB_LOGIN_URL points the token exchange at a Docker Hub staging host (*-stage.docker.com), the engine's staging realms are read instead (dockerhub.Staging()).
  • GetUserInfo now names the account of the token in use, read from its claims, so it matches the account requests are sent as. Docker Desktop's account is the fallback for a token without one, such as a personal access token.
  • docker agent debug auth reports the new source as docker desktop secrets engine and, when the engine couldn't be read, says why with the SDK's hint (see below).

Other credentials (CI etc.)

  • The engine never shadows an explicit credential. It is only reached through the docker-desktop source at the end of the environment chain. A DOCKER_TOKEN from the environment, run secrets, an env file or a credential helper still wins, and the engine isn't asked.
  • Machines without the engine behave as before. On CI runners, Linux without Docker Desktop, or older Desktop releases, the dial fails straight away and the existing sources take over. A missing engine (the SDK's ErrSecretsEngineNotRunning) is logged at debug level only. Other failures (permission denied, a timeout, access denied, a session that can't be decoded) log a warning, only for the first failure in a run, so a long-running process doesn't repeat it after every cooldown.
  • The engine can't hold up requests. Each lookup is capped at 5 s on its own context, so the caller's context is still intact for the fallbacks. Concurrent callers share one lookup, and a caller waits at most half its remaining time for it. After a failure the engine is skipped for 30 s. "Nobody signed in" (dockerhub.ErrNoSession) is a clean miss with no cooldown, so a new sign-in is picked up right away.
  • Tokens are vetted like the others. An expiring engine token, or one Docker already rejected (InvalidateToken), is never served. It falls through so minting or the forced refresh can replace it. Engine tokens share the existing 30 s in-memory cache.
  • Only built where the SDK is. The engine code builds on darwin, linux and windows. Elsewhere, js/wasm included, the engine step finds no token and the existing sources take over.

How a failure shows up

A failed lookup never fails a request: the next source is tried. Without --debug the CLI discards logs, so debug auth is where a user sees why the engine was skipped. It prints the SDK's ConnectError reason, socket and hint, falling back to the full error when there is no known reason. An engine that isn't running is only reported when no token was found, since machines without Docker Desktop don't have one.

With Docker Desktop quit and minting disabled:

$ DOCKER_AGENT_NO_TOKEN_EXCHANGE=1 docker agent debug auth
No token found. Is Docker Desktop running, or are you logged in with `docker login`?

Secrets engine: not running
Socket:         /Users/benehiko/Library/Caches/docker-secrets-engine/engine.sock
Hint:           Start Docker Desktop and retry: the secrets engine runs as part of it.

When another source supplied the token, other failures are still reported. For example, when the user can't open the socket:

$ docker agent debug auth
Token:      eyJhbGciOi...
Source:     docker desktop
...
Status:     ✅ Valid

Secrets engine: permission denied
Socket:         /Users/benehiko/Library/Caches/docker-secrets-engine/engine.sock
Hint:           Your user lacks permission to connect to "/Users/benehiko/Library/Caches/docker-secrets-engine/engine.sock". Grant it read and write access to the socket and retry.

The JSON output adds a secrets_engine object that also keeps the full error:

$ DOCKER_AGENT_NO_TOKEN_EXCHANGE=1 docker agent debug auth --json | jq
{
  "error": "no token found (is Docker Desktop running, or are you logged in with `docker login`?)",
  "secrets_engine": {
    "error": "retrieve default account metadata: secrets engine is not running at /Users/benehiko/Library/Caches/docker-secrets-engine/engine.sock: unavailable: dial unix /Users/benehiko/Library/Caches/docker-secrets-engine/engine.sock: connect: no such file or directory",
    "reason": "not running",
    "socket": "/Users/benehiko/Library/Caches/docker-secrets-engine/engine.sock",
    "hint": "Start Docker Desktop and retry: the secrets engine runs as part of it."
  }
}

With --debug, the debug log records the same failure, with the hint in a hint= field.

Changes

  • go.mod/go.sum: github.com/docker/secrets-engine/client v0.2.0 and github.com/docker/secrets-engine/x v0.8.3. v0.2.0 is the first release with ErrSecretsEngineNotRunning (feat(client)!: report why the client could not connect to the engine secrets-engine#677), and it reports failed connections as a ConnectError carrying the reason, socket and a hint. The only other new module is connectrpc.com/connect, pulled in indirectly.
  • pkg/desktop/secrets_engine.go (darwin, linux, windows): the secretsEngine type, which owns the SDK client and handles the lookup, its time cap, shared lookups, the cooldown and failure logging, plus desktop.SecretsEngineError() for debug auth.
  • pkg/desktop/secrets_engine_other.go: the stub everywhere else.
  • pkg/desktop/login.go: the engine step, the SourceSecretsEngine label, and GetUserInfo naming the account of the token in use.
  • pkg/hubauth/exchange.go: Staging(), which picks the engine's staging realms.
  • cmd/root/debug_auth.go: why the engine supplied no token, in the text and JSON output.
  • Docs: guides/secrets (Docker Authentication), features/cli (debug auth sources and secrets engine fields), and configuration/overview (staging sessions for DOCKER_AGENT_HUB_LOGIN_URL).

Testing

  • pkg/desktop/secrets_engine_test.go runs the real dockerhub accessor against a fake resolver.
    • Engine tests build their own secretsEngine with a fake client and run with t.Parallel(). They cover: the cooldown after a missing engine, access denied or a lookup failure; no cooldown when nobody is signed in; a client that can't be created; the 5 s budget and the half-deadline rule; a lookup that outlives its callers; a canceled caller; concurrent callers sharing one lookup; and recording the last lookup error.
    • Tests through GetTokenWithSource cover: engine preferred; cached; signed out, then signing in; a failing engine falling back; expiring token; expired token falling through to minting; rejected token; and GetUserInfo naming the engine token's account.
  • pkg/desktop/secrets_engine_logging_test.go checks the log level for each kind of failure, that connection failures carry the SDK's hint, and that other failures warn once per run.
  • cmd/root/debug_auth_test.go checks which failures debug auth reports, with their reason, socket and hint, and how they print.
  • pkg/hubauth/exchange_test.go checks staging host detection.
  • The existing pkg/desktop tests install a signed-out fake engine, so they never reach a developer's own Docker Desktop.
  • go test -race passes for pkg/desktop, pkg/environment, pkg/hubauth, pkg/sandbox and cmd/root.
  • golangci-lint reports no issues in this branch's changes; go run ./lint . and the tidy check pass.
  • Builds pass for native, linux/arm64, windows/amd64, and js/wasm with and without docker_agent_wasm_cloud.
  • Manually: debug auth with Docker Desktop quit, as shown above.

Open question

docker/cloud#304 counts engine failures as an OTel secrets_engine_auth_failure metric; docker/gordon#2510 doesn't. This PR logs (debug when no engine is running, a warning for the first other failure in a run) and shows the failure in debug auth. It sends no telemetry. Happy to add an event if we want that signal.

Read the default Docker Hub session from the secrets engine that Docker Desktop serves on its engine socket before asking the Desktop backend API, as in docker/gordon#2510 and docker/cloud#304. The Desktop /registry/token endpoint, the token minted from the docker login access token and the forced Desktop refresh remain fallbacks, in that order.

The engine only adds a source: it sits behind the docker-desktop source at the end of the environment chain, so a DOCKER_TOKEN from the environment, run secrets, env files or a credential helper (CI) still wins. Lookups are bounded to 5s on their own context, a failing engine is skipped for 30s, and a missing session is a clean miss. Platforms the SDK does not support, js/wasm included, get a stub.
@aheritier aheritier added area/cli CLI commands, flags, output formatting kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Oct 7, 2026
@dgageot
dgageot self-requested a review October 7, 2026 11:39
A missing engine is routine on machines without Docker Desktop and stays at debug level, but other failures, such as access being denied, now log a warning. Only the first failure in a run warns, so a long-lived process doesn't repeat it after every cooldown; the next run starts once the engine answers again. Telling a missing engine apart needs the SDK's client package, so the check lives in the build-tagged files and js/wasm still builds.

Also: the lookup budget and cooldown are consts, the cooldown state goes through small helpers, a canceled caller keeps the engine's error, a token refused mid-lookup is no longer returned with ok=false, and a test that replaced hubAuth now restores it itself. The GetTokenWithSource comment and the Docker authentication docs list the token sources in order.
@Benehiko
Benehiko marked this pull request as ready for review October 7, 2026 13:23
@Benehiko
Benehiko requested a review from a team as a code owner October 7, 2026 13:23
Comment thread pkg/desktop/secrets_engine_client.go Outdated

@dgageot dgageot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cache hits add no socket work. On a cache miss, an absent engine adds a failed dial; a usable engine session needs two sequential RPCs instead of the previous single token request; a stalled engine can add five seconds. The deadline interaction below is a concrete regression. Reproduced the deadline, concurrent-probe, and identity cases with temporary tests; desktop race tests pass. The existing socket-error classification comment already covers the logging issue.

Comment thread pkg/desktop/secrets_engine.go Outdated
Comment thread pkg/desktop/secrets_engine.go Outdated
Comment thread pkg/desktop/login.go
Comment thread pkg/desktop/secrets_engine_client.go Outdated
Comment thread docs/guides/secrets/index.md Outdated
…fall back

The engine lookup's five-second budget was derived from the caller's
context, so it could only shrink: gateway model discovery gives auth and
its request five seconds in all, and a stalled engine used all of it
before /registry/token could run. That caller had then given up, so the
cooldown was skipped and every discovery stalled again. Concurrent cache
misses also each probed the engine before any failure started the
cooldown.

Callers now share one lookup that runs detached on its own budget, the
way forced refreshes already do. Each caller waits at most half of its
remaining time, capped at the budget, then falls back. The lookup always
finishes, so a stalled engine starts the cooldown even when every caller
left, and logs its failure once rather than once per caller. The docs no
longer claim the engine never holds up model requests.
The SDK wraps every failed dial in ErrSecretsEngineNotAvailable,
permission denied and timeouts included, so an engine we may not connect
to was logged at debug level as if Docker Desktop were not installed.
Only a missing socket or one nothing listens on is routine now; anything
else gets the warning. Tests build the SDK's real error shape instead of
wrapping a string. docker/secrets-engine#677 adds
ErrSecretsEngineUnreachable to do this in the SDK.
GetUserInfo asked Docker Desktop's /registry/info first and only read the
token's claims when that failed, while GetToken now prefers the secrets
engine. Read the account from the claims of the token GetToken serves, so
`debug auth`, DOCKER_USERNAME and DOCKER_EMAIL name the account requests
are sent as. /registry/info remains the fallback for a token that carries
no account, such as a personal access token some Docker Desktop releases
serve.
The engine client read the production Docker Hub realms only, so a
staging setup was served a production session or none. When
DOCKER_AGENT_HUB_LOGIN_URL points the token exchange at a staging host,
such as hub-stage.docker.com, read the staging realms instead.

Docker Desktop doesn't say which environment it is signed in to, so a
Desktop on staging still needs that variable; the godoc and docs say so.
@Benehiko

Benehiko commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Thanks both. I've pushed fixes for all the review threads, with replies inline:

On the latency summary: agreed on the numbers. On a cache miss, a usable engine session takes two sequential RPCs where there was one request before. The 30s token cache keeps misses rare, so I've left that as is. A stalled engine is now bounded by half the caller's remaining time, and it starts the cooldown.

@aheritier aheritier added area/deps Dependency updates and version bumps area/docs Documentation changes labels Oct 8, 2026
@Benehiko
Benehiko requested review from aheritier and dgageot October 8, 2026 10:44
…tRunning

Bump github.com/docker/secrets-engine/client to v0.2.0, which reports failed dials as a ConnectError carrying the reason. Match ErrSecretsEngineNotRunning instead of ENOENT and ECONNREFUSED ourselves, drop the now unused errno files, and replace the secretsEngineUnavailable helper with a per-platform errNoSecretsEngine sentinel.
@Benehiko
Benehiko force-pushed the feat/secrets-engine-auth branch from affd741 to a381ab6 Compare October 12, 2026 06:42
A failed connection to the engine is a secrets-engine ConnectError, which knows how to recover from it, such as starting Docker Desktop. Log its Hint() alongside the error, and log through a single slog.Log call.
Shorten godocs to a single line, drop comments inside function bodies, and remove comments from the new test files.
Move the lookup, cooldown and logging state into a secretsEngine struct that owns its client and logger. Tests build their own engine with a fake client instead of swapping package globals, so the engine tests run with t.Parallel. Drop the hubAuth and hubStaging vars; GetTokenWithSource reads a lazily created default engine.
… built

Call hubauth.Staging() in newSecretsEngineHubAuth instead of passing the choice through secretsEngineHubOptions. Drop the helper and its test, which only exercised the SDK's dockerhub.Staging().
Put secrets_engine.go behind the darwin/linux/windows build tag so it uses the SDK directly: ErrSecretsEngineNotRunning, ConnectError.Hint and the client constructor. This drops the errNoSecretsEngine, secretsEngineHint and newSecretsEngineHubAuth wrappers. Other platforms get a single secretsEngineToken stub that finds no token.
The default engine stored slog.Default() when it was created, so it kept logging to that logger even after the default changed, as eval's temporary swap does. Fall back to slog.Default() at log time instead; tests still pass in their own logger.
Record the last secrets engine lookup error and expose it as desktop.SecretsEngineError. debug auth prints it with the SDK's ConnectError hint, in the text output and as secrets_engine in the JSON. A secrets engine that isn't running is left out when another source supplied the token.
…ate lines

debug auth printed the secrets engine's full error chain, which repeated the socket path and named the internal step that failed. Print the SDK's ConnectError reason, socket path and hint on their own lines instead, falling back to the full error when there is no known reason. The JSON output gains reason and socket and keeps the full error.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cli CLI commands, flags, output formatting area/deps Dependency updates and version bumps area/docs Documentation changes kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants