From cc05ccac43797a4c21bf7e9861d4d79bdd662fdc Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:27:50 +0200 Subject: [PATCH 1/2] feat(client): return the account username with the Docker Hub session GetDefaultSession already loads the default profile to find the account entry, but threw it away. Since the token's username claim is often empty, callers had to call GetDefaultProfile again to get a username for a registry credential. Add UserSession.Username. GetDefaultSession fills it from the default profile, falling back to the username claim; GetSession fills it from the requested username. The field is not decoded from the stored payload. Refs #682 Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- README.md | 1 + client/dockerhub/dockerhub.go | 25 +++++++++-- client/dockerhub/dockerhub_test.go | 67 ++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 37af9773..e12e4fde 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ if err != nil { } fmt.Println(session.AccessToken) // the raw JWT access token +fmt.Println(session.Username) // the account the session belongs to fmt.Println(session.Claims.Username) // decoded token claims fmt.Println(session.Claims.ExpiresAt) diff --git a/client/dockerhub/dockerhub.go b/client/dockerhub/dockerhub.go index 066a75c0..7e681fc9 100644 --- a/client/dockerhub/dockerhub.go +++ b/client/dockerhub/dockerhub.go @@ -18,6 +18,7 @@ package dockerhub import ( + "cmp" "context" "encoding/json" "errors" @@ -50,6 +51,9 @@ type UserSession struct { AccessToken string `json:"access_token"` // Claims are zero when the payload carries none. Claims Claims `json:"claims"` + // Username is the Docker Hub account the session belongs to. It is filled + // in from the account lookup, not decoded from the stored payload. + Username string `json:"-"` } // Claims are the claims of a Docker Hub access token. @@ -174,9 +178,12 @@ type ClientAuth interface { GetDefaultProfile(ctx context.Context) (Profile, error) // GetDefaultSession returns the default account's session: // [ErrNoDefaultProfile] when no default is set, [ErrNoSession] when its - // credential is missing. + // credential is missing. The session's Username is the default profile's + // username, falling back to the token's username claim, so callers do not + // need to call GetDefaultProfile as well. GetDefaultSession(ctx context.Context) (UserSession, error) - // GetSession returns the session for username, or [ErrNoSession]. + // GetSession returns the session for username, or [ErrNoSession]. The + // session's Username is username. GetSession(ctx context.Context, username string) (UserSession, error) } @@ -292,7 +299,12 @@ func (c clientAuth) GetDefaultSession(ctx context.Context) (UserSession, error) if !c.cfg.accountEntry.Match(id) { return UserSession{}, fmt.Errorf("default profile user id %q is not an account entry in the %s realm", profile.UserID, c.cfg.accounts) } - return c.getSession(ctx, exactPattern(id)) + session, err := c.getSession(ctx, exactPattern(id)) + if err != nil { + return UserSession{}, err + } + session.Username = cmp.Or(profile.Username, session.Claims.Username) + return session, nil } func (c clientAuth) GetSession(ctx context.Context, username string) (UserSession, error) { @@ -307,7 +319,12 @@ func (c clientAuth) GetSession(ctx context.Context, username string) (UserSessio if err != nil { return UserSession{}, err } - return c.getSession(ctx, exactPattern(id)) + session, err := c.getSession(ctx, exactPattern(id)) + if err != nil { + return UserSession{}, err + } + session.Username = username + return session, nil } func (c clientAuth) getSession(ctx context.Context, pattern secrets.Pattern) (UserSession, error) { diff --git a/client/dockerhub/dockerhub_test.go b/client/dockerhub/dockerhub_test.go index 672a4d6f..082d3580 100644 --- a/client/dockerhub/dockerhub_test.go +++ b/client/dockerhub/dockerhub_test.go @@ -94,6 +94,17 @@ func (e nilIDEngine) GetSecrets(_ context.Context, pattern secrets.Pattern) ([]s return envelopes, nil } +// countingEngine records the pattern of every lookup it serves. +type countingEngine struct { + secrets.Resolver + patterns []string +} + +func (c *countingEngine) GetSecrets(ctx context.Context, pattern secrets.Pattern) ([]secrets.Envelope, error) { + c.patterns = append(c.patterns, pattern.String()) + return c.Resolver.GetSecrets(ctx, pattern) +} + type staticEngine struct { envelopes []secrets.Envelope } @@ -142,6 +153,15 @@ func TestGetSession(t *testing.T) { assert.Equal(t, "session-1", session.Claims.SessionID) assert.Equal(t, "alice@example.com", session.Claims.Email) assert.Equal(t, "alice", session.Claims.Username) + assert.Equal(t, "alice", session.Username) + }) + t.Run("username is the requested account", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/hub/alice": `{"access_token":"tok","username":"mallory"}`, + }) + session, err := hub(t, engine).GetSession(t.Context(), "alice") + require.NoError(t, err) + assert.Equal(t, "alice", session.Username) }) t.Run("audience as single string", func(t *testing.T) { engine := serving(map[string]string{ @@ -264,6 +284,53 @@ func TestGetDefaultSession(t *testing.T) { require.NoError(t, err) assert.Equal(t, "token-alice", session.AccessToken) assert.Equal(t, "alice", session.Claims.Username) + assert.Equal(t, "alice", session.Username) + }) + t.Run("username from the profile when claims lack it", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": profileWire, + "docker/auth/hub/alice": `{"access_token":"tok"}`, + }) + session, err := hub(t, engine).GetDefaultSession(t.Context()) + require.NoError(t, err) + assert.Empty(t, session.Claims.Username) + assert.Equal(t, "alice", session.Username) + }) + t.Run("username from claims when the profile lacks it", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": `{"user_id":"docker/auth/hub/alice"}`, + "docker/auth/hub/alice": sessionWire, + }) + session, err := hub(t, engine).GetDefaultSession(t.Context()) + require.NoError(t, err) + assert.Equal(t, "alice", session.Username) + }) + t.Run("profile username takes precedence over claims", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": profileWire, + "docker/auth/hub/alice": `{"access_token":"tok","claims":{"username":"Alice"}}`, + }) + session, err := hub(t, engine).GetDefaultSession(t.Context()) + require.NoError(t, err) + assert.Equal(t, "alice", session.Username) + }) + t.Run("username empty when neither profile nor claims carry one", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": `{"user_id":"docker/auth/hub/alice"}`, + "docker/auth/hub/alice": `{"access_token":"tok"}`, + }) + session, err := hub(t, engine).GetDefaultSession(t.Context()) + require.NoError(t, err) + assert.Empty(t, session.Username) + }) + t.Run("reads the profile and the session once each", func(t *testing.T) { + engine := &countingEngine{Resolver: serving(map[string]string{ + "docker/auth/metadata/hub/default": profileWire, + "docker/auth/hub/alice": sessionWire, + })} + _, err := hub(t, engine).GetDefaultSession(t.Context()) + require.NoError(t, err) + assert.Equal(t, []string{"docker/auth/metadata/hub/default", "docker/auth/hub/alice"}, engine.patterns) }) t.Run("no default profile", func(t *testing.T) { _, err := hub(t, serving(nil)).GetDefaultSession(t.Context()) From 044562db7e7398e1c4986092a8178c306b2adfc3 Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:30:14 +0200 Subject: [PATCH 2/2] chore(client): trim dockerhub Username godoc and test comment Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- client/dockerhub/dockerhub.go | 3 +-- client/dockerhub/dockerhub_test.go | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/client/dockerhub/dockerhub.go b/client/dockerhub/dockerhub.go index 7e681fc9..81a5e4f4 100644 --- a/client/dockerhub/dockerhub.go +++ b/client/dockerhub/dockerhub.go @@ -51,8 +51,7 @@ type UserSession struct { AccessToken string `json:"access_token"` // Claims are zero when the payload carries none. Claims Claims `json:"claims"` - // Username is the Docker Hub account the session belongs to. It is filled - // in from the account lookup, not decoded from the stored payload. + // Username is the Docker Hub account the session belongs to. Username string `json:"-"` } diff --git a/client/dockerhub/dockerhub_test.go b/client/dockerhub/dockerhub_test.go index 082d3580..0253a014 100644 --- a/client/dockerhub/dockerhub_test.go +++ b/client/dockerhub/dockerhub_test.go @@ -94,7 +94,6 @@ func (e nilIDEngine) GetSecrets(_ context.Context, pattern secrets.Pattern) ([]s return envelopes, nil } -// countingEngine records the pattern of every lookup it serves. type countingEngine struct { secrets.Resolver patterns []string