From 50af5321f4b3bec15aa61ba772bce9ff4ce7fff1 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Sun, 9 Aug 2026 16:13:30 +0200 Subject: [PATCH] feat(auth): mint Docker tokens from the stored access token Docker Desktop hands out a 15-minute token it never renews, so a stuck refresher leaves every caller with a dead one. The access token `docker login` stores is long-lived, and Docker Hub exchanges it for a fresh one with no user interaction. pkg/hubauth owns that exchange: a token renewed ahead of its expiry, credential re-checks so a logout is noticed, a cache shared with sibling processes, retries, issuer and audience validation, and clock-skew correction learned from Hub. Desktop's token still comes first while it is usable; one the gateway refuses is never served again and the request is replayed once with a fresh one. `docker agent debug auth` reports which token is in use and where it came from, and DOCKER_AGENT_NO_TOKEN_EXCHANGE opts out entirely. Signed-off-by: David Gageot --- cmd/root/debug_auth.go | 13 +- cmd/root/doctor.go | 2 +- cmd/root/doctor_test.go | 3 +- docs/configuration/overview/index.md | 2 + docs/features/cli/index.md | 6 +- docs/guides/secrets/index.md | 8 + pkg/desktop/login.go | 229 ++++++++++++++++------ pkg/desktop/login_test.go | 256 ++++++++++++++++++++++++- pkg/httpclient/authretry.go | 86 +++++++++ pkg/httpclient/authretry_test.go | 214 +++++++++++++++++++++ pkg/httpclient/client.go | 27 ++- pkg/hubauth/cache.go | 89 +++++++++ pkg/hubauth/cache_test.go | 133 +++++++++++++ pkg/hubauth/clock.go | 39 ++++ pkg/hubauth/clock_test.go | 93 +++++++++ pkg/hubauth/credentials.go | 50 +++++ pkg/hubauth/exchange.go | 246 ++++++++++++++++++++++++ pkg/hubauth/exchange_test.go | 179 +++++++++++++++++ pkg/hubauth/expiry.go | 57 ++++++ pkg/hubauth/helpers_test.go | 176 +++++++++++++++++ pkg/hubauth/identity.go | 40 ++++ pkg/hubauth/identity_test.go | 54 ++++++ pkg/hubauth/token.go | 227 ++++++++++++++++++++++ pkg/hubauth/token_test.go | 233 ++++++++++++++++++++++ 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 +- 29 files changed, 2410 insertions(+), 75 deletions(-) create mode 100644 pkg/httpclient/authretry.go create mode 100644 pkg/httpclient/authretry_test.go 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/cmd/root/debug_auth.go b/cmd/root/debug_auth.go index 10bf4abfcd..b8b40ff3fe 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"` @@ -30,7 +31,7 @@ func newDebugAuthCmd() *cobra.Command { cmd := &cobra.Command{ Use: "auth", - Short: "Print Docker Desktop authentication information", + Short: "Print Docker authentication information", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) (commandErr error) { ctx := cmd.Context() @@ -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 7cdc47b813..f49540732d 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 5904955868..e863b6e2fe 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)) diff --git a/docs/configuration/overview/index.md b/docs/configuration/overview/index.md index 3d50b996c4..705a698cf4 100644 --- a/docs/configuration/overview/index.md +++ b/docs/configuration/overview/index.md @@ -162,6 +162,8 @@ API keys and secrets are read from environment variables — never stored in con | `DOCKER_AGENT_MODELS_GATEWAY` | Route model traffic through a gateway. Equivalent to the `--models-gateway` flag. | | `DOCKER_AGENT_HIDE_TELEMETRY_BANNER`| Set to `1` to suppress the first-run telemetry notice. | | `DOCKER_AGENT_AUTO_UPDATE` | Set to a truthy value (`1`, `true`, `yes`, `on`) to let standalone release binaries self-update before running. See [Optional Self-Updates](../../getting-started/installation/index.md#optional-self-updates). | +| `DOCKER_AGENT_NO_TOKEN_EXCHANGE` | Set to `1` to stop Docker Agent from exchanging the access token stored by `docker login` for a Docker token. See [Docker authentication](../../guides/secrets/index.md#docker-authentication). | +| `DOCKER_AGENT_HUB_LOGIN_URL` | Point the token exchange at a Docker staging environment. Ignored unless it is an HTTPS `docker.com` URL. | > [!NOTE] > **Legacy `CAGENT_*` aliases** diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index b389d58730..e77250cde6 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -674,7 +674,7 @@ $ docker agent debug [flags] | `toolsets ` | List every toolset each agent in the config exposes, with each tool's name and description. | | `skills ` | List the skills discovered for each agent, marking forked skills. | | `title ` | Generate a session title for `` using the same title-generation path the TUI uses (including any configured `title_model`), without starting a session. See [Session Titles](../sessions/index.md#session-titles). | -| `auth` | Print parsed Docker Desktop authentication info from the locally stored JWT (subject, issuer, expiry, username/email). Add `--json` for machine-readable output. | +| `auth` | Print parsed Docker authentication info from the token in use (source, subject, issuer, expiry, username/email). Add `--json` for machine-readable output. | | `oauth list` | List stored MCP OAuth tokens (resource, scope, expiry, redacted access token). Add `--json` for machine-readable output. | | `oauth remove ` | Remove a stored MCP OAuth token. | | `oauth login ` | Perform an interactive OAuth login for a remote MCP server declared in the config, by its name or URL. See [Remote MCP Servers](../remote-mcp/index.md). | @@ -693,7 +693,9 @@ $ docker agent debug oauth login agent.yaml github > [!WARNING] > **`debug auth --json` prints the full bearer token** > -> The text output of `debug auth` truncates the token to a short preview, but `--json` includes the complete, unredacted JWT in its `token` field. Never paste `debug auth --json` output into logs, issue trackers, or bug reports — anyone with that token can act as you against Docker Desktop's backend. Use the plain-text output (or redact the `token` field yourself) when sharing diagnostic output. +> The text output of `debug auth` truncates the token to a short preview, but `--json` includes the complete, unredacted JWT in its `token` field. Never paste `debug auth --json` output into logs, issue trackers, or bug reports — anyone with that token can act as you against Docker. Use the plain-text output (or redact the `token` field yourself) when sharing diagnostic output. + +The `Source` field says where the token came from: `docker desktop`, or `minted from the stored access token` when it was obtained by exchanging the access token `docker login` stored. See [Docker authentication](../../guides/secrets/index.md#docker-authentication). The `config`, `toolsets`, `skills`, and `title` subcommands also accept [runtime configuration flags](#runtime-configuration-flags) (`--working-dir`, `--models-gateway`, …); `title` additionally accepts `--model` to override the model used to resolve the config before generating the title. diff --git a/docs/guides/secrets/index.md b/docs/guides/secrets/index.md index 1cc653db6b..26fe935471 100644 --- a/docs/guides/secrets/index.md +++ b/docs/guides/secrets/index.md @@ -184,6 +184,14 @@ The command is invoked with the variable name appended as the final argument, an On machines where Docker Desktop is installed, Docker Agent queries Docker Desktop's backend for secrets stored against your signed-in Docker account. This is transparent — no extra configuration — and it is how signed-in Docker users get provider API keys without setting any environment variables. +## Docker Authentication + +Routing model traffic through the [Docker models gateway](../../configuration/models/index.md) needs a Docker token. Docker Desktop hands out one that is valid for 15 minutes and cannot be renewed by Docker Agent, so when Desktop has nothing usable to offer — it is signed out, not running, or its own refresh is stuck — Docker Agent exchanges the long-lived access token that `docker login` left in your credential store for a fresh Docker token, the same exchange `docker login` itself performs. Signing in with `docker login` is therefore enough; Docker Desktop is not required. + +Only Docker access tokens are exchanged — the `dckr_…` secrets `docker login` stores — never an account password, and the exchange goes to Docker Hub over HTTPS. The resulting bearer token is cached in a private file under Docker Agent's cache directory so sibling processes reuse it instead of minting their own, and it stops being used within seconds of a `docker logout` or an account switch. Run `docker agent debug auth` to see which token is in use and where it came from. + +Set `DOCKER_AGENT_NO_TOKEN_EXCHANGE=1` to opt out: Docker Agent then relies on Docker Desktop alone. + ## 1Password References Any secret value resolved through the chain above can be a **1Password secret reference** instead of the literal secret. If the value starts with `op://`, Docker Agent resolves it by invoking the [1Password CLI](https://developer.1password.com/docs/cli/) (`op read `) and uses the result. diff --git a/pkg/desktop/login.go b/pkg/desktop/login.go index a3734e87ad..26b6a1ac60 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,169 @@ 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 + staleAt time.Time // time from which the token must be looked up again + + // rejected holds the tokens Docker refused, until they expire. Docker + // Desktop keeps serving one — it has no idea it was refused, and can't be + // told — and can regress to an earlier one, so a single tombstone would let + // a refused token back in. + rejected map[string]struct{} +} + +// cacheTTL bounds how long a token is served from memory before Docker Desktop +// and the credential store are consulted again. Expiry alone would not do: a +// minted token can be valid for hours, and a `docker logout` or an account +// switch in between must not keep the previous account's token in play. +const cacheTTL = 30 * time.Second + +// 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 && usable(token) && remember(token, SourceDesktop) { + return token, SourceDesktop } logUnusableToken(ctx, token, err) + // Minting needs no help from Desktop and, unlike a forced refresh, is + // deterministic: try it first. hubauth keeps its own in-memory copy and + // re-checks the credential store, so cacheTTL bounds how long a minted + // token is served without it being asked again. + minted, mintErr := mintToken(ctx) + if mintErr == nil && usable(minted) && remember(minted, SourceMinted) { + return 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 != "" { + if fresh := forceTokenRefresh(ctx); fresh != "" && remember(fresh, SourceDesktop) { slog.InfoContext(ctx, "Recovered a fresh token from Docker Desktop", "fingerprint", tokenFingerprint(fresh)) - return fresh + return 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 + } + if cache.rejected == nil { + cache.rejected = make(map[string]struct{}) + } + // An expired token is refused by everyone anyway, and never served from + // here: forget it rather than grow the set for the life of the process. + for known := range cache.rejected { + if hubauth.Expiring(known) { + delete(cache.rejected, known) + } + } + cache.rejected[token] = struct{}{} + cache.Unlock() + + hubauth.Invalidate(token) +} + +func cached() (string, Source, bool) { + cache.Lock() + defer cache.Unlock() + + if cache.token == "" || isRejected(cache.token) { + return "", SourceNone, false + } + if !time.Now().Before(cache.staleAt) || hubauth.Expiring(cache.token) { + return "", SourceNone, false + } + return cache.token, cache.source, true +} + +// usable reports whether a token is worth handing to callers: one that dies +// mid-request is no better than none, and one Docker already refused is worse. +func usable(token string) bool { + return token != "" && !hubauth.Expiring(token) && !wasRejected(token) +} + +func wasRejected(token string) bool { + cache.Lock() + defer cache.Unlock() + + return isRejected(token) +} + +// isRejected must be called with the cache lock held. +func isRejected(token string) bool { + _, refused := cache.rejected[token] + return refused +} + +// remember caches a token, reporting whether it may be served: the rejection +// check and the write are a single operation, so a token invalidated while it +// was being looked up is never handed out. +func remember(token string, source Source) bool { + cache.Lock() + defer cache.Unlock() + + if token == "" || isRejected(token) { + return false + } + cache.token, cache.source, cache.staleAt = token, source, time.Now().Add(cacheTTL) + return true } // logUnusableToken records why Docker Desktop's token can't be used as-is, @@ -57,12 +189,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 +207,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 +247,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 +285,7 @@ func forceTokenRefresh(ctx context.Context) string { // result if still valid. token := refreshState.result refreshState.Unlock() - if token != "" && !tokenExpired(token) { + if usable(token) { return token } return "" @@ -222,8 +341,10 @@ func runTokenRefresh(ctx context.Context) string { defer ticker.Stop() for { - // Check right away: Desktop may have refreshed synchronously. - if token, err := fetchToken(ctx); err == nil && token != "" && !tokenExpired(token) { + // Check right away: Desktop may have refreshed synchronously. A token + // Docker refused doesn't count: Desktop serves it until it renews its + // session, and accepting it here would cache it for its whole life. + if token, err := fetchToken(ctx); err == nil && usable(token) { return token } select { diff --git a/pkg/desktop/login_test.go b/pkg/desktop/login_test.go index 5ee3e682da..423920a4e4 100644 --- a/pkg/desktop/login_test.go +++ b/pkg/desktop/login_test.go @@ -3,9 +3,11 @@ package desktop import ( "context" "encoding/json" + "errors" "net" "net/http" "sync" + "sync/atomic" "testing" "time" @@ -26,6 +28,96 @@ 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("a refused token is not served again when minting is unavailable", func(t *testing.T) { + // The forced refresh polls Docker Desktop, which serves the refused + // token until it renews its session: accepting it would send Docker a + // token it just refused, and pin it in the cache for its whole life. + backend := &fakeBackend{token: valid, loggedIn: true} + installFakeBackend(t, backend) // minting unavailable: no PAT, or Hub is down + require.Equal(t, valid, GetToken(t.Context())) + + InvalidateToken(valid) + + token, source := GetTokenWithSource(t.Context()) + assert.Empty(t, token, "a refused token must never be served again") + assert.Equal(t, SourceNone, source) + + // Desktop eventually renews its session: the next token is served. + fresh := makeToken(t, time.Now().Add(time.Hour)) + backend.setToken(fresh) + assert.Equal(t, fresh, GetToken(t.Context())) + }) + + t.Run("a refused token is not reused from the last refresh result", func(t *testing.T) { + // The refresh is rate-limited, and its result is reused while it lasts: + // not once Docker has refused that token. + backend := &fakeBackend{token: expired, loggedIn: true} + backend.onRefresh = func() { backend.setToken(valid) } + installFakeBackend(t, backend) + require.Equal(t, valid, GetToken(t.Context())) + require.Equal(t, 1, backend.refreshes()) + + backend.setToken(expired) + InvalidateToken(valid) + + // The last resort is the stale token Desktop still serves, never the + // refused one. + assert.Equal(t, expired, GetToken(t.Context())) + assert.Equal(t, 1, backend.refreshes(), "still rate-limited") + }) + t.Run("expired token triggers forced refresh", func(t *testing.T) { backend := &fakeBackend{token: expired} backend.onRefresh = func() { backend.setToken(valid) } @@ -132,20 +224,142 @@ 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 { +// TestCachedTokenIsRecheckedPeriodically covers a `docker logout` or an account while a minted token — which can be valid for hours — is cached: +// waiting for its expiry would keep the previous account's token in play. +func TestCachedTokenIsRecheckedPeriodically(t *testing.T) { + minted := makeToken(t, time.Now().Add(4*time.Hour)) + + // Docker Desktop signed out, or not running at all: the token can only + // come from the credential store. + installFakeBackend(t, &fakeBackend{}) + mints := 0 + mintToken = func(context.Context) (string, error) { + mints++ + return minted, nil + } + + require.Equal(t, minted, GetToken(t.Context())) + require.Equal(t, minted, GetToken(t.Context())) + require.Equal(t, 1, mints, "a fresh token is served from memory") + + expireCache() + mintToken = func(context.Context) (string, error) { + return "", errors.New("no Docker access token in the credential store") + } + + assert.Empty(t, GetToken(t.Context()), "a logout must stop the previous account's token from being served") +} + +// TestTokenInvalidatedDuringLookupIsNotServed covers the window between +// checking a token and caching it: another request's 401 lands in between, so +// the token must not be handed out even though it looked fine when fetched. +func TestTokenInvalidatedDuringLookupIsNotServed(t *testing.T) { + valid := makeToken(t, time.Now().Add(time.Hour)) + installFakeBackend(t, &fakeBackend{token: valid, loggedIn: true}) + + require.True(t, usable(valid)) + InvalidateToken(valid) // the gateway answered 401 to a concurrent request + + assert.False(t, remember(valid, SourceDesktop), + "a token refused while it was being looked up must not be served") + + token, source := GetTokenWithSource(t.Context()) + assert.Empty(t, token) + assert.Equal(t, SourceNone, source) +} + +// TestEveryRefusedTokenStaysRefused covers Docker Desktop regressing to a token +// refused before the one it serves now: a single tombstone would let the older +// one back in. +func TestEveryRefusedTokenStaysRefused(t *testing.T) { + first := makeToken(t, time.Now().Add(time.Hour)) + second := makeToken(t, time.Now().Add(time.Hour)) + + backend := &fakeBackend{token: first, loggedIn: true} + installFakeBackend(t, backend) + + require.Equal(t, first, GetToken(t.Context())) + InvalidateToken(first) + + backend.setToken(second) + require.Equal(t, second, GetToken(t.Context())) + InvalidateToken(second) + + backend.setToken(first) + assert.Empty(t, GetToken(t.Context()), "the first refused token must stay refused") +} + +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())) + }) +} + +// expireCache ages the cached token past its re-check window, so the next call +// consults Docker Desktop and the credential store again. +func expireCache() { + cache.Lock() + defer cache.Unlock() + cache.staleAt = time.Now().Add(-time.Second) +} + +// tokenSerial keeps successive tokens distinct: Docker never issues the same +// JWT twice, and a test that tells two tokens apart must not depend on the +// second they were signed in. +var tokenSerial atomic.Int64 + +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(), "jti": tokenSerial.Add(1)} + 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 +367,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 +410,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 +436,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.staleAt, cache.rejected = "", SourceNone, time.Time{}, nil + } + clearCache() + t.Cleanup(clearCache) + ln := newMemListener() server := &http.Server{Handler: backend.handler()} go func() { _ = server.Serve(ln) }() diff --git a/pkg/httpclient/authretry.go b/pkg/httpclient/authretry.go new file mode 100644 index 0000000000..0e14625b24 --- /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 0000000000..dc213fb2b8 --- /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 14caff3ce7..6381dec57f 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/hubauth/cache.go b/pkg/hubauth/cache.go new file mode 100644 index 0000000000..c9745f2532 --- /dev/null +++ b/pkg/hubauth/cache.go @@ -0,0 +1,89 @@ +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 a directory of its +// own, also owner-only, 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. +// +// File modes are POSIX-only: on Windows the token is left to the ACLs it +// inherits from the user's profile, like every other secret docker-agent +// caches (see the atomicfile package). + +type cacheEntry struct { + Credentials string `json:"credentials"` + Token string `json:"token"` +} + +// cachePath keeps the token in a subdirectory of this package's own rather +// than in the cache root: MkdirAll applies its mode only to the directories it +// creates, and the shared cache root usually already exists, world-readable. +func cachePath() string { + return filepath.Join(paths.GetCacheDir(), "hubauth", "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 + } + // The same checks a fresh exchange goes through: a file that somehow holds + // a token from another issuer, for another audience, or one due for + // renewal, is no more trustworthy than a response off the wire. + if err := validate(entry.Token); err != nil { + slog.Debug("Ignoring the cached Docker token", "error", err) + 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 0000000000..544dee040f --- /dev/null +++ b/pkg/hubauth/cache_test.go @@ -0,0 +1,133 @@ +package hubauth + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/paths" +) + +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("ignores a token that isn't a Docker Hub token", func(t *testing.T) { + resetState(t) + + store("fingerprint", makeToken(t, time.Now().Add(time.Hour), func(c jwt.MapClaims) { + c["iss"] = "https://evil.example.com/" + })) + _, ok := load("fingerprint") + assert.False(t, ok, "the file gets the same checks as a fresh exchange") + }) + + 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.MkdirAll(filepath.Dir(cachePath()), 0o700)) + 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) { + if runtime.GOOS == "windows" { + t.Skip("file modes are POSIX-only") + } + resetState(t) + // A cache directory as the rest of docker-agent leaves it: the token + // must not rely on the root being owner-only. + require.NoError(t, os.Chmod(paths.GetCacheDir(), 0o755)) + + store("fingerprint", longLived(t)) + + info, err := os.Stat(cachePath()) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + dir, err := os.Stat(filepath.Dir(cachePath())) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), dir.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 0000000000..aabfd9c597 --- /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 0000000000..2d65c82ef7 --- /dev/null +++ b/pkg/hubauth/clock_test.go @@ -0,0 +1,93 @@ +package hubauth + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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)}} +} + +// TestClockSkewIsLearnedFromTokenResponsesOnly pins where the correction may +// come from: only a 200 from the exchange endpoint proves we reached Docker, so +// an error page — from a TLS-terminating proxy, say — must not move this +// process's idea of the time, which every expiry decision depends on. +func TestClockSkewIsLearnedFromTokenResponsesOnly(t *testing.T) { + t.Run("learns from a token response", func(t *testing.T) { + // Long-lived enough to stay valid once our clock is corrected forward. + hub := installFakeHub(t, makeToken(t, time.Now().Add(3*time.Hour))) + installSecret(t, testToken) + hub.respondWith(dateHeader(time.Now().Add(time.Hour))) + + _, err := Token(t.Context()) + require.NoError(t, err) + assert.WithinDuration(t, time.Now().Add(time.Hour), now(), 5*time.Second) + }) + + t.Run("ignores an error response", func(t *testing.T) { + hub := installFakeHub(t, longLived(t)) + installSecret(t, testToken) + hub.fail(http.StatusProxyAuthRequired, dateHeader(time.Now().Add(time.Hour))) + + _, err := Token(t.Context()) + require.Error(t, err) + assert.Zero(t, clockSkew.Load()) + }) +} diff --git a/pkg/hubauth/credentials.go b/pkg/hubauth/credentials.go new file mode 100644 index 0000000000..af4496829c --- /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 0000000000..44f73f0ec5 --- /dev/null +++ b/pkg/hubauth/exchange.go @@ -0,0 +1,246 @@ +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. The +// date form is resolved against the response's own Date header: that date is +// expressed in the sender's clock, which this response may be the first thing +// to tell us about. +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(sentAt(header)), 0) + } + return 0 +} + +// sentAt returns when the response was sent, as its sender saw it, falling back +// to our own corrected clock when it said nothing. +func sentAt(header http.Header) time.Time { + if date, err := http.ParseTime(header.Get("Date")); err == nil { + return date + } + return now() +} + +// 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 0000000000..3fd62aa9ee --- /dev/null +++ b/pkg/hubauth/exchange_test.go @@ -0,0 +1,179 @@ +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)) + + // A date is only meaningful next to the clock it was written by: an hour of + // difference between the server and this machine must not turn a 90-second + // delay into a whole hour, nor into none at all. + for _, offset := range []time.Duration{time.Hour, -time.Hour} { + serverNow := time.Now().Add(offset) + header := http.Header{ + "Date": []string{serverNow.UTC().Format(http.TimeFormat)}, + "Retry-After": []string{serverNow.Add(90 * time.Second).UTC().Format(http.TimeFormat)}, + } + assert.InDelta(t, 90*time.Second, retryAfterFrom(header), 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 0000000000..55d00d5583 --- /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 0000000000..f3ffd46c5c --- /dev/null +++ b/pkg/hubauth/helpers_test.go @@ -0,0 +1,176 @@ +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 +} + +// respondWith sets extra headers the fake hub sends with a successful answer. +func (h *fakeHub) respondWith(header http.Header) { + h.mu.Lock() + defer h.mu.Unlock() + 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 0000000000..6236ce09f5 --- /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 0000000000..59a537383e --- /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 0000000000..86f71cef66 --- /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 0000000000..c8829af58f --- /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) + }) +} diff --git a/pkg/model/provider/anthropic/client.go b/pkg/model/provider/anthropic/client.go index 0317bba6de..e32af74a3e 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 75d4a6058e..b6bc2e5c5d 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 16090cd16f..d51fb43d90 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 73511c6783..5b37c58cd7 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 e17383cb1d..8f2f050938 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 {