Skip to content
Merged
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
56 changes: 40 additions & 16 deletions pkg/desktop/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,35 +19,51 @@ type DockerHubInfo struct {
// 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. When that happens we force a refresh on Desktop's side.
// expired JWT forever — or nothing at all when its read-time refresh failed.
// When that happens we force a refresh on Desktop's side.
func GetToken(ctx context.Context) string {
token := fetchToken(ctx)
if token == "" || !tokenExpired(token) {
token, err := fetchToken(ctx)
if err == nil && token != "" && !tokenExpired(token) {
return token
}

logExpiredToken(ctx, token)
logUnusableToken(ctx, token, err)

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

if fresh := forceTokenRefresh(ctx); fresh != "" {
slog.InfoContext(ctx, "Recovered a fresh token from Docker Desktop",
"fingerprint", tokenFingerprint(fresh))
return fresh
}
if token == "" {
slog.WarnContext(ctx, "Token refresh failed, no token available")
return ""
}
slog.WarnContext(ctx, "Token refresh failed, sending a token known to be expired",
"fingerprint", tokenFingerprint(token),
"expired_for", expiredFor(token))
return token
}

// logExpiredToken records evidence that Docker Desktop served an
// already-expired token, so gateway 401s can be attributed to a stale Desktop
// session rather than a rejection of a valid token.
func logExpiredToken(ctx context.Context, token string) {
attrs := []any{"fingerprint", tokenFingerprint(token), "expired_for", expiredFor(token)}
if exp, ok := tokenExpiry(token); ok {
attrs = append(attrs, "expires_at", exp.UTC().Format(time.RFC3339))
// logUnusableToken records why Docker Desktop's token can't be used as-is,
// so gateway auth failures can be attributed from logs.
func logUnusableToken(ctx context.Context, token string, err error) {
switch {
case err != nil:
slog.WarnContext(ctx, "Failed to fetch a token from Docker Desktop", "error", err)
case token == "":
slog.WarnContext(ctx, "Docker Desktop served an empty token")
default:
attrs := []any{"fingerprint", tokenFingerprint(token), "expired_for", expiredFor(token)}
if exp, ok := tokenExpiry(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 an expired token", attrs...)
}

// tokenFingerprint returns a short non-reversible identifier, safe to log.
Expand Down Expand Up @@ -85,10 +101,18 @@ func GetUserInfo(ctx context.Context) DockerHubInfo {
return info
}

func fetchToken(ctx context.Context) string {
func fetchToken(ctx context.Context) (string, error) {
var token string
_ = ClientBackend.Get(ctx, "/registry/token", &token)
return token
err := ClientBackend.Get(ctx, "/registry/token", &token)
return token, err
}

func isLoggedIn(ctx context.Context) bool {
var loggedIn bool
if err := ClientBackend.Get(ctx, "/registry/is-logged-in", &loggedIn); err != nil {
return false
}
return loggedIn
}

// tokenExpired reports whether the JWT's exp claim is in the past, with
Expand Down Expand Up @@ -199,7 +223,7 @@ func runTokenRefresh(ctx context.Context) string {

for {
// Check right away: Desktop may have refreshed synchronously.
if token := fetchToken(ctx); token != "" && !tokenExpired(token) {
if token, err := fetchToken(ctx); err == nil && token != "" && !tokenExpired(token) {
return token
}
select {
Expand Down
56 changes: 48 additions & 8 deletions pkg/desktop/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,34 @@ func TestGetToken(t *testing.T) {
assert.Equal(t, 0, backend.refreshes())
})

t.Run("empty token returned as-is", func(t *testing.T) {
t.Run("empty token while signed out returns without refresh", func(t *testing.T) {
backend := &fakeBackend{}
installFakeBackend(t, backend)

assert.Empty(t, GetToken(t.Context()))
assert.Equal(t, 0, backend.refreshes())
})

t.Run("empty token while signed in triggers forced refresh", func(t *testing.T) {
backend := &fakeBackend{loggedIn: true}
backend.onRefresh = func() { backend.setToken(valid) }
installFakeBackend(t, backend)

assert.Equal(t, valid, GetToken(t.Context()))
assert.Equal(t, 1, backend.refreshes())
})

t.Run("failed token fetch while signed in triggers forced refresh", func(t *testing.T) {
backend := &fakeBackend{loggedIn: true, failTokenFetch: true}
backend.onRefresh = func() {
backend.setToken(valid)
backend.setFailTokenFetch(false)
}
installFakeBackend(t, backend)

assert.Equal(t, valid, GetToken(t.Context()))
assert.Equal(t, 1, backend.refreshes())
})
}

func TestTokenExpired(t *testing.T) {
Expand All @@ -126,13 +147,16 @@ func makeToken(t *testing.T, exp time.Time) string {
}

// fakeBackend emulates Docker Desktop's backend API: GET /registry/token
// serves the current token; POST /registry/credstore-updated triggers
// onRefresh (Desktop's async AutoLogin).
// serves the current token; GET /registry/is-logged-in reports session state;
// POST /registry/credstore-updated triggers onRefresh (Desktop's async
// AutoLogin).
type fakeBackend struct {
mu sync.Mutex
token string
refreshCalls int
onRefresh func()
mu sync.Mutex
token string
loggedIn bool
failTokenFetch bool
refreshCalls int
onRefresh func()
}

func (b *fakeBackend) setToken(token string) {
Expand All @@ -141,6 +165,12 @@ func (b *fakeBackend) setToken(token string) {
b.token = token
}

func (b *fakeBackend) setFailTokenFetch(fail bool) {
b.mu.Lock()
defer b.mu.Unlock()
b.failTokenFetch = fail
}

func (b *fakeBackend) refreshes() int {
b.mu.Lock()
defer b.mu.Unlock()
Expand All @@ -151,10 +181,20 @@ func (b *fakeBackend) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /registry/token", func(w http.ResponseWriter, _ *http.Request) {
b.mu.Lock()
token := b.token
token, fail := b.token, b.failTokenFetch
b.mu.Unlock()
if fail {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(token)
})
mux.HandleFunc("GET /registry/is-logged-in", func(w http.ResponseWriter, _ *http.Request) {
b.mu.Lock()
loggedIn := b.loggedIn
b.mu.Unlock()
_ = json.NewEncoder(w).Encode(loggedIn)
})
mux.HandleFunc("POST /registry/credstore-updated", func(http.ResponseWriter, *http.Request) {
b.mu.Lock()
b.refreshCalls++
Expand Down
4 changes: 4 additions & 0 deletions pkg/desktop/raw_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ func (c *RawClient) Get(ctx context.Context, endpoint string, v any) error {
return err
}

if response.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("GET %s: %s", endpoint, response.Status)
}

if err := json.Unmarshal(buf, &v); err != nil {
return err
}
Expand Down
Loading