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..81a5e4f4 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,8 @@ 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. + Username string `json:"-"` } // Claims are the claims of a Docker Hub access token. @@ -174,9 +177,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 +298,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 +318,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..0253a014 100644 --- a/client/dockerhub/dockerhub_test.go +++ b/client/dockerhub/dockerhub_test.go @@ -94,6 +94,16 @@ func (e nilIDEngine) GetSecrets(_ context.Context, pattern secrets.Pattern) ([]s return envelopes, nil } +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 +152,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 +283,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())