From 7de0fa038eef5875e87bf36428c260cf0c21f35b Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 6 Aug 2026 12:31:12 +0200 Subject: [PATCH 1/4] feat(auth): mint Docker tokens from the stored access token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker Desktop's backend API only hands out its own access token — valid for 15 minutes — and never the refresh token behind it, so an expired JWT cannot be renewed: a stuck refresher on Desktop's side leaves every caller with the same dead token. The access token `docker login` (and Desktop's own sign-in) leaves in the credential store is long-lived, and Docker Hub exchanges it for a fresh token with no user interaction. This package owns that exchange: an in-memory token renewed ahead of its expiry, credential re-checks so a logout or an account switch is noticed, a shared cache so sibling processes don't each mint their own, retries for transient failures (honouring Retry-After), a long back-off when the token is refused, issuer and audience validation, and clock-skew correction learned from Hub's Date header so expiry decisions follow the issuer's clock rather than a drifting local one. The access token itself never leaves the process except in the exchange request: the endpoint is pinned to a Docker host, redirects are not followed, and account passwords are never sent. Set DOCKER_AGENT_NO_TOKEN_EXCHANGE to opt out entirely. --- pkg/hubauth/cache.go | 78 ++++++++++++ pkg/hubauth/cache_test.go | 108 ++++++++++++++++ pkg/hubauth/clock.go | 39 ++++++ pkg/hubauth/clock_test.go | 65 ++++++++++ pkg/hubauth/credentials.go | 50 ++++++++ pkg/hubauth/exchange.go | 232 ++++++++++++++++++++++++++++++++++ pkg/hubauth/exchange_test.go | 167 +++++++++++++++++++++++++ pkg/hubauth/expiry.go | 57 +++++++++ pkg/hubauth/helpers_test.go | 169 +++++++++++++++++++++++++ pkg/hubauth/identity.go | 40 ++++++ pkg/hubauth/identity_test.go | 54 ++++++++ pkg/hubauth/token.go | 227 ++++++++++++++++++++++++++++++++++ pkg/hubauth/token_test.go | 233 +++++++++++++++++++++++++++++++++++ 13 files changed, 1519 insertions(+) create mode 100644 pkg/hubauth/cache.go create mode 100644 pkg/hubauth/cache_test.go create mode 100644 pkg/hubauth/clock.go create mode 100644 pkg/hubauth/clock_test.go create mode 100644 pkg/hubauth/credentials.go create mode 100644 pkg/hubauth/exchange.go create mode 100644 pkg/hubauth/exchange_test.go create mode 100644 pkg/hubauth/expiry.go create mode 100644 pkg/hubauth/helpers_test.go create mode 100644 pkg/hubauth/identity.go create mode 100644 pkg/hubauth/identity_test.go create mode 100644 pkg/hubauth/token.go create mode 100644 pkg/hubauth/token_test.go diff --git a/pkg/hubauth/cache.go b/pkg/hubauth/cache.go new file mode 100644 index 000000000..d2858b5a9 --- /dev/null +++ b/pkg/hubauth/cache.go @@ -0,0 +1,78 @@ +package hubauth + +import ( + "bytes" + "encoding/json" + "log/slog" + "os" + "path/filepath" + + "github.com/docker/docker-agent/pkg/atomicfile" + "github.com/docker/docker-agent/pkg/paths" +) + +// Minted tokens are shared between docker-agent processes through a file in +// the cache directory: a `docker agent` invocation, the MCP server it spawns +// and a sandbox helper all authenticate as the same user, and re-exchanging the +// PAT in each of them costs a credential-helper exec plus a round-trip to Hub. +// +// The file holds a bearer token, so it is owner-only inside an owner-only +// directory, and it is tied to a fingerprint of the credentials that minted it: +// after a `docker logout` or an account switch, the entry is simply ignored. +// Every failure here is non-fatal — the token is re-minted instead. + +type cacheEntry struct { + Credentials string `json:"credentials"` + Token string `json:"token"` +} + +func cachePath() string { + return filepath.Join(paths.GetCacheDir(), "hub-token.json") +} + +// load returns a token minted from the given credentials by this or another +// process, when one is cached and not due for renewal. +func load(credHash string) (string, bool) { + data, err := os.ReadFile(cachePath()) + if err != nil { + return "", false + } + var entry cacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return "", false + } + if entry.Credentials != credHash || entry.Token == "" { + return "", false + } + if !now().Before(renewAt(entry.Token)) { + return "", false + } + return entry.Token, true +} + +// store publishes a minted token for other processes to reuse. +func store(credHash, token string) { + data, err := json.Marshal(cacheEntry{Credentials: credHash, Token: token}) + if err != nil { + return + } + + path := cachePath() + // 0700 on the directory keeps the token unreadable during the window + // between atomicfile's rename and its chmod. + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + slog.Debug("Could not create the Docker token cache directory", "error", err) + return + } + if err := atomicfile.Write(path, bytes.NewReader(data), 0o600); err != nil { + slog.Debug("Could not cache the Docker token", "error", err) + } +} + +// forget removes the shared token, so no process keeps using one that this one +// found to be unusable. +func forget() { + if err := os.Remove(cachePath()); err != nil && !os.IsNotExist(err) { + slog.Debug("Could not remove the cached Docker token", "error", err) + } +} diff --git a/pkg/hubauth/cache_test.go b/pkg/hubauth/cache_test.go new file mode 100644 index 000000000..7cad4f9fd --- /dev/null +++ b/pkg/hubauth/cache_test.go @@ -0,0 +1,108 @@ +package hubauth + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSharedCache(t *testing.T) { + t.Run("round-trips a token", func(t *testing.T) { + resetState(t) + token := longLived(t) + + store("fingerprint", token) + got, ok := load("fingerprint") + require.True(t, ok) + assert.Equal(t, token, got) + }) + + t.Run("ignores another account's token", func(t *testing.T) { + resetState(t) + + store("fingerprint", longLived(t)) + _, ok := load("other-fingerprint") + assert.False(t, ok) + }) + + t.Run("ignores a token due for renewal", func(t *testing.T) { + resetState(t) + + store("fingerprint", makeToken(t, time.Now().Add(renewBefore/2))) + _, ok := load("fingerprint") + assert.False(t, ok) + }) + + t.Run("survives a missing or corrupt file", func(t *testing.T) { + resetState(t) + + _, ok := load("fingerprint") + assert.False(t, ok) + + require.NoError(t, os.WriteFile(cachePath(), []byte("{not json"), 0o600)) + _, ok = load("fingerprint") + assert.False(t, ok) + }) + + t.Run("is owner-only", func(t *testing.T) { + resetState(t) + store("fingerprint", longLived(t)) + + info, err := os.Stat(cachePath()) + require.NoError(t, err) + if os.Getenv("GOOS") != "windows" { + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + } + }) + + t.Run("forget removes the file", func(t *testing.T) { + resetState(t) + store("fingerprint", longLived(t)) + + forget() + _, err := os.Stat(cachePath()) + assert.True(t, os.IsNotExist(err)) + + forget() // idempotent + }) +} + +func TestTokenReusesAnotherProcessesToken(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + shared := longLived(t) + store(fingerprint("bob", testToken), shared) + + token, err := Token(t.Context()) + require.NoError(t, err) + assert.Equal(t, shared, token) + assert.Empty(t, hub.received(), "no exchange needed") +} + +func TestTokenPublishesForOtherProcesses(t *testing.T) { + installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + token, err := Token(t.Context()) + require.NoError(t, err) + + cached, ok := load(fingerprint("bob", testToken)) + require.True(t, ok) + assert.Equal(t, token, cached) +} + +func TestInvalidateRemovesTheSharedToken(t *testing.T) { + installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + token, err := Token(t.Context()) + require.NoError(t, err) + Invalidate(token) + + _, ok := load(fingerprint("bob", testToken)) + assert.False(t, ok, "other processes must not keep using a rejected token") +} diff --git a/pkg/hubauth/clock.go b/pkg/hubauth/clock.go new file mode 100644 index 000000000..aabfd9c59 --- /dev/null +++ b/pkg/hubauth/clock.go @@ -0,0 +1,39 @@ +package hubauth + +import ( + "net/http" + "sync/atomic" + "time" +) + +// skewThreshold is how far our clock must differ from Docker's before we +// correct for it: smaller differences are dominated by request latency and the +// one-second resolution of the Date header. +const skewThreshold = 5 * time.Second + +// clockSkew is how far this machine's clock is behind Docker's, in +// nanoseconds. A machine resuming from sleep, or a VM with a drifting clock, +// can be minutes off — enough to make every fresh token look expired (or a +// dead one look valid) and to defeat every expiry decision below. +var clockSkew atomic.Int64 + +// now returns the current time as Docker sees it. +func now() time.Time { + return time.Now().Add(time.Duration(clockSkew.Load())) +} + +// learnClockSkew records how far our clock is from the one of the server that +// issues our tokens. +func learnClockSkew(header http.Header) { + date, err := http.ParseTime(header.Get("Date")) + if err != nil { + return + } + + skew := time.Until(date) + if skew > -skewThreshold && skew < skewThreshold { + clockSkew.Store(0) + return + } + clockSkew.Store(int64(skew)) +} diff --git a/pkg/hubauth/clock_test.go b/pkg/hubauth/clock_test.go new file mode 100644 index 000000000..f50b6e861 --- /dev/null +++ b/pkg/hubauth/clock_test.go @@ -0,0 +1,65 @@ +package hubauth + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLearnClockSkew(t *testing.T) { + t.Run("ignores small differences", func(t *testing.T) { + clockSkew.Store(0) + t.Cleanup(func() { clockSkew.Store(0) }) + + learnClockSkew(dateHeader(time.Now().Add(time.Second))) + assert.Zero(t, clockSkew.Load()) + assert.WithinDuration(t, time.Now(), now(), time.Second) + }) + + t.Run("corrects a clock that is behind", func(t *testing.T) { + clockSkew.Store(0) + t.Cleanup(func() { clockSkew.Store(0) }) + + learnClockSkew(dateHeader(time.Now().Add(time.Hour))) + assert.WithinDuration(t, time.Now().Add(time.Hour), now(), 5*time.Second) + }) + + t.Run("corrects a clock that is ahead", func(t *testing.T) { + clockSkew.Store(0) + t.Cleanup(func() { clockSkew.Store(0) }) + + learnClockSkew(dateHeader(time.Now().Add(-time.Hour))) + assert.WithinDuration(t, time.Now().Add(-time.Hour), now(), 5*time.Second) + }) + + t.Run("ignores a missing or unparseable header", func(t *testing.T) { + clockSkew.Store(int64(time.Minute)) + t.Cleanup(func() { clockSkew.Store(0) }) + + learnClockSkew(http.Header{}) + learnClockSkew(http.Header{"Date": []string{"nonsense"}}) + assert.Equal(t, int64(time.Minute), clockSkew.Load(), "an unusable header leaves the known skew alone") + }) +} + +// TestExpiryDecisionsFollowTheIssuersClock covers the reason clock skew is +// tracked: a badly skewed machine would otherwise consider every fresh token +// expired. +func TestExpiryDecisionsFollowTheIssuersClock(t *testing.T) { + clockSkew.Store(0) + t.Cleanup(func() { clockSkew.Store(0) }) + + // Our clock runs an hour ahead of Docker's, so a token just issued with ten + // minutes of life looks long dead. + token := makeToken(t, time.Now().Add(-time.Hour+10*time.Minute)) + assert.True(t, Expiring(token)) + + learnClockSkew(dateHeader(time.Now().Add(-time.Hour))) + assert.False(t, Expiring(token), "once the skew is known, the token is fine") +} + +func dateHeader(at time.Time) http.Header { + return http.Header{"Date": []string{at.UTC().Format(http.TimeFormat)}} +} diff --git a/pkg/hubauth/credentials.go b/pkg/hubauth/credentials.go new file mode 100644 index 000000000..af4496829 --- /dev/null +++ b/pkg/hubauth/credentials.go @@ -0,0 +1,50 @@ +package hubauth + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "github.com/docker/cli/cli/config" +) + +const ( + // indexServer is the credential store key `docker login` uses for Hub. + indexServer = "https://index.docker.io/v1/" + + // tokenPrefix marks a stored secret as a Docker-issued access token + // (dckr_pat_, dckr_oat_, ...). Anything else is an account password: 2FA + // can make it unusable here and it is too sensitive to send around, so we + // never exchange it. + tokenPrefix = "dckr_" +) + +// isAccessToken reports whether secret is a Docker access token rather than a +// password. +func isAccessToken(secret string) bool { + return strings.HasPrefix(secret, tokenPrefix) +} + +// dockerConfigCredentials reads the Hub credentials from the Docker CLI +// config, going through the configured credential helper when there is one. +// +// A helper that answers with an identity token instead of a password is of no +// use here: that token authenticates to the registry, not to Hub. +func dockerConfigCredentials() (username, secret string, err error) { + cfg, err := config.Load(config.Dir()) + if err != nil { + return "", "", fmt.Errorf("loading Docker CLI config: %w", err) + } + auth, err := cfg.GetAuthConfig(indexServer) + if err != nil { + return "", "", fmt.Errorf("reading Docker credentials: %w", err) + } + return auth.Username, auth.Password, nil +} + +// fingerprint identifies a credential pair without keeping it in memory. +func fingerprint(username, secret string) string { + sum := sha256.Sum256([]byte(username + "\x00" + secret)) + return hex.EncodeToString(sum[:]) +} diff --git a/pkg/hubauth/exchange.go b/pkg/hubauth/exchange.go new file mode 100644 index 000000000..e75bb21b2 --- /dev/null +++ b/pkg/hubauth/exchange.go @@ -0,0 +1,232 @@ +package hubauth + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math/rand/v2" + "net/http" + "net/url" + "os" + "slices" + "strings" + "sync" + "time" + + "github.com/docker/docker-agent/pkg/version" +) + +const ( + // defaultLoginEndpoint is Docker Hub's token exchange endpoint: the same + // one `docker login` uses. + defaultLoginEndpoint = "https://hub.docker.com/v2/users/login" + + // envLoginURL overrides the exchange endpoint (for staging). Restricted to + // HTTPS Docker hosts so it cannot be used to harvest the PAT. + envLoginURL = "DOCKER_AGENT_HUB_LOGIN_URL" + + // envNoExchange opts out of minting entirely, for users who would rather + // docker-agent didn't use their stored access token. + envNoExchange = "DOCKER_AGENT_NO_TOKEN_EXCHANGE" + + // expectedAudience and trustedIssuer are the claims a token must carry to + // be worth caching: a response that isn't a Docker-issued Hub token means + // we're not talking to Docker. + expectedAudience = "https://hub.docker.com" + + // maxAttempts bounds how often a single mint retries a transient failure. + maxAttempts = 3 + + // maxRetryAfter is the longest server-requested delay we sit through; past + // that we give up and let the caller's cooldown handle it. + maxRetryAfter = 3 * time.Second +) + +// trustedIssuers are the Docker services that issue tokens for Hub. +var trustedIssuers = []string{"https://api.docker.com/", "https://login.docker.com/"} + +// errRejected means Docker refused the stored access token: it was revoked, +// or it never had access. Retrying won't help until the user signs in again. +var errRejected = errors.New("the stored access token was refused, sign in again with `docker login`") + +// errTransient marks a failure worth retrying (network trouble, rate limits, +// server errors). +var errTransient = errors.New("temporary failure") + +// Overridable for tests, which must neither read the developer's credential +// store nor reach the real Hub. +var ( + lookupCredentials = dockerConfigCredentials + httpClient = &http.Client{ + // A redirect would resend the PAT to another host. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +) + +// loginEndpoint resolves the exchange endpoint once per process. +var loginEndpoint = sync.OnceValue(resolveLoginEndpoint) + +func resolveLoginEndpoint() string { + override := os.Getenv(envLoginURL) + if override == "" { + return defaultLoginEndpoint + } + if u, err := url.Parse(override); err != nil || u.Scheme != "https" || !isDockerHost(u.Hostname()) { + slog.Warn("Ignoring "+envLoginURL+": not an HTTPS docker.com URL", "url", override) + return defaultLoginEndpoint + } + return override +} + +func isDockerHost(host string) bool { + return host == "docker.com" || strings.HasSuffix(host, ".docker.com") +} + +func exchangeDisabled() bool { + switch strings.ToLower(os.Getenv(envNoExchange)) { + case "", "0", "false": + return false + default: + return true + } +} + +// exchangeWithRetry trades the PAT for a token, retrying transient failures +// within the caller's budget. +func exchangeWithRetry(ctx context.Context, username, secret string) (string, error) { + var err error + for attempt := 1; ; attempt++ { + var token string + var retryAfter time.Duration + token, retryAfter, err = exchange(ctx, username, secret) + if err == nil { + return token, nil + } + if !errors.Is(err, errTransient) || attempt == maxAttempts { + return "", err + } + + delay := retryDelay(attempt, retryAfter) + if delay == 0 { + return "", err + } + slog.DebugContext(ctx, "Retrying the Docker token exchange", "in", delay, "error", err) + select { + case <-time.After(delay): + case <-ctx.Done(): + return "", errors.Join(err, ctx.Err()) + } + } +} + +// retryDelay returns how long to wait before the next attempt, or 0 to give up +// (a server asking for more than [maxRetryAfter] wants us gone). +func retryDelay(attempt int, retryAfter time.Duration) time.Duration { + if retryAfter > 0 { + if retryAfter > maxRetryAfter { + return 0 + } + return retryAfter + } + // Exponential with jitter, so concurrent processes don't retry in lockstep. + base := time.Duration(1<= 500: + return fmt.Errorf("%w: exchanging access token: HTTP %d", errTransient, status) + default: + return fmt.Errorf("exchanging access token: HTTP %d", status) + } +} + +// retryAfterFrom reads the Retry-After header, in either of its two forms. +func retryAfterFrom(header http.Header) time.Duration { + value := header.Get("Retry-After") + if value == "" { + return 0 + } + if seconds, err := time.ParseDuration(value + "s"); err == nil { + return max(seconds, 0) + } + if date, err := http.ParseTime(value); err == nil { + return max(date.Sub(now()), 0) + } + return 0 +} + +// validate rejects an exchange result we shouldn't cache: an empty token, one +// that isn't a Docker-issued Hub token, or one that is already due for renewal +// (accepting it would exchange the PAT again on the very next call). +func validate(token string) error { + if token == "" { + return errors.New("token exchange returned no token") + } + claims, err := parseClaims(token) + if err != nil { + return fmt.Errorf("token exchange returned an unreadable token: %w", err) + } + issuer, _ := claims.GetIssuer() + if !slices.Contains(trustedIssuers, issuer) { + return fmt.Errorf("token exchange returned a token from an unexpected issuer %q", issuer) + } + audience, _ := claims.GetAudience() + if !slices.Contains(audience, expectedAudience) { + return fmt.Errorf("token exchange returned a token for an unexpected audience %q", audience) + } + if !now().Before(renewAt(token)) { + return errors.New("token exchange returned a token too close to expiry") + } + return nil +} diff --git a/pkg/hubauth/exchange_test.go b/pkg/hubauth/exchange_test.go new file mode 100644 index 000000000..ec82c5a9a --- /dev/null +++ b/pkg/hubauth/exchange_test.go @@ -0,0 +1,167 @@ +package hubauth + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExchangeRejectsUnusableTokens(t *testing.T) { + later := time.Now().Add(time.Hour) + + tests := []struct { + name string + token string + want string + }{ + { + name: "no token", + want: "no token", + }, + { + name: "not a JWT", + token: "not-a-jwt", + want: "unreadable", + }, + { + name: "unexpected issuer", + token: makeToken(t, later, func(c jwt.MapClaims) { c["iss"] = "https://evil.example.com/" }), + want: "unexpected issuer", + }, + { + name: "unexpected audience", + token: makeToken(t, later, func(c jwt.MapClaims) { c["aud"] = []string{"https://evil.example.com"} }), + want: "unexpected audience", + }, + { + name: "too close to expiry", + token: makeToken(t, time.Now().Add(ExpiryLeeway/2)), + want: "too close to expiry", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + installFakeHub(t, tt.token) + installSecret(t, testToken) + + _, err := Token(t.Context()) + assert.ErrorContains(t, err, tt.want) + }) + } +} + +func TestExchangeRetriesTransientFailures(t *testing.T) { + t.Run("retries a server error", func(t *testing.T) { + token := longLived(t) + var attempts int + resetState(t) + loginEndpoint = newServer(t, func(w http.ResponseWriter, _ *http.Request) { + attempts++ + if attempts == 1 { + http.Error(w, "boom", http.StatusBadGateway) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"token": token}) + }) + installSecret(t, testToken) + + got, err := Token(t.Context()) + require.NoError(t, err) + assert.Equal(t, token, got) + assert.Equal(t, 2, attempts) + }) + + t.Run("gives up after maxAttempts", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + hub.fail(http.StatusInternalServerError, nil) + + _, err := Token(t.Context()) + require.ErrorIs(t, err, errTransient) + assert.Len(t, hub.received(), maxAttempts) + }) + + t.Run("does not retry a refusal", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + hub.fail(http.StatusForbidden, nil) + + _, err := Token(t.Context()) + require.ErrorIs(t, err, errRejected) + assert.Len(t, hub.received(), 1) + }) + + t.Run("honours a short Retry-After", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + hub.fail(http.StatusTooManyRequests, http.Header{"Retry-After": []string{"0"}}) + + _, err := Token(t.Context()) + require.ErrorIs(t, err, errTransient) + assert.Len(t, hub.received(), maxAttempts) + }) + + t.Run("gives up on a long Retry-After", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + hub.fail(http.StatusTooManyRequests, http.Header{"Retry-After": []string{"600"}}) + + _, err := Token(t.Context()) + require.ErrorIs(t, err, errTransient) + assert.Len(t, hub.received(), 1, "the server asked us to stay away") + }) +} + +func TestRetryAfterFrom(t *testing.T) { + assert.Zero(t, retryAfterFrom(http.Header{})) + assert.Equal(t, 2*time.Second, retryAfterFrom(http.Header{"Retry-After": []string{"2"}})) + assert.Zero(t, retryAfterFrom(http.Header{"Retry-After": []string{"-2"}})) + assert.Zero(t, retryAfterFrom(http.Header{"Retry-After": []string{"nonsense"}})) + + date := time.Now().Add(90 * time.Second).UTC().Format(http.TimeFormat) + assert.InDelta(t, 90*time.Second, retryAfterFrom(http.Header{"Retry-After": []string{date}}), float64(2*time.Second)) +} + +func TestExchangeDoesNotFollowRedirects(t *testing.T) { + resetState(t) + + var leaked bool + target := newServer(t, func(w http.ResponseWriter, _ *http.Request) { + leaked = true + _ = json.NewEncoder(w).Encode(map[string]string{"token": longLived(t)}) + }) + loginEndpoint = newServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target(), http.StatusTemporaryRedirect) + }) + installSecret(t, testToken) + + _, err := Token(t.Context()) + require.Error(t, err) + assert.False(t, leaked, "the access token must not reach the redirect target") +} + +func TestLoginEndpointOverride(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + {name: "unset", url: "", want: defaultLoginEndpoint}, + {name: "docker host", url: "https://hub-stage.docker.com/v2/users/login", want: "https://hub-stage.docker.com/v2/users/login"}, + {name: "other host", url: "https://evil.example.com/login", want: defaultLoginEndpoint}, + {name: "plain HTTP", url: "http://hub.docker.com/v2/users/login", want: defaultLoginEndpoint}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(envLoginURL, tt.url) + assert.Equal(t, tt.want, resolveLoginEndpoint()) + }) + } +} diff --git a/pkg/hubauth/expiry.go b/pkg/hubauth/expiry.go new file mode 100644 index 000000000..55d00d558 --- /dev/null +++ b/pkg/hubauth/expiry.go @@ -0,0 +1,57 @@ +package hubauth + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// ExpiryLeeway is how long before its expiry a token stops being handed out: +// it covers the flight time of the request the token authenticates, plus the +// residual clock difference with the issuer. +const ExpiryLeeway = 30 * time.Second + +// Expiry returns the token's exp claim, or false when the token doesn't parse +// or carries no exp claim. +func Expiry(token string) (time.Time, bool) { + claims, err := parseClaims(token) + if err != nil { + return time.Time{}, false + } + exp, err := claims.GetExpirationTime() + if err != nil || exp == nil { + return time.Time{}, false + } + return exp.Time, true +} + +// Expiring reports whether the JWT's exp claim has passed or is less than +// [ExpiryLeeway] away, i.e. whether a fresh token should be obtained. Tokens +// that don't parse or carry no exp claim are left for the server to judge. +func Expiring(token string) bool { + exp, ok := Expiry(token) + if !ok { + return false + } + return exp.Before(now().Add(ExpiryLeeway)) +} + +// renewAt returns the time from which token must be replaced. +func renewAt(token string) time.Time { + exp, ok := Expiry(token) + if !ok { + return now().Add(unknownExpiryTTL) + } + return exp.Add(-renewBefore) +} + +// parseClaims reads a JWT's claims without verifying its signature: the token +// is a bearer credential we received over TLS from its issuer, and only the +// issuer can act on it. +func parseClaims(token string) (jwt.MapClaims, error) { + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(token, claims); err != nil { + return nil, err + } + return claims, nil +} diff --git a/pkg/hubauth/helpers_test.go b/pkg/hubauth/helpers_test.go new file mode 100644 index 000000000..1f1d5188c --- /dev/null +++ b/pkg/hubauth/helpers_test.go @@ -0,0 +1,169 @@ +package hubauth + +import ( + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/paths" +) + +const testToken = tokenPrefix + "pat_secret" + +// fakeHub stands in for Docker Hub's token exchange endpoint: it records the +// credentials it is sent and answers with whatever the test asks for. +type fakeHub struct { + mu sync.Mutex + creds []credentials + token string + status int + header http.Header +} + +type credentials struct { + username string + secret string +} + +func (h *fakeHub) received() []credentials { + h.mu.Lock() + defer h.mu.Unlock() + return h.creds +} + +// serve sets the token the fake hub answers with; an empty one makes the +// exchange fail. +func (h *fakeHub) serve(token string) { + h.mu.Lock() + defer h.mu.Unlock() + h.token = token +} + +// fail makes the fake hub answer with the given status, and optionally a +// Retry-After header. +func (h *fakeHub) fail(status int, header http.Header) { + h.mu.Lock() + defer h.mu.Unlock() + h.status = status + h.header = header +} + +func installFakeHub(t *testing.T, token string) *fakeHub { + t.Helper() + resetState(t) + + hub := &fakeHub{token: token} + loginEndpoint = newServer(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + hub.mu.Lock() + hub.creds = append(hub.creds, credentials{body.Username, body.Password}) + token, status, header := hub.token, hub.status, hub.header + hub.mu.Unlock() + + maps.Copy(w.Header(), header) + if status != 0 { + w.WriteHeader(status) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"token": token}) + }) + return hub +} + +// newServer starts a test server and returns a resolver for its URL, shaped +// like the [loginEndpoint] it replaces. +func newServer(t *testing.T, handler http.HandlerFunc) func() string { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return func() string { return server.URL } +} + +func installSecret(t *testing.T, secret string) { + t.Helper() + lookupCredentials = func() (string, string, error) { return "bob", secret, nil } +} + +// expireCredentialCheck ages the cached token past its credential re-check +// window, so the next call consults the credential store again. +func expireCredentialCheck() { + state.Lock() + defer state.Unlock() + state.credCheckedAt = time.Now().Add(-credCheckTTL - time.Second) +} + +// expireRenewal marks the cached token as due for renewal. The token shared +// with other processes ages at the same time — it is the very same token. +func expireRenewal() { + forget() + state.Lock() + defer state.Unlock() + state.renewAt = time.Now().Add(-time.Second) +} + +// resetState isolates a test from the developer's machine and from its +// neighbours: fresh in-memory cache, a throw-away shared-token file, no +// inherited clock skew, and the package fakes restored on cleanup. +func resetState(t *testing.T) { + t.Helper() + + oldEndpoint, oldLookup := loginEndpoint, lookupCredentials + t.Cleanup(func() { + loginEndpoint, lookupCredentials = oldEndpoint, oldLookup + }) + + paths.SetCacheDir(t.TempDir()) + t.Cleanup(func() { paths.SetCacheDir("") }) + + reset := func() { + clockSkew.Store(0) + state.Lock() + defer state.Unlock() + state.token, state.renewAt, state.credHash, state.credCheckedAt = "", time.Time{}, "", time.Time{} + state.lastErr, state.nextAttempt = nil, time.Time{} + } + reset() + t.Cleanup(reset) +} + +func longLived(t *testing.T) string { + t.Helper() + return makeToken(t, time.Now().Add(10*time.Minute)) +} + +// makeToken signs a token shaped like the ones Docker issues for Hub. +func makeToken(t *testing.T, exp time.Time, edits ...func(jwt.MapClaims)) string { + t.Helper() + + claims := jwt.MapClaims{ + "exp": exp.Unix(), + "iss": trustedIssuers[0], + "aud": []string{expectedAudience}, + hubClaim: map[string]any{ + "username": "bob", + "email": "bob@example.com", + }, + } + for _, edit := range edits { + edit(claims) + } + + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("secret")) + require.NoError(t, err) + return token +} diff --git a/pkg/hubauth/identity.go b/pkg/hubauth/identity.go new file mode 100644 index 000000000..6236ce09f --- /dev/null +++ b/pkg/hubauth/identity.go @@ -0,0 +1,40 @@ +package hubauth + +// Identity is the Docker account a token was issued for. Both Docker Desktop's +// tokens and the ones we mint carry it, which makes the account known without +// asking Docker Desktop — the only source docker-agent used to have. +type Identity struct { + Username string + Email string +} + +// hubClaim is the namespaced claim Docker's tokens carry their account +// information in. +const hubClaim = "https://hub.docker.com" + +// IdentityFromToken returns the account token was issued for, and false when +// the token carries no account information. +func IdentityFromToken(token string) (Identity, bool) { + claims, err := parseClaims(token) + if err != nil { + return Identity{}, false + } + fields, ok := claims[hubClaim].(map[string]any) + if !ok { + return Identity{}, false + } + + identity := Identity{ + Username: stringField(fields, "username"), + Email: stringField(fields, "email"), + } + if identity.Username == "" && identity.Email == "" { + return Identity{}, false + } + return identity, true +} + +func stringField(fields map[string]any, name string) string { + value, _ := fields[name].(string) + return value +} diff --git a/pkg/hubauth/identity_test.go b/pkg/hubauth/identity_test.go new file mode 100644 index 000000000..59a537383 --- /dev/null +++ b/pkg/hubauth/identity_test.go @@ -0,0 +1,54 @@ +package hubauth + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" +) + +func TestIdentityFromToken(t *testing.T) { + t.Run("reads the account from the claims", func(t *testing.T) { + identity, ok := IdentityFromToken(longLived(t)) + + assert.True(t, ok) + assert.Equal(t, Identity{Username: "bob", Email: "bob@example.com"}, identity) + }) + + t.Run("accepts a token without an email", func(t *testing.T) { + token := makeToken(t, time.Now().Add(time.Hour), func(c jwt.MapClaims) { + c[hubClaim] = map[string]any{"username": "bob"} + }) + + identity, ok := IdentityFromToken(token) + assert.True(t, ok) + assert.Equal(t, Identity{Username: "bob"}, identity) + }) + + t.Run("reports tokens without account information", func(t *testing.T) { + for name, token := range map[string]string{ + "not a JWT": "not-a-jwt", + "no hub claim": makeToken(t, time.Now().Add(time.Hour), func(c jwt.MapClaims) { delete(c, hubClaim) }), + "empty claim": makeToken(t, time.Now().Add(time.Hour), func(c jwt.MapClaims) { c[hubClaim] = map[string]any{} }), + "claim is text": makeToken(t, time.Now().Add(time.Hour), func(c jwt.MapClaims) { c[hubClaim] = "nope" }), + } { + t.Run(name, func(t *testing.T) { + _, ok := IdentityFromToken(token) + assert.False(t, ok) + }) + } + }) +} + +func TestIsAccessToken(t *testing.T) { + assert.True(t, isAccessToken("dckr_pat_abc")) + assert.True(t, isAccessToken("dckr_oat_abc"), "org access tokens are tokens too") + assert.False(t, isAccessToken("hunter2")) + assert.False(t, isAccessToken("")) +} + +func TestFingerprintSeparatesFields(t *testing.T) { + // Without a separator, ("ab", "c") and ("a", "bc") would collide. + assert.NotEqual(t, fingerprint("ab", "c"), fingerprint("a", "bc")) +} diff --git a/pkg/hubauth/token.go b/pkg/hubauth/token.go new file mode 100644 index 000000000..86f71cef6 --- /dev/null +++ b/pkg/hubauth/token.go @@ -0,0 +1,227 @@ +// Package hubauth mints Docker access tokens from the personal access token +// that `docker login` (including Docker Desktop's sign-in) leaves in the +// Docker CLI credential store. +// +// Docker Desktop's backend API only ever hands out its own access token — +// valid for 15 minutes — and never the refresh token behind it, so callers +// cannot renew it: when Desktop's background refresher is stuck, every caller +// keeps getting the same expired JWT. The stored PAT is long-lived and Docker +// Hub exchanges it for a fresh token without any user interaction, which gives +// docker-agent a token source it controls. +// +// The PAT never leaves this process except in the exchange request to Docker +// Hub: the endpoint is pinned to a Docker host, redirects are not followed, +// and account passwords are never sent. +package hubauth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" +) + +const ( + // renewBefore is how long before its expiry a minted token is replaced, + // so callers never receive one that dies mid-request. + renewBefore = time.Minute + + // unknownExpiryTTL bounds the reuse of a minted token whose exp claim we + // can't read, instead of exchanging the PAT on every call. + unknownExpiryTTL = 5 * time.Minute + + // credCheckTTL is how long a minted token is served before the credential + // store is consulted again, so a `docker logout` or an account switch is + // picked up quickly without shelling out to a credential helper on every + // call. + credCheckTTL = 30 * time.Second + + // mintBudget bounds the exchange with Hub, retries included. It cannot + // interrupt a hung credential helper (those take no context), but callers + // are never blocked on one: they wait on their own context. + mintBudget = 15 * time.Second + + // failureCooldown keeps a broken credential store or an unreachable Hub + // from adding latency to every single call. + failureCooldown = 30 * time.Second + + // rejectedCooldown applies when Docker refuses the stored token: that + // won't fix itself, so back off far longer than for a transient failure. + rejectedCooldown = 5 * time.Minute +) + +// errNoCredentials means the credential store holds no access token we can +// exchange, so any token minted earlier no longer represents the user. +var errNoCredentials = errors.New("no Docker access token in the credential store") + +var state struct { + sync.Mutex + + token string + renewAt time.Time // time from which the token must be replaced + credHash string // fingerprint of the credentials that minted it + credCheckedAt time.Time // last time those credentials were confirmed + lastErr error // why the last attempt failed + nextAttempt time.Time // earliest time a new attempt may start + inflight chan struct{} // closed when the in-flight attempt completes +} + +// Token returns a Docker token minted from the stored PAT, reusing the last one +// until it is about to expire. Callers get an error when no PAT is available +// (not signed in, or signed in with a password) or when Hub refuses the +// exchange. +// +// Attempts are singleflighted and run detached from the caller — reading the +// credential store shells out to a helper that ignores cancellation, and the +// result serves everyone — so a caller whose context is canceled returns +// immediately without holding up the others. +func Token(ctx context.Context) (string, error) { + if exchangeDisabled() { + return "", errors.New("token exchange is disabled by " + envNoExchange) + } + + state.Lock() + + current := now() + if state.token != "" && current.Before(state.renewAt) && current.Before(state.credCheckedAt.Add(credCheckTTL)) { + token := state.token + state.Unlock() + return token, nil + } + + if inflight := state.inflight; inflight != nil { + state.Unlock() + return await(ctx, inflight) + } + + if current.Before(state.nextAttempt) { + // A usable token from before the failure beats no token at all. + if state.token != "" && !Expiring(state.token) { + token := state.token + state.Unlock() + return token, nil + } + wait, err := state.nextAttempt.Sub(current).Round(time.Second), state.lastErr + state.Unlock() + return "", fmt.Errorf("waiting %s before retrying: %w", wait, err) + } + + done := make(chan struct{}) + state.inflight = done + state.Unlock() + + go func() { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), mintBudget) + defer cancel() + + token, credHash, err := freshToken(ctx) + + state.Lock() + defer state.Unlock() + record(token, credHash, err) + state.inflight = nil + close(done) + }() + + return await(ctx, done) +} + +// Invalidate drops token from the cache, so the next [Token] call mints a new +// one. Called when Docker rejects a token we believed to be valid; a token +// that has since been replaced is left alone. +func Invalidate(token string) { + if token == "" { + return + } + + state.Lock() + defer state.Unlock() + if state.token != token { + return + } + state.token, state.credHash, state.renewAt = "", "", time.Time{} + forget() +} + +// await waits for the in-flight attempt, or gives up when the caller's own +// context is canceled. +func await(ctx context.Context, done <-chan struct{}) (string, error) { + select { + case <-done: + state.Lock() + defer state.Unlock() + if state.token != "" { + return state.token, nil + } + return "", state.lastErr + case <-ctx.Done(): + return "", ctx.Err() + } +} + +// record stores the outcome of an attempt. It must be called with the state +// lock held. A token that is still usable survives a failed renewal, unless +// the credentials behind it are gone or refused: it then no longer represents +// the user. +func record(token, credHash string, err error) { + if err != nil { + state.lastErr = err + state.nextAttempt = now().Add(cooldownFor(err)) + if errors.Is(err, errNoCredentials) || errors.Is(err, errRejected) || Expiring(state.token) { + state.token, state.credHash = "", "" + forget() + } + return + } + + state.token = token + state.credHash = credHash + state.renewAt = renewAt(token) + state.credCheckedAt = now() + state.lastErr = nil + state.nextAttempt = time.Time{} +} + +func cooldownFor(err error) time.Duration { + if errors.Is(err, errRejected) { + return rejectedCooldown + } + return failureCooldown +} + +// freshToken returns a token minted from the credentials currently in the +// store, along with their fingerprint. The cached token is kept when it comes +// from those same credentials and is not due for renewal. +func freshToken(ctx context.Context) (token, credHash string, err error) { + username, secret, err := lookupCredentials() + if err != nil { + return "", "", fmt.Errorf("%w: %w", errNoCredentials, err) + } + if username == "" || !isAccessToken(secret) { + return "", "", errNoCredentials + } + credHash = fingerprint(username, secret) + + state.Lock() + cached, sameCredentials, dueForRenewal := state.token, credHash == state.credHash, !now().Before(state.renewAt) + state.Unlock() + + if cached != "" && sameCredentials && !dueForRenewal { + return cached, credHash, nil + } + + // A token minted by another docker-agent process is as good as ours. + if shared, ok := load(credHash); ok { + slog.DebugContext(ctx, "Reusing a Docker token minted by another process") + return shared, credHash, nil + } + + token, err = exchangeWithRetry(ctx, username, secret) + if err != nil { + return "", credHash, err + } + store(credHash, token) + return token, credHash, nil +} diff --git a/pkg/hubauth/token_test.go b/pkg/hubauth/token_test.go new file mode 100644 index 000000000..c8829af58 --- /dev/null +++ b/pkg/hubauth/token_test.go @@ -0,0 +1,233 @@ +package hubauth + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToken(t *testing.T) { + t.Run("exchanges the stored access token", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + token, err := Token(t.Context()) + require.NoError(t, err) + assert.NotEmpty(t, token) + assert.Equal(t, []credentials{{"bob", testToken}}, hub.received()) + }) + + t.Run("reuses the minted token until it is about to expire", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + first, err := Token(t.Context()) + require.NoError(t, err) + second, err := Token(t.Context()) + require.NoError(t, err) + + assert.Equal(t, first, second) + assert.Len(t, hub.received(), 1) + }) + + t.Run("mints again when the token is due for renewal", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + _, err := Token(t.Context()) + require.NoError(t, err) + expireRenewal() + + _, err = Token(t.Context()) + require.NoError(t, err) + assert.Len(t, hub.received(), 2) + }) + + t.Run("credentials are re-checked but not re-exchanged", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + first, err := Token(t.Context()) + require.NoError(t, err) + expireCredentialCheck() + + second, err := Token(t.Context()) + require.NoError(t, err) + assert.Equal(t, first, second) + assert.Len(t, hub.received(), 1, "same credentials and token still fresh") + }) + + t.Run("a credential change mints a new token", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + _, err := Token(t.Context()) + require.NoError(t, err) + expireCredentialCheck() + installSecret(t, tokenPrefix+"pat_other") + + _, err = Token(t.Context()) + require.NoError(t, err) + assert.Len(t, hub.received(), 2) + }) + + t.Run("signing out drops the minted token", func(t *testing.T) { + installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + _, err := Token(t.Context()) + require.NoError(t, err) + expireCredentialCheck() + installSecret(t, "") + + _, err = Token(t.Context()) + require.ErrorIs(t, err, errNoCredentials) + }) + + t.Run("passwords are never exchanged", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, "hunter2") + + _, err := Token(t.Context()) + require.ErrorIs(t, err, errNoCredentials) + assert.Empty(t, hub.received()) + }) + + t.Run("credential store failure is reported", func(t *testing.T) { + installFakeHub(t, longLived(t)) + lookupCredentials = func() (string, string, error) { return "", "", errors.New("boom") } + + _, err := Token(t.Context()) + require.ErrorIs(t, err, errNoCredentials) + assert.ErrorContains(t, err, "boom") + }) + + t.Run("a usable token survives a failed renewal", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + first, err := Token(t.Context()) + require.NoError(t, err) + + hub.serve("") + expireRenewal() + second, err := Token(t.Context()) + require.NoError(t, err) + assert.Equal(t, first, second) + + // Still served while the failure cooldown holds off new attempts. + third, err := Token(t.Context()) + require.NoError(t, err) + assert.Equal(t, first, third) + assert.Len(t, hub.received(), 2) + }) + + t.Run("failures are cached to keep callers fast", func(t *testing.T) { + hub := installFakeHub(t, "") + installSecret(t, testToken) + + _, err := Token(t.Context()) + require.ErrorContains(t, err, "no token") + + _, err = Token(t.Context()) + require.ErrorContains(t, err, "before retrying") + assert.Len(t, hub.received(), 1, "no new exchange while cooling down") + }) + + t.Run("a refused access token backs off for longer", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + hub.fail(http.StatusUnauthorized, nil) + + _, err := Token(t.Context()) + require.ErrorIs(t, err, errRejected) + require.ErrorContains(t, err, "docker login") + + state.Lock() + cooldown := time.Until(state.nextAttempt) + state.Unlock() + assert.Greater(t, cooldown, failureCooldown, "a revoked token won't fix itself") + }) + + t.Run("concurrent callers share a single exchange", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + _, err := Token(t.Context()) + assert.NoError(t, err) + }) + } + wg.Wait() + + assert.Len(t, hub.received(), 1) + }) + + t.Run("a canceled caller neither blocks nor poisons the others", func(t *testing.T) { + release := make(chan struct{}) + resetState(t) + loginEndpoint = newServer(t, func(w http.ResponseWriter, _ *http.Request) { + <-release + _ = json.NewEncoder(w).Encode(map[string]string{"token": longLived(t)}) + }) + installSecret(t, testToken) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := Token(ctx) + require.ErrorIs(t, err, context.Canceled) + + close(release) + token, err := Token(t.Context()) + require.NoError(t, err) + assert.NotEmpty(t, token) + }) + + t.Run("the exchange can be turned off", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + t.Setenv(envNoExchange, "1") + + _, err := Token(t.Context()) + require.ErrorContains(t, err, envNoExchange) + assert.Empty(t, hub.received()) + }) +} + +func TestInvalidate(t *testing.T) { + t.Run("drops the token the caller found unusable", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + token, err := Token(t.Context()) + require.NoError(t, err) + + Invalidate(token) + _, err = Token(t.Context()) + require.NoError(t, err) + assert.Len(t, hub.received(), 2) + }) + + t.Run("keeps a token that was already replaced", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + + token, err := Token(t.Context()) + require.NoError(t, err) + + Invalidate("some-other-token") + fresh, err := Token(t.Context()) + require.NoError(t, err) + assert.Equal(t, token, fresh) + assert.Len(t, hub.received(), 1) + }) +} From 8d98e1debaaf7064785e245ab298f704cff7c88e Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 6 Aug 2026 12:31:26 +0200 Subject: [PATCH 2/4] feat(desktop): serve minted tokens and cache the Docker token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway clients are rebuilt for every request and each one asks for a token, so every LLM call used to pay a round-trip to Docker Desktop over its socket: the token is now kept in memory until it nears its expiry. A token that is about to expire counts as unusable — it would die mid-request — and when Desktop has nothing usable to offer, minting from the stored access token comes before nudging Desktop: it needs nothing from Desktop and, unlike a forced refresh, is deterministic. A token Docker refused is never served again, however healthy Desktop believes it to be. GetTokenWithSource reports where a token came from, and the signed-in account is now read from the token's claims when Desktop isn't around to be asked, so DOCKER_USERNAME and DOCKER_EMAIL also resolve on a plain `docker login`. --- pkg/desktop/login.go | 182 +++++++++++++++++++++++++++----------- pkg/desktop/login_test.go | 139 +++++++++++++++++++++++++++-- 2 files changed, 262 insertions(+), 59 deletions(-) diff --git a/pkg/desktop/login.go b/pkg/desktop/login.go index a3734e87a..78640d5eb 100644 --- a/pkg/desktop/login.go +++ b/pkg/desktop/login.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/golang-jwt/jwt/v5" + "github.com/docker/docker-agent/pkg/hubauth" ) type DockerHubInfo struct { @@ -16,37 +16,128 @@ type DockerHubInfo struct { Email string `json:"email,omitempty"` } -// GetToken returns Docker Desktop's access token. 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 force a refresh on Desktop's side. +// Source says where a token came from, for diagnostics. +type Source string + +const ( + SourceNone Source = "none" + 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 +// never reach the real credential store or Docker Hub. +var mintToken = hubauth.Token + +// cache holds the last token known to be usable. Gateway clients are rebuilt +// for every request and each one asks for a token, so without this every LLM +// call would pay a round-trip to Docker Desktop over its socket. +var cache struct { + sync.Mutex + + token string + source Source + + // rejected is the last token Docker refused. Docker Desktop would keep + // serving it — it has no idea it was refused, and can't be told — so it is + // remembered here to make sure we mint one instead. + rejected string +} + +// GetToken returns the user's Docker access token, or "" when there is none. func GetToken(ctx context.Context) string { + token, _ := GetTokenWithSource(ctx) + 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. +func GetTokenWithSource(ctx context.Context) (string, Source) { + if token, source, ok := cached(); ok { + return token, source + } + token, err := fetchToken(ctx) - if err == nil && token != "" && !tokenExpired(token) { - return token + if err == nil && token != "" && !hubauth.Expiring(token) && !wasRejected(token) { + return remember(token, SourceDesktop) } logUnusableToken(ctx, token, err) + // Minting needs no help from Desktop and, unlike a forced refresh, is + // deterministic: try it first. + minted, mintErr := mintToken(ctx) + if mintErr == nil && minted != "" { + return remember(minted, SourceMinted) + } + slog.DebugContext(ctx, "Could not mint a Docker token from the credential store", "error", mintErr) + // Signed out: a forced refresh can't help and would delay every caller. if token == "" && !isLoggedIn(ctx) { - return "" + return "", SourceNone } if fresh := forceTokenRefresh(ctx); fresh != "" { slog.InfoContext(ctx, "Recovered a fresh token from Docker Desktop", "fingerprint", tokenFingerprint(fresh)) - return fresh + return remember(fresh, SourceDesktop) } - if token == "" { + if token == "" || wasRejected(token) { slog.WarnContext(ctx, "Token refresh failed, no token available") - return "" + return "", SourceNone } - slog.WarnContext(ctx, "Token refresh failed, sending a token known to be expired", + slog.WarnContext(ctx, "Token refresh failed, sending a token that expired or is about to", "fingerprint", tokenFingerprint(token), - "expired_for", expiredFor(token)) - return token + "expires_in", expiresIn(token)) + return token, SourceDesktop +} + +// InvalidateToken forgets token, everywhere it may be cached, so the next +// [GetToken] fetches or mints a new one. Called when Docker rejects a token we +// believed to be valid: only the issuer knows for sure. +func InvalidateToken(token string) { + if token == "" { + return + } + + cache.Lock() + if cache.token == token { + cache.token, cache.source = "", SourceNone + } + cache.rejected = token + cache.Unlock() + + hubauth.Invalidate(token) +} + +func cached() (string, Source, bool) { + cache.Lock() + defer cache.Unlock() + + if cache.token == "" || hubauth.Expiring(cache.token) { + return "", SourceNone, false + } + return cache.token, cache.source, true +} + +func wasRejected(token string) bool { + cache.Lock() + defer cache.Unlock() + + return token != "" && token == cache.rejected +} + +func remember(token string, source Source) (string, Source) { + cache.Lock() + defer cache.Unlock() + + cache.token, cache.source = token, source + return token, source } // logUnusableToken records why Docker Desktop's token can't be used as-is, @@ -57,12 +148,15 @@ func logUnusableToken(ctx context.Context, token string, err error) { slog.WarnContext(ctx, "Failed to fetch a token from Docker Desktop", "error", err) case token == "": slog.WarnContext(ctx, "Docker Desktop served an empty token") + case wasRejected(token): + slog.WarnContext(ctx, "Docker Desktop served a token Docker refused", + "fingerprint", tokenFingerprint(token)) default: - attrs := []any{"fingerprint", tokenFingerprint(token), "expired_for", expiredFor(token)} - if exp, ok := tokenExpiry(token); ok { + attrs := []any{"fingerprint", tokenFingerprint(token), "expires_in", expiresIn(token)} + if exp, ok := hubauth.Expiry(token); ok { attrs = append(attrs, "expires_at", exp.UTC().Format(time.RFC3339)) } - slog.WarnContext(ctx, "Docker Desktop served an expired token", attrs...) + slog.WarnContext(ctx, "Docker Desktop served a token that expired or is about to", attrs...) } } @@ -72,33 +166,30 @@ func tokenFingerprint(token string) string { return hex.EncodeToString(sum[:4]) } -// expiredFor returns how long ago the token's exp claim passed. -func expiredFor(token string) string { - exp, ok := tokenExpiry(token) +// expiresIn returns how long the token has left, negative once its exp claim +// has passed. +func expiresIn(token string) string { + exp, ok := hubauth.Expiry(token) if !ok { return "unknown" } - return time.Since(exp).Round(time.Second).String() -} - -// tokenExpiry returns the token's exp claim, or false when the token doesn't -// parse or carries no exp claim. -func tokenExpiry(token string) (time.Time, bool) { - parsed, _, err := jwt.NewParser().ParseUnverified(token, jwt.MapClaims{}) - if err != nil { - return time.Time{}, false - } - exp, err := parsed.Claims.GetExpirationTime() - if err != nil || exp == nil { - return time.Time{}, false - } - return exp.Time, true + 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. func GetUserInfo(ctx context.Context) DockerHubInfo { var info DockerHubInfo _ = ClientBackend.Get(ctx, "/registry/info", &info) - return info + if info.Username != "" { + return info + } + + identity, ok := hubauth.IdentityFromToken(GetToken(ctx)) + if !ok { + return info + } + return DockerHubInfo{Username: identity.Username, Email: identity.Email} } func fetchToken(ctx context.Context) (string, error) { @@ -115,19 +206,6 @@ func isLoggedIn(ctx context.Context) bool { return loggedIn } -// tokenExpired reports whether the JWT's exp claim is in the past, with -// leeway for clock skew between this machine and the token issuer. -// Tokens that don't parse or carry no exp claim are treated as valid. -func tokenExpired(token string) bool { - exp, ok := tokenExpiry(token) - if !ok { - return false - } - return exp.Before(time.Now().Add(-expiryLeeway)) -} - -const expiryLeeway = 30 * time.Second - var refreshState struct { sync.Mutex @@ -166,7 +244,7 @@ func forceTokenRefresh(ctx context.Context) string { // result if still valid. token := refreshState.result refreshState.Unlock() - if token != "" && !tokenExpired(token) { + if token != "" && !hubauth.Expiring(token) { return token } return "" @@ -223,7 +301,7 @@ func runTokenRefresh(ctx context.Context) string { for { // Check right away: Desktop may have refreshed synchronously. - if token, err := fetchToken(ctx); err == nil && token != "" && !tokenExpired(token) { + if token, err := fetchToken(ctx); err == nil && token != "" && !hubauth.Expiring(token) { return token } select { diff --git a/pkg/desktop/login_test.go b/pkg/desktop/login_test.go index 5ee3e682d..78e500256 100644 --- a/pkg/desktop/login_test.go +++ b/pkg/desktop/login_test.go @@ -3,6 +3,7 @@ package desktop import ( "context" "encoding/json" + "errors" "net" "net/http" "sync" @@ -26,6 +27,58 @@ func TestGetToken(t *testing.T) { assert.Equal(t, 0, backend.refreshes()) }) + t.Run("expired token replaced by a minted one", func(t *testing.T) { + backend := &fakeBackend{token: expired} + installFakeBackend(t, backend) + mintToken = func(context.Context) (string, error) { return valid, nil } + + token, source := GetTokenWithSource(t.Context()) + assert.Equal(t, valid, token) + assert.Equal(t, SourceMinted, source) + assert.Equal(t, 0, backend.refreshes(), "minting makes nudging Desktop unnecessary") + }) + + t.Run("a usable token is served from memory", func(t *testing.T) { + backend := &fakeBackend{token: valid} + installFakeBackend(t, backend) + + token, source := GetTokenWithSource(t.Context()) + assert.Equal(t, valid, token) + assert.Equal(t, SourceDesktop, source) + + // Desktop is not asked again: gateway clients call this per request. + backend.setFailTokenFetch(true) + assert.Equal(t, valid, GetToken(t.Context())) + }) + + t.Run("an invalidated token is fetched again", func(t *testing.T) { + backend := &fakeBackend{token: valid} + installFakeBackend(t, backend) + require.Equal(t, valid, GetToken(t.Context())) + + other := makeToken(t, time.Now().Add(time.Hour)) + backend.setToken(other) + InvalidateToken(valid) + + assert.Equal(t, other, GetToken(t.Context())) + }) + + t.Run("a refused token is not served again", func(t *testing.T) { + // Docker Desktop keeps serving the token Docker refused: it has no way + // of knowing, so minting is the only way out. + backend := &fakeBackend{token: valid} + installFakeBackend(t, backend) + require.Equal(t, valid, GetToken(t.Context())) + + minted := makeToken(t, time.Now().Add(time.Hour)) + mintToken = func(context.Context) (string, error) { return minted, nil } + InvalidateToken(valid) + + token, source := GetTokenWithSource(t.Context()) + assert.Equal(t, minted, token) + assert.Equal(t, SourceMinted, source) + }) + t.Run("expired token triggers forced refresh", func(t *testing.T) { backend := &fakeBackend{token: expired} backend.onRefresh = func() { backend.setToken(valid) } @@ -132,20 +185,64 @@ func TestGetToken(t *testing.T) { }) } -func TestTokenExpired(t *testing.T) { - assert.False(t, tokenExpired(makeToken(t, time.Now().Add(time.Minute)))) - assert.False(t, tokenExpired(makeToken(t, time.Now().Add(-10*time.Second))), "within clock-skew leeway") - assert.True(t, tokenExpired(makeToken(t, time.Now().Add(-time.Minute)))) - assert.False(t, tokenExpired("not-a-jwt")) +func TestGetTokenSignedOutStillMints(t *testing.T) { + valid := makeToken(t, time.Now().Add(time.Hour)) + + // A `docker login` PAT works even when Docker Desktop is signed out or + // not running at all. + backend := &fakeBackend{} + installFakeBackend(t, backend) + mintToken = func(context.Context) (string, error) { return valid, nil } + + assert.Equal(t, valid, GetToken(t.Context())) + assert.Equal(t, 0, backend.refreshes()) } -func makeToken(t *testing.T, exp time.Time) string { +func TestGetUserInfo(t *testing.T) { + t.Run("prefers what Docker Desktop reports", 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) + + assert.Equal(t, DockerHubInfo{Username: "desktop-user", Email: "desktop@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")} + installFakeBackend(t, backend) + + assert.Equal(t, DockerHubInfo{Username: "claims-user", Email: "claims@example.com"}, GetUserInfo(t.Context())) + }) + + t.Run("reports nothing without a token", func(t *testing.T) { + installFakeBackend(t, &fakeBackend{}) + + assert.Equal(t, DockerHubInfo{}, GetUserInfo(t.Context())) + }) +} + +func makeToken(t *testing.T, exp time.Time, claims ...func(jwt.MapClaims)) string { t.Helper() - token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"exp": exp.Unix()}).SignedString([]byte("secret")) + mapClaims := jwt.MapClaims{"exp": exp.Unix()} + for _, claim := range claims { + claim(mapClaims) + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, mapClaims).SignedString([]byte("secret")) require.NoError(t, err) return token } +// makeIdentityToken signs a token carrying an account, the way Docker's tokens +// do. +func makeIdentityToken(t *testing.T, username, email string) string { + t.Helper() + return makeToken(t, time.Now().Add(time.Hour), func(c jwt.MapClaims) { + c["https://hub.docker.com"] = map[string]any{"username": username, "email": email} + }) +} + // fakeBackend emulates Docker Desktop's backend API: GET /registry/token // serves the current token; GET /registry/is-logged-in reports session state; // POST /registry/credstore-updated triggers onRefresh (Desktop's async @@ -153,6 +250,7 @@ func makeToken(t *testing.T, exp time.Time) string { type fakeBackend struct { mu sync.Mutex token string + info *DockerHubInfo loggedIn bool failTokenFetch bool refreshCalls int @@ -195,6 +293,17 @@ func (b *fakeBackend) handler() http.Handler { b.mu.Unlock() _ = json.NewEncoder(w).Encode(loggedIn) }) + mux.HandleFunc("GET /registry/info", func(w http.ResponseWriter, _ *http.Request) { + b.mu.Lock() + info := b.info + b.mu.Unlock() + if info == nil { + http.Error(w, "not signed in", http.StatusNotFound) + return + } + // Docker Desktop reports the username in an "id" field. + _ = json.NewEncoder(w).Encode(map[string]string{"id": info.Username, "email": info.Email}) + }) mux.HandleFunc("POST /registry/credstore-updated", func(http.ResponseWriter, *http.Request) { b.mu.Lock() b.refreshCalls++ @@ -210,6 +319,22 @@ func (b *fakeBackend) handler() http.Handler { func installFakeBackend(t *testing.T, backend *fakeBackend) { t.Helper() + // Minting is exercised on its own in pkg/hubauth; here it must never + // reach the developer's credential store or the real Docker Hub. + oldMint := mintToken + mintToken = func(context.Context) (string, error) { + return "", errors.New("no Docker access token in the credential store") + } + t.Cleanup(func() { mintToken = oldMint }) + + clearCache := func() { + cache.Lock() + defer cache.Unlock() + cache.token, cache.source, cache.rejected = "", SourceNone, "" + } + clearCache() + t.Cleanup(clearCache) + ln := newMemListener() server := &http.Server{Handler: backend.handler()} go func() { _ = server.Serve(ln) }() From 1a478801bf47be5eefb89712c299c37a4314373b Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 6 Aug 2026 12:31:26 +0200 Subject: [PATCH 3/4] feat(gateway): re-authenticate once when the gateway rejects our token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 401 from the gateway is a more reliable signal than local expiry arithmetic, which a skewed clock, a stale cache or a revoked session can all get wrong. Gateway-bound clients now forget the rejected token, ask for a fresh one and replay the request once — bodies that cannot be rewound and requests that presented no token are left alone. --- pkg/httpclient/authretry.go | 86 ++++++++++ pkg/httpclient/authretry_test.go | 214 +++++++++++++++++++++++++ pkg/httpclient/client.go | 27 +++- pkg/model/provider/anthropic/client.go | 1 + pkg/model/provider/base/gateway.go | 18 +++ pkg/model/provider/gemini/client.go | 1 + pkg/model/provider/openai/client.go | 1 + pkg/modelsgateway/discovery.go | 2 +- 8 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 pkg/httpclient/authretry.go create mode 100644 pkg/httpclient/authretry_test.go diff --git a/pkg/httpclient/authretry.go b/pkg/httpclient/authretry.go new file mode 100644 index 000000000..0e14625b2 --- /dev/null +++ b/pkg/httpclient/authretry.go @@ -0,0 +1,86 @@ +package httpclient + +import ( + "context" + "io" + "net/http" + "strings" +) + +// authHeaders are the headers our gateway clients present a token in: OpenAI +// and Anthropic use Authorization (and x-api-key), Gemini x-goog-api-key. +var authHeaders = []string{"Authorization", "X-Api-Key", "X-Goog-Api-Key"} + +// authRetryTransport re-authenticates once when the server rejects the token a +// request presented. Docker's gateway tokens are short-lived and can be +// revoked or rotated at any time, and only the gateway knows for sure whether +// the one we hold still works: a 401 is a more reliable signal than any local +// expiry arithmetic, which a skewed clock or a stale cache can get wrong. +type authRetryTransport struct { + base http.RoundTripper + + // refresh returns a token to replace the rejected one, or an error when + // none can be obtained. + refresh func(ctx context.Context, rejected string) (string, error) +} + +func (t *authRetryTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.base.RoundTrip(req) + if err != nil || resp.StatusCode != http.StatusUnauthorized { + return resp, err + } + + rejected := presentedToken(req.Header) + if rejected == "" { + return resp, nil + } + // A body we cannot rewind cannot be replayed. + if req.Body != nil && req.Body != http.NoBody && req.GetBody == nil { + return resp, nil + } + + fresh, err := t.refresh(req.Context(), rejected) + if err != nil || fresh == "" || fresh == rejected { + return resp, nil + } + + retry := req.Clone(req.Context()) + if req.GetBody != nil { + body, err := req.GetBody() + if err != nil { + return resp, nil + } + retry.Body = body + } + replaceToken(retry.Header, rejected, fresh) + + // Release the connection the rejected response is holding; nobody will + // read its body. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + _ = resp.Body.Close() + + // Straight to the base transport: one retry, never a loop. + return t.base.RoundTrip(retry) +} + +// presentedToken returns the token a request authenticated with. +func presentedToken(header http.Header) string { + for _, name := range authHeaders { + if value := header.Get(name); value != "" { + return strings.TrimPrefix(value, "Bearer ") + } + } + return "" +} + +// replaceToken swaps rejected for fresh in every header carrying it, keeping +// whatever scheme prefix the client used. +func replaceToken(header http.Header, rejected, fresh string) { + for _, name := range authHeaders { + value := header.Get(name) + if value == "" || !strings.Contains(value, rejected) { + continue + } + header.Set(name, strings.Replace(value, rejected, fresh, 1)) + } +} diff --git a/pkg/httpclient/authretry_test.go b/pkg/httpclient/authretry_test.go new file mode 100644 index 000000000..dc213fb2b --- /dev/null +++ b/pkg/httpclient/authretry_test.go @@ -0,0 +1,214 @@ +package httpclient + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnauthorizedRetry(t *testing.T) { + t.Parallel() + + t.Run("replays the request with a fresh token", func(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var seen []string + var bodies []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + seen = append(seen, r.Header.Get("Authorization")) + bodies = append(bodies, string(body)) + mu.Unlock() + + if r.Header.Get("Authorization") != "Bearer fresh" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + client := NewHTTPClient(t.Context(), WithUnauthorizedRetry(func(_ context.Context, rejected string) (string, error) { + assert.Equal(t, "stale", rejected) + return "fresh", nil + })) + + resp := post(t, client, srv.URL) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, []string{"Bearer stale", "Bearer fresh"}, seen) + assert.Equal(t, []string{"payload", "payload"}, bodies, "the body is replayed as-is") + }) + + t.Run("refreshes every header carrying the token", func(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var apiKeys []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + apiKeys = append(apiKeys, r.Header.Get("X-Api-Key")) + mu.Unlock() + + if r.Header.Get("X-Api-Key") != "fresh" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + client := NewHTTPClient(t.Context(), WithUnauthorizedRetry(func(context.Context, string) (string, error) { + return "fresh", nil + })) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL, strings.NewReader("payload")) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer stale") + req.Header.Set("X-Api-Key", "stale") + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, []string{"stale", "fresh"}, apiKeys) + }) + + t.Run("gives up after one retry", func(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + calls++ + mu.Unlock() + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + client := NewHTTPClient(t.Context(), WithUnauthorizedRetry(func(context.Context, string) (string, error) { + return "fresh", nil + })) + + resp := post(t, client, srv.URL) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 2, calls) + }) + + t.Run("does not retry when no fresh token is available", func(t *testing.T) { + t.Parallel() + + tests := map[string]func(context.Context, string) (string, error){ + "refresh fails": func(context.Context, string) (string, error) { return "", assert.AnError }, + "same token": func(_ context.Context, rejected string) (string, error) { return rejected, nil }, + "no token at all": func(context.Context, string) (string, error) { return "", nil }, + } + + for name, refresh := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + calls++ + mu.Unlock() + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + client := NewHTTPClient(t.Context(), WithUnauthorizedRetry(refresh)) + resp := post(t, client, srv.URL) + defer resp.Body.Close() + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 1, calls) + }) + } + }) + + t.Run("leaves other statuses alone", func(t *testing.T) { + t.Parallel() + + var refreshed bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + client := NewHTTPClient(t.Context(), WithUnauthorizedRetry(func(context.Context, string) (string, error) { + refreshed = true + return "fresh", nil + })) + + resp := post(t, client, srv.URL) + defer resp.Body.Close() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + assert.False(t, refreshed) + }) + + t.Run("does not retry an unauthenticated request", func(t *testing.T) { + t.Parallel() + + var refreshed bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + client := NewHTTPClient(t.Context(), WithUnauthorizedRetry(func(context.Context, string) (string, error) { + refreshed = true + return "fresh", nil + })) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, http.NoBody) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.False(t, refreshed, "nothing to refresh without a presented token") + }) +} + +func TestPresentedToken(t *testing.T) { + t.Parallel() + + assert.Equal(t, "abc", presentedToken(http.Header{"Authorization": []string{"Bearer abc"}})) + assert.Equal(t, "abc", presentedToken(http.Header{"Authorization": []string{"abc"}})) + assert.Equal(t, "abc", presentedToken(http.Header{"X-Goog-Api-Key": []string{"abc"}})) + assert.Empty(t, presentedToken(http.Header{})) +} + +// post sends an authenticated POST presenting a token the fake servers below +// consider stale, with a body that has to survive a replay. +func post(t *testing.T, client *http.Client, url string) *http.Response { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, strings.NewReader("payload")) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer stale") + + resp, err := client.Do(req) + require.NoError(t, err) + return resp +} diff --git a/pkg/httpclient/client.go b/pkg/httpclient/client.go index 14caff3ce..6381dec57 100644 --- a/pkg/httpclient/client.go +++ b/pkg/httpclient/client.go @@ -25,6 +25,10 @@ type HTTPOptions struct { // [userid.Get]; tests inject their own source via // [withCagentIDSource] to stay independent of global state. cagentID func() string + + // refreshAuth re-authenticates a request the server answered with 401. + // Set through [WithUnauthorizedRetry]; nil leaves 401s to the caller. + refreshAuth func(ctx context.Context, rejected string) (string, error) } type Opt func(*HTTPOptions) @@ -47,11 +51,24 @@ func NewHTTPClient(ctx context.Context, opts ...Opt) *http.Client { // See https://github.com/docker/docker-agent/issues/1956 rt := newTransport(ctx) - return &http.Client{ - Transport: WrapWithOTel(&userAgentTransport{ - httpOptions: httpOptions, - rt: &sseFilterTransport{base: rt}, - }), + var wrapped http.RoundTripper = &userAgentTransport{ + httpOptions: httpOptions, + rt: &sseFilterTransport{base: rt}, + } + if httpOptions.refreshAuth != nil { + // Outermost, so a replayed request goes through the whole chain again. + wrapped = &authRetryTransport{base: wrapped, refresh: httpOptions.refreshAuth} + } + + return &http.Client{Transport: WrapWithOTel(wrapped)} +} + +// WithUnauthorizedRetry re-authenticates and replays a request once when the +// server rejects the token it presented. refresh receives the rejected token +// and returns its replacement. +func WithUnauthorizedRetry(refresh func(ctx context.Context, rejected string) (string, error)) Opt { + return func(o *HTTPOptions) { + o.refreshAuth = refresh } } diff --git a/pkg/model/provider/anthropic/client.go b/pkg/model/provider/anthropic/client.go index 0317bba6d..e32af74a3 100644 --- a/pkg/model/provider/anthropic/client.go +++ b/pkg/model/provider/anthropic/client.go @@ -116,6 +116,7 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro // Configure a custom HTTP client to inject headers and query params used by the Gateway. httpOptions := base.GatewayHTTPOptions(url, "https://api.anthropic.com/", cfg, &globalOptions) + httpOptions = append(httpOptions, base.GatewayAuthRetry(env, gateway)...) gatewayHTTPClient := httpclient.NewHTTPClient(ctx, httpOptions...) globalOptions.WrapTransport(ctx, gatewayHTTPClient) diff --git a/pkg/model/provider/base/gateway.go b/pkg/model/provider/base/gateway.go index 75d4a6058..b6bc2e5c5 100644 --- a/pkg/model/provider/base/gateway.go +++ b/pkg/model/provider/base/gateway.go @@ -4,9 +4,11 @@ import ( "cmp" "context" "errors" + "log/slog" "net/url" "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/desktop" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/httpclient" "github.com/docker/docker-agent/pkg/model/provider/options" @@ -40,6 +42,22 @@ func GatewayAuthToken(ctx context.Context, env environment.Provider, gateway str return token, nil } +// GatewayAuthRetry lets a client recover from a gateway that rejects the Docker +// token it presented: the token is forgotten and the request replayed once with +// a fresh one. Empty for gateways that don't authenticate with a Docker login, +// and a no-op when the token comes from a static source (an explicitly set +// DOCKER_TOKEN can't be refreshed, and must not be second-guessed). +func GatewayAuthRetry(env environment.Provider, gateway string) []httpclient.Opt { + if !environment.IsTrustedDockerURL(gateway) { + return nil + } + return []httpclient.Opt{httpclient.WithUnauthorizedRetry(func(ctx context.Context, rejected string) (string, error) { + slog.WarnContext(ctx, "The Docker AI gateway rejected our token, re-authenticating") + desktop.InvalidateToken(rejected) + return GatewayAuthToken(ctx, env, gateway) + })} +} + // GatewayHTTPOptions builds the httpclient options shared by all // gateway-mode provider clients: the proxied base URL (the provider's public // endpoint unless the model overrides base_url), provider/model identity, diff --git a/pkg/model/provider/gemini/client.go b/pkg/model/provider/gemini/client.go index 16090cd16..d51fb43d9 100644 --- a/pkg/model/provider/gemini/client.go +++ b/pkg/model/provider/gemini/client.go @@ -153,6 +153,7 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro baseURL := fmt.Sprintf("%s://%s%s/", url.Scheme, url.Host, url.Path) httpOptions := base.GatewayHTTPOptions(url, "https://generativelanguage.googleapis.com/", cfg, &globalOptions) + httpOptions = append(httpOptions, base.GatewayAuthRetry(env, gateway)...) httpOpts := genai.HTTPOptions{ BaseURL: baseURL, diff --git a/pkg/model/provider/openai/client.go b/pkg/model/provider/openai/client.go index 73511c678..5b37c58cd 100644 --- a/pkg/model/provider/openai/client.go +++ b/pkg/model/provider/openai/client.go @@ -164,6 +164,7 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro // Configure a custom HTTP client to inject headers and query params used by the Gateway. httpOptions := base.GatewayHTTPOptions(url, "https://api.openai.com/v1", cfg, &globalOptions) + httpOptions = append(httpOptions, base.GatewayAuthRetry(env, gateway)...) gatewayHTTPClient := httpclient.NewHTTPClient(ctx, httpOptions...) globalOptions.WrapTransport(ctx, gatewayHTTPClient) diff --git a/pkg/modelsgateway/discovery.go b/pkg/modelsgateway/discovery.go index e17383cb1..8f2f05093 100644 --- a/pkg/modelsgateway/discovery.go +++ b/pkg/modelsgateway/discovery.go @@ -72,7 +72,7 @@ func listModelsWith(ctx context.Context, gatewayURL string, env environment.Prov } if client == nil { - client = httpclient.NewHTTPClient(ctx) + client = httpclient.NewHTTPClient(ctx, base.GatewayAuthRetry(env, gatewayURL)...) } resp, err := client.Do(req) if err != nil { From 6fb44917975e21c7715e67202107d8f0af7e9f6f Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 6 Aug 2026 12:31:26 +0200 Subject: [PATCH 4/4] feat(cli): report the token source and mention docker login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker agent debug auth` now shows whether the token came from Docker Desktop or was minted from the stored access token — the first thing to know when the gateway rejects it. The doctor issue no longer implies Docker Desktop is the only way to sign in. Signed-off-by: David Gageot --- cmd/root/debug_auth.go | 11 ++++++++--- cmd/root/doctor.go | 2 +- cmd/root/doctor_test.go | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/cmd/root/debug_auth.go b/cmd/root/debug_auth.go index 10bf4abfc..0bc2b1c4a 100644 --- a/cmd/root/debug_auth.go +++ b/cmd/root/debug_auth.go @@ -16,6 +16,7 @@ import ( // authInfo holds the parsed JWT authentication information. type authInfo struct { Token string `json:"token"` + Source string `json:"source,omitempty"` Subject string `json:"subject,omitempty"` Issuer string `json:"issuer,omitempty"` IssuedAt time.Time `json:"issued_at,omitzero"` @@ -41,14 +42,14 @@ func newDebugAuthCmd() *cobra.Command { w := cmd.OutOrStdout() - token := desktop.GetToken(ctx) + token, source := desktop.GetTokenWithSource(ctx) if token == "" { if jsonOutput { return json.NewEncoder(w).Encode(map[string]string{ - "error": "no token found (is Docker Desktop running and are you logged in?)", + "error": "no token found (is Docker Desktop running, or are you logged in with `docker login`?)", }) } - fmt.Fprintln(w, "No token found. Is Docker Desktop running and are you logged in?") + fmt.Fprintln(w, "No token found. Is Docker Desktop running, or are you logged in with `docker login`?") return nil } @@ -56,6 +57,7 @@ func newDebugAuthCmd() *cobra.Command { if err != nil { return fmt.Errorf("failed to parse JWT: %w", err) } + info.Source = string(source) userInfo := desktop.GetUserInfo(ctx) info.Username = userInfo.Username @@ -112,6 +114,9 @@ func printAuthInfoText(w io.Writer, info *authInfo) { fmt.Fprintf(w, "Token: %s...%s\n", info.Token[:previewLen], info.Token[len(info.Token)-previewLen:]) } + if info.Source != "" { + fmt.Fprintf(w, "Source: %s\n", info.Source) + } if info.Username != "" { fmt.Fprintf(w, "Username: %s\n", info.Username) } diff --git a/cmd/root/doctor.go b/cmd/root/doctor.go index 7cdc47b81..f49540732 100644 --- a/cmd/root/doctor.go +++ b/cmd/root/doctor.go @@ -291,7 +291,7 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor if _, ok := findSource(ctx, sources, environment.DockerDesktopTokenEnv); !ok { autoStatus.Usable = false autoIssues = append(autoIssues, - "the models gateway requires Docker Desktop sign-in and no DOCKER_TOKEN was found; sign in to Docker Desktop (check with `docker agent debug auth`)") + "the models gateway requires a Docker sign-in and no DOCKER_TOKEN was found; sign in to Docker Desktop or run `docker login` (check with `docker agent debug auth`)") } } diff --git a/cmd/root/doctor_test.go b/cmd/root/doctor_test.go index 590495586..e863b6e2f 100644 --- a/cmd/root/doctor_test.go +++ b/cmd/root/doctor_test.go @@ -254,7 +254,8 @@ func TestDoctorCommand_DockerGatewayNeedsSignIn(t *testing.T) { withDoctorTestEnv(nil, nil, dmr.ErrNotInstalled)) require.Error(t, err) - assert.Contains(t, output, "requires Docker Desktop sign-in") + assert.Contains(t, output, "requires a Docker sign-in") + assert.Contains(t, output, "docker login") output, err = executeDoctor(t, []string{"--models-gateway", "https://api.docker.com/gateway"}, withDoctorTestEnv(map[string]string{"DOCKER_TOKEN": "jwt"}, nil, dmr.ErrNotInstalled))