Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
24 changes: 20 additions & 4 deletions client/dockerhub/dockerhub.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package dockerhub

import (
"cmp"
"context"
"encoding/json"
"errors"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
66 changes: 66 additions & 0 deletions client/dockerhub/dockerhub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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())
Expand Down
Loading