From 816c83e4013e75a322ce6ffce2b2b000954c5962 Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:48:00 +0200 Subject: [PATCH] fix(registry): read the Desktop session expiry from the token when claims lack it The session payload may carry no claims, in which case the access token was reused for a minute without any expiry check, so an expired token could reach the registry and fail the pull instead of falling back. Read the exp claim from the JWT itself in that case. An expiry that cannot be read is still tolerated. Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- internal/registry/desktop.go | 39 +++++++++++++++++++++++-- internal/registry/desktop_test.go | 47 +++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/internal/registry/desktop.go b/internal/registry/desktop.go index fa1569778..821eada87 100644 --- a/internal/registry/desktop.go +++ b/internal/registry/desktop.go @@ -18,8 +18,11 @@ package registry import ( "context" + "encoding/base64" + "encoding/json" "errors" "fmt" + "strings" "sync" "time" @@ -195,8 +198,8 @@ func (s *desktopSession) fetch() (clitypes.AuthConfig, time.Time, error) { now := s.now() validUntil := now.Add(sessionTTL) - if exp := session.Claims.ExpiresAt; exp != nil { - validUntil = exp.Add(-sessionExpirySkew) + if expiresAt, ok := sessionExpiry(session); ok { + validUntil = expiresAt.Add(-sessionExpirySkew) if !now.Before(validUntil) { return clitypes.AuthConfig{}, time.Time{}, errSessionExpired } @@ -209,6 +212,38 @@ func (s *desktopSession) fetch() (clitypes.AuthConfig, time.Time, error) { }, validUntil, nil } +// sessionExpiry returns when the session's access token expires: from the +// session claims, or, when the payload carries none, from the token itself. +// An expiry that cannot be read is tolerated, the token is then reused for +// sessionTTL only. +func sessionExpiry(session dockerhub.UserSession) (time.Time, bool) { + if exp := session.Claims.ExpiresAt; exp != nil { + return exp.Time, true + } + return jwtExpiry(session.AccessToken) +} + +// jwtExpiry reads the exp claim of a JWT without verifying its signature: it +// only decides whether the token is still worth sending, the registry +// verifies it. +func jwtExpiry(token string) (time.Time, bool) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{}, false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{}, false + } + var claims struct { + ExpiresAt *dockerhub.NumericDate `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil || claims.ExpiresAt == nil { + return time.Time{}, false + } + return claims.ExpiresAt.Time, true +} + // logDesktopFallback reports why Docker Hub credentials come from the // fallback. Desktop not running and nobody signed in are routine and only // logged at debug level. That includes an expired session: Desktop keeps the diff --git a/internal/registry/desktop_test.go b/internal/registry/desktop_test.go index f8b7e0970..d8975e1f7 100644 --- a/internal/registry/desktop_test.go +++ b/internal/registry/desktop_test.go @@ -18,6 +18,7 @@ package registry import ( "context" + "encoding/base64" "errors" "fmt" "sync" @@ -293,6 +294,52 @@ func TestDesktopAuthProvider_RefetchesSessionWithoutExpiry(t *testing.T) { assert.Equal(t, hub.sessionCalls.Load(), int32(2)) } +// fakeJWT builds an unsigned JWT carrying only an exp claim. +func fakeJWT(expiresAt time.Time) string { + enc := base64.RawURLEncoding.EncodeToString + return enc([]byte(`{"alg":"none"}`)) + "." + enc(fmt.Appendf(nil, `{"exp":%d}`, expiresAt.Unix())) + ".sig" +} + +func TestDesktopAuthProvider_ExpiryFromTokenWhenClaimsLackIt(t *testing.T) { + t.Run("expired token falls back", func(t *testing.T) { + session := hubSession("hubuser", time.Time{}) + session.AccessToken = fakeJWT(testNow.Add(-time.Minute)) + p := newTestProvider(&fakeHub{session: session}, nil) + + got, err := p.GetAuthConfig("docker.io") + assert.NilError(t, err) + assert.DeepEqual(t, got, cliCredentials("docker.io")) + }) + + t.Run("valid token is reused until it expires", func(t *testing.T) { + session := hubSession("hubuser", time.Time{}) + session.AccessToken = fakeJWT(testNow.Add(10 * time.Minute)) + hub := &fakeHub{session: session} + p := newTestProvider(hub, nil) + + got, err := p.GetAuthConfig("docker.io") + assert.NilError(t, err) + assert.Equal(t, got.Password, session.AccessToken) + + // Past sessionTTL, but the token's own expiry still applies. + p.clock.advance(sessionTTL + time.Minute) + _, err = p.GetAuthConfig("docker.io") + assert.NilError(t, err) + assert.Equal(t, hub.sessionCalls.Load(), int32(1)) + }) +} + +func TestJWTExpiry(t *testing.T) { + exp, ok := jwtExpiry(fakeJWT(testNow)) + assert.Assert(t, ok) + assert.Assert(t, exp.Equal(testNow)) + + for _, token := range []string{"", "opaque-token", "a.%%%.c", "a." + base64.RawURLEncoding.EncodeToString([]byte(`{}`)) + ".c"} { + _, ok := jwtExpiry(token) + assert.Assert(t, !ok, token) + } +} + func TestDesktopAuthProvider_ConcurrentLookupsShareOneFetch(t *testing.T) { hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))} p := newTestProvider(hub, nil)