Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions cmd/root/debug_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -41,21 +42,22 @@ 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
}

info, err := parseAuthInfo(token)
if err != nil {
return fmt.Errorf("failed to parse JWT: %w", err)
}
info.Source = string(source)

userInfo := desktop.GetUserInfo(ctx)
info.Username = userInfo.Username
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/root/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`)")
}
}

Expand Down
3 changes: 2 additions & 1 deletion cmd/root/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
182 changes: 130 additions & 52 deletions pkg/desktop/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,45 +8,136 @@ import (
"sync"
"time"

"github.com/golang-jwt/jwt/v5"
"github.com/docker/docker-agent/pkg/hubauth"
)

type DockerHubInfo struct {
Username string `json:"id"`
Email string `json:"email,omitempty"`
}

// GetToken returns Docker Desktop's access token. Desktop's newer auth stack
// (auth v2) serves whatever its in-memory token source holds and never
// refreshes on GET, so a stuck background refresher makes it return the same
// expired JWT forever — or nothing at all when its read-time refresh failed.
// When that happens we force a refresh on Desktop's side.
// Source says where a token came from, for diagnostics.
type Source string

const (
SourceNone Source = "none"
SourceDesktop Source = "docker desktop"
SourceMinted Source = "minted from the stored access token"
)

// mintToken exchanges the stored access token for a fresh one. A var so tests
// never reach the real credential store or Docker Hub.
var mintToken = hubauth.Token

// cache holds the last token known to be usable. Gateway clients are rebuilt
// for every request and each one asks for a token, so without this every LLM
// call would pay a round-trip to Docker Desktop over its socket.
var cache struct {
sync.Mutex

token string
source Source

// rejected is the last token Docker refused. Docker Desktop would keep
// serving it — it has no idea it was refused, and can't be told — so it is
// remembered here to make sure we mint one instead.
rejected string
}

// GetToken returns the user's Docker access token, or "" when there is none.
func GetToken(ctx context.Context) string {
token, _ := GetTokenWithSource(ctx)
return token
}

// GetTokenWithSource returns the user's Docker access token and where it came
// from. Docker Desktop's newer auth stack (auth v2) serves whatever its
// in-memory token source holds and never refreshes on GET, so a stuck
// background refresher makes it return the same expired JWT forever — or
// nothing at all when its read-time refresh failed. When that happens we mint
// a token ourselves from the access token `docker login` stored, and only then
// fall back to nudging Desktop.
func GetTokenWithSource(ctx context.Context) (string, Source) {
if token, source, ok := cached(); ok {
return token, source
}

token, err := fetchToken(ctx)
if err == nil && token != "" && !tokenExpired(token) {
return token
if err == nil && token != "" && !hubauth.Expiring(token) && !wasRejected(token) {
return remember(token, SourceDesktop)
}

logUnusableToken(ctx, token, err)

// Minting needs no help from Desktop and, unlike a forced refresh, is
// deterministic: try it first.
minted, mintErr := mintToken(ctx)
if mintErr == nil && minted != "" {
return remember(minted, SourceMinted)
}
slog.DebugContext(ctx, "Could not mint a Docker token from the credential store", "error", mintErr)

// Signed out: a forced refresh can't help and would delay every caller.
if token == "" && !isLoggedIn(ctx) {
return ""
return "", SourceNone
}

if fresh := forceTokenRefresh(ctx); fresh != "" {
slog.InfoContext(ctx, "Recovered a fresh token from Docker Desktop",
"fingerprint", tokenFingerprint(fresh))
return fresh
return remember(fresh, SourceDesktop)
}
if token == "" {
if token == "" || wasRejected(token) {
slog.WarnContext(ctx, "Token refresh failed, no token available")
return ""
return "", SourceNone
}
slog.WarnContext(ctx, "Token refresh failed, sending a token known to be expired",
slog.WarnContext(ctx, "Token refresh failed, sending a token that expired or is about to",
"fingerprint", tokenFingerprint(token),
"expired_for", expiredFor(token))
return token
"expires_in", expiresIn(token))
return token, SourceDesktop
}

// InvalidateToken forgets token, everywhere it may be cached, so the next
// [GetToken] fetches or mints a new one. Called when Docker rejects a token we
// believed to be valid: only the issuer knows for sure.
func InvalidateToken(token string) {
if token == "" {
return
}

cache.Lock()
if cache.token == token {
cache.token, cache.source = "", SourceNone
}
cache.rejected = token
cache.Unlock()

hubauth.Invalidate(token)
}

func cached() (string, Source, bool) {
cache.Lock()
defer cache.Unlock()

if cache.token == "" || hubauth.Expiring(cache.token) {
return "", SourceNone, false
}
return cache.token, cache.source, true
}

func wasRejected(token string) bool {
cache.Lock()
defer cache.Unlock()

return token != "" && token == cache.rejected
}

func remember(token string, source Source) (string, Source) {
cache.Lock()
defer cache.Unlock()

cache.token, cache.source = token, source
return token, source
}

// logUnusableToken records why Docker Desktop's token can't be used as-is,
Expand All @@ -57,12 +148,15 @@ func logUnusableToken(ctx context.Context, token string, err error) {
slog.WarnContext(ctx, "Failed to fetch a token from Docker Desktop", "error", err)
case token == "":
slog.WarnContext(ctx, "Docker Desktop served an empty token")
case wasRejected(token):
slog.WarnContext(ctx, "Docker Desktop served a token Docker refused",
"fingerprint", tokenFingerprint(token))
default:
attrs := []any{"fingerprint", tokenFingerprint(token), "expired_for", expiredFor(token)}
if exp, ok := tokenExpiry(token); ok {
attrs := []any{"fingerprint", tokenFingerprint(token), "expires_in", expiresIn(token)}
if exp, ok := hubauth.Expiry(token); ok {
attrs = append(attrs, "expires_at", exp.UTC().Format(time.RFC3339))
}
slog.WarnContext(ctx, "Docker Desktop served an expired token", attrs...)
slog.WarnContext(ctx, "Docker Desktop served a token that expired or is about to", attrs...)
}
}

Expand All @@ -72,33 +166,30 @@ func tokenFingerprint(token string) string {
return hex.EncodeToString(sum[:4])
}

// expiredFor returns how long ago the token's exp claim passed.
func expiredFor(token string) string {
exp, ok := tokenExpiry(token)
// expiresIn returns how long the token has left, negative once its exp claim
// has passed.
func expiresIn(token string) string {
exp, ok := hubauth.Expiry(token)
if !ok {
return "unknown"
}
return time.Since(exp).Round(time.Second).String()
}

// tokenExpiry returns the token's exp claim, or false when the token doesn't
// parse or carries no exp claim.
func tokenExpiry(token string) (time.Time, bool) {
parsed, _, err := jwt.NewParser().ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return time.Time{}, false
}
exp, err := parsed.Claims.GetExpirationTime()
if err != nil || exp == nil {
return time.Time{}, false
}
return exp.Time, true
return time.Until(exp).Round(time.Second).String()
}

// GetUserInfo returns the signed-in account. Docker Desktop knows it best, but
// it is not always around: the token itself carries the same information.
func GetUserInfo(ctx context.Context) DockerHubInfo {
var info DockerHubInfo
_ = ClientBackend.Get(ctx, "/registry/info", &info)
return info
if info.Username != "" {
return info
}

identity, ok := hubauth.IdentityFromToken(GetToken(ctx))
if !ok {
return info
}
return DockerHubInfo{Username: identity.Username, Email: identity.Email}
}

func fetchToken(ctx context.Context) (string, error) {
Expand All @@ -115,19 +206,6 @@ func isLoggedIn(ctx context.Context) bool {
return loggedIn
}

// tokenExpired reports whether the JWT's exp claim is in the past, with
// leeway for clock skew between this machine and the token issuer.
// Tokens that don't parse or carry no exp claim are treated as valid.
func tokenExpired(token string) bool {
exp, ok := tokenExpiry(token)
if !ok {
return false
}
return exp.Before(time.Now().Add(-expiryLeeway))
}

const expiryLeeway = 30 * time.Second

var refreshState struct {
sync.Mutex

Expand Down Expand Up @@ -166,7 +244,7 @@ func forceTokenRefresh(ctx context.Context) string {
// result if still valid.
token := refreshState.result
refreshState.Unlock()
if token != "" && !tokenExpired(token) {
if token != "" && !hubauth.Expiring(token) {
return token
}
return ""
Expand Down Expand Up @@ -223,7 +301,7 @@ func runTokenRefresh(ctx context.Context) string {

for {
// Check right away: Desktop may have refreshed synchronously.
if token, err := fetchToken(ctx); err == nil && token != "" && !tokenExpired(token) {
if token, err := fetchToken(ctx); err == nil && token != "" && !hubauth.Expiring(token) {
return token
}
select {
Expand Down
Loading
Loading