diff --git a/README.md b/README.md index 24f3d2e5..7f408730 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,55 @@ if err != nil { fmt.Println(secrets[0].Value) ``` +## How to fetch a Docker Hub access token + +The client exposes a Docker Hub authentication accessor via `HubAuth()`. It +locates the right credential and decodes the JSON payload into a typed +`UserSession`, so you don't need to know the realm layout or the payload +format: + +```go +import ( + "github.com/docker/secrets-engine/client" + "github.com/docker/secrets-engine/client/dockerhub" +) + +c, err := client.New() +if err != nil { + log.Fatalf("failed to create secrets engine client: %v", err) +} +hub := c.HubAuth() + +// Fetch the session of the default signed-in account ... +session, err := hub.GetDefaultSession(context.Background()) +if errors.Is(err, dockerhub.ErrNoSession) { + // Also matches dockerhub.ErrNoDefaultProfile, which wraps ErrNoSession. + log.Fatalf("not signed in to Docker Hub") +} +if err != nil { + log.Fatalf("failed fetching access token: %v", err) +} + +// ... or fetch the session of a specific account. +session, err = hub.GetSession(context.Background(), "myuser") +if err != nil { + log.Fatalf("failed fetching access token: %v", err) +} + +fmt.Println(session.AccessToken) // the raw JWT access token +fmt.Println(session.Claims.Username) // decoded token claims +fmt.Println(session.Claims.ExpiresAt) + +// List the profiles of all signed-in accounts. +profiles, err := hub.ListProfiles(context.Background()) +if err != nil { + log.Fatalf("failed listing profiles: %v", err) +} +for _, profile := range profiles { + fmt.Println(profile.Username, profile.UserID) +} +``` + ## How to create a plugin ### 1. Implement the plugin interface diff --git a/client/client.go b/client/client.go index a71546f2..2d13eb18 100644 --- a/client/client.go +++ b/client/client.go @@ -24,6 +24,7 @@ import ( "connectrpc.com/connect" + "github.com/docker/secrets-engine/client/dockerhub" "github.com/docker/secrets-engine/x/api" healthv1 "github.com/docker/secrets-engine/x/api/health/v1" "github.com/docker/secrets-engine/x/api/health/v1/healthv1connect" @@ -152,6 +153,10 @@ func (c client) GetSecrets(ctx context.Context, pattern secrets.Pattern) ([]secr return envelopes, nil } +func (c client) HubAuth(opts ...dockerhub.Option) dockerhub.ClientAuth { + return dockerhub.New(c, opts...) +} + func (c client) Version(ctx context.Context) (DaemonVersion, error) { resp, err := c.versionClient.GetVersion(ctx, connect.NewRequest(healthv1.GetVersionRequest_builder{}.Build())) if isDialError(err) { @@ -174,6 +179,9 @@ type Client interface { // Version returns the name and version reported by the daemon. Version(ctx context.Context) (DaemonVersion, error) + + // HubAuth returns a Docker Hub authentication accessor backed by this client. + HubAuth(opts ...dockerhub.Option) dockerhub.ClientAuth } type PluginManagement interface { diff --git a/client/client_test.go b/client/client_test.go index 65829a13..f652d1d6 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -29,6 +29,7 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/docker/secrets-engine/client/dockerhub" "github.com/docker/secrets-engine/x/api" healthv1 "github.com/docker/secrets-engine/x/api/health/v1" "github.com/docker/secrets-engine/x/api/health/v1/healthv1connect" @@ -319,6 +320,13 @@ func TestSecretsEngineUnavailable(t *testing.T) { require.ErrorIs(t, err, ErrSecretsEngineNotAvailable) } +func TestHubAuth(t *testing.T) { + client, err := New(WithSocketPath(testhelper.RandomShortSocketName())) + require.NoError(t, err) + assert.NotNil(t, client.HubAuth()) + assert.NotNil(t, client.HubAuth(dockerhub.Staging())) +} + func TestIsDialError(t *testing.T) { require.True(t, isDialError(&net.OpError{ Op: "dial", diff --git a/client/dockerhub/dockerhub.go b/client/dockerhub/dockerhub.go new file mode 100644 index 00000000..066a75c0 --- /dev/null +++ b/client/dockerhub/dockerhub.go @@ -0,0 +1,345 @@ +// Copyright 2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package dockerhub reads Docker Hub access tokens and account profiles from +// the secrets engine. Obtain a [ClientAuth] from the client's HubAuth method, +// or from any [secrets.Resolver] via [New]. +package dockerhub + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/docker/secrets-engine/client/realms" + "github.com/docker/secrets-engine/x/secrets" +) + +var ( + // ErrNoSession means no Docker Hub credential is stored for the account. + ErrNoSession = errors.New("user is not authenticated for this application") + // ErrNoDefaultProfile means no account is set as the default. It wraps + // [ErrNoSession]. + ErrNoDefaultProfile = fmt.Errorf("no default account profile set: %w", ErrNoSession) +) + +var ( + defaultProfileKey = secrets.MustParsePattern("default") + singleEntryKey = secrets.MustParsePattern("*") +) + +// UserSession is a stored Docker Hub credential. +type UserSession struct { + // AccessToken is a Docker Hub issued JWT. + AccessToken string `json:"access_token"` + // Claims are zero when the payload carries none. + Claims Claims `json:"claims"` +} + +// Claims are the claims of a Docker Hub access token. +type Claims struct { + Issuer string `json:"iss,omitempty"` + Subject string `json:"sub,omitempty"` + Audience Audience `json:"aud,omitempty"` + ExpiresAt *NumericDate `json:"exp,omitempty"` + NotBefore *NumericDate `json:"nbf,omitempty"` + IssuedAt *NumericDate `json:"iat,omitempty"` + ID string `json:"jti,omitempty"` + + // Scope is a space-delimited list of granted scopes. + Scope string `json:"scope,omitempty"` + // AppName is the Docker client application the token was issued to. + AppName string `json:"app_name"` + UUID string `json:"uuid"` + // Source is formatted as `docker_{type}|{id}`. + Source string `json:"source"` + SessionID string `json:"session_id"` + ClientID string `json:"client_id,omitempty"` + ClientName string `json:"client_name,omitempty"` + Email string `json:"email"` + Username string `json:"username"` +} + +// NumericDate is an RFC 7519 numeric date: UNIX epoch seconds. It marshals +// truncated to whole seconds. +type NumericDate struct { + time.Time +} + +func (d NumericDate) MarshalJSON() ([]byte, error) { + return []byte(strconv.FormatInt(d.Unix(), 10)), nil +} + +func (d *NumericDate) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + var epoch float64 + if err := json.Unmarshal(data, &epoch); err != nil { + return fmt.Errorf("parse numeric date: %w", err) + } + if math.Abs(epoch) > 1e15 { + return fmt.Errorf("parse numeric date: %v is out of range", epoch) + } + seconds, fraction := math.Modf(epoch) + d.Time = time.Unix(int64(seconds), int64(fraction*float64(time.Second))) + return nil +} + +// Audience is the "aud" claim: a single string or an array of strings. +type Audience []string + +func (a *Audience) UnmarshalJSON(data []byte) error { + var value any + if err := json.Unmarshal(data, &value); err != nil { + return fmt.Errorf("parse audience: %w", err) + } + switch v := value.(type) { + case nil: + case string: + *a = Audience{v} + case []any: + audience := make(Audience, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if !ok { + return fmt.Errorf("parse audience: unexpected element type %T", item) + } + audience = append(audience, s) + } + *a = audience + default: + return fmt.Errorf("parse audience: unexpected type %T", value) + } + return nil +} + +// Profile describes a signed-in Docker Hub account. +type Profile struct { + // UserID is the secret ID where the account's credential is stored. + UserID string `json:"user_id"` + // OriginalSignInApp is the Docker client application the user signed in from. + OriginalSignInApp string `json:"original_sign_in_app"` + Username string `json:"username"` + Email string `json:"email"` + SignInDate time.Time `json:"sign_in_date"` +} + +// parseUserSession decodes a docker/auth/hub/** envelope into a [UserSession]. +func parseUserSession(envelope secrets.Envelope) (UserSession, error) { + var session UserSession + if err := json.Unmarshal(envelope.Value, &session); err != nil { + return UserSession{}, fmt.Errorf("decode user session: %w", err) + } + if session.AccessToken == "" { + return UserSession{}, errors.New("decode user session: no access token in payload") + } + return session, nil +} + +// parseProfile decodes a docker/auth/metadata/hub/** envelope into a [Profile]. +func parseProfile(envelope secrets.Envelope) (Profile, error) { + var profile Profile + if err := json.Unmarshal(envelope.Value, &profile); err != nil { + return Profile{}, fmt.Errorf("decode profile metadata: %w", err) + } + if profile.UserID == "" { + return Profile{}, errors.New("decode profile metadata: no user ID in payload") + } + return profile, nil +} + +// ClientAuth reads Docker Hub authentication state from the secrets engine. +type ClientAuth interface { + // ListProfiles returns the profiles of all signed-in accounts. + ListProfiles(ctx context.Context) ([]Profile, error) + // GetDefaultProfile returns the default account's profile, or + // [ErrNoDefaultProfile] when no default is set. + 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. + GetDefaultSession(ctx context.Context) (UserSession, error) + // GetSession returns the session for username, or [ErrNoSession]. + GetSession(ctx context.Context, username string) (UserSession, error) +} + +// Option configures a [ClientAuth]. +type Option func(*config) + +type config struct { + accounts secrets.Pattern + profiles secrets.Pattern + defaultEntry secrets.Pattern + accountEntry secrets.Pattern +} + +// Staging switches the lookup to the Docker Hub staging realms. +func Staging() Option { + return func(c *config) { + c.accounts = realms.DockerHubStagingAuthentication + c.profiles = realms.DockerHubStagingAuthenticationMetadata + } +} + +func newConfig(opts []Option) config { + cfg := config{ + accounts: realms.DockerHubAuthentication, + profiles: realms.DockerHubAuthenticationMetadata, + } + for _, opt := range opts { + opt(&cfg) + } + cfg.defaultEntry = mustExpand(cfg.profiles, defaultProfileKey) + cfg.accountEntry = mustExpand(cfg.accounts, singleEntryKey) + return cfg +} + +func mustExpand(realm, key secrets.Pattern) secrets.Pattern { + expanded, err := realm.ExpandPattern(key) + if err != nil { + panic(fmt.Sprintf("dockerhub: expand %s in %s: %v", key, realm, err)) + } + return expanded +} + +var _ ClientAuth = clientAuth{} + +type clientAuth struct { + engine secrets.Resolver + cfg config +} + +// New returns a [ClientAuth] backed by engine. It panics on a nil engine. +func New(engine secrets.Resolver, opts ...Option) ClientAuth { + if engine == nil { + panic("dockerhub: secrets engine client is required") + } + return clientAuth{engine: engine, cfg: newConfig(opts)} +} + +func (c clientAuth) ListProfiles(ctx context.Context) ([]Profile, error) { + envelopes, err := c.engine.GetSecrets(ctx, c.cfg.profiles) + if errors.Is(err, secrets.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("list account profiles: %w", err) + } + var profiles []Profile + var errs []error + seen := make(map[string]bool, len(envelopes)) + for _, envelope := range envelopes { + // The default entry duplicates an account's profile. + if envelope.ID != nil && c.cfg.defaultEntry.Match(envelope.ID) { + continue + } + profile, err := parseProfile(envelope) + if err != nil { + errs = append(errs, err) + continue + } + if seen[profile.UserID] { + continue + } + seen[profile.UserID] = true + profiles = append(profiles, profile) + } + if len(profiles) == 0 && len(errs) > 0 { + return nil, errors.Join(errs...) + } + return profiles, nil +} + +func (c clientAuth) GetDefaultProfile(ctx context.Context) (Profile, error) { + envelopes, err := c.engine.GetSecrets(ctx, c.cfg.defaultEntry) + if errors.Is(err, secrets.ErrNotFound) { + return Profile{}, ErrNoDefaultProfile + } + if err != nil { + return Profile{}, fmt.Errorf("retrieve default account metadata: %w", err) + } + return parseFirst(envelopes, parseProfile, ErrNoDefaultProfile) +} + +func (c clientAuth) GetDefaultSession(ctx context.Context) (UserSession, error) { + profile, err := c.GetDefaultProfile(ctx) + if err != nil { + return UserSession{}, err + } + // Require a wildcard-free ID naming one account entry in the accounts + // realm, so a tampered profile cannot address an arbitrary secret. + id, err := secrets.ParseID(profile.UserID) + if err != nil { + return UserSession{}, fmt.Errorf("default profile user id: %w", err) + } + 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)) +} + +func (c clientAuth) GetSession(ctx context.Context, username string) (UserSession, error) { + if strings.Contains(username, "/") { + return UserSession{}, fmt.Errorf("invalid username %q: must not contain '/'", username) + } + user, err := secrets.ParseID(username) + if err != nil { + return UserSession{}, fmt.Errorf("invalid username %q: %w", username, err) + } + id, err := c.cfg.accounts.ExpandID(user) + if err != nil { + return UserSession{}, err + } + return c.getSession(ctx, exactPattern(id)) +} + +func (c clientAuth) getSession(ctx context.Context, pattern secrets.Pattern) (UserSession, error) { + envelopes, err := c.engine.GetSecrets(ctx, pattern) + if errors.Is(err, secrets.ErrNotFound) { + return UserSession{}, ErrNoSession + } + if err != nil { + return UserSession{}, fmt.Errorf("retrieve user access token: %w", err) + } + return parseFirst(envelopes, parseUserSession, ErrNoSession) +} + +func exactPattern(id secrets.ID) secrets.Pattern { + return secrets.MustParsePattern(id.String()) +} + +// parseFirst returns the first envelope that parses. It returns notFound when +// there are no envelopes, and the joined decode errors when none parses. +func parseFirst[T any](envelopes []secrets.Envelope, parse func(secrets.Envelope) (T, error), notFound error) (T, error) { + var zero T + var errs []error + for _, envelope := range envelopes { + v, err := parse(envelope) + if err != nil { + errs = append(errs, err) + continue + } + return v, nil + } + if len(errs) == 0 { + return zero, notFound + } + return zero, errors.Join(errs...) +} diff --git a/client/dockerhub/dockerhub_test.go b/client/dockerhub/dockerhub_test.go new file mode 100644 index 00000000..70904fd8 --- /dev/null +++ b/client/dockerhub/dockerhub_test.go @@ -0,0 +1,470 @@ +// Copyright 2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dockerhub_test + +import ( + "context" + "encoding/json" + "maps" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/secrets-engine/client" + "github.com/docker/secrets-engine/client/dockerhub" + "github.com/docker/secrets-engine/x/secrets" + "github.com/docker/secrets-engine/x/testhelper" +) + +const sessionWire = `{ + "access_token": "token-alice", + "claims": { + "iss": "https://auth.docker.io", + "sub": "user-uuid-1", + "aud": ["audience.docker.io"], + "exp": 1755500000, + "nbf": 1755400000, + "iat": 1755400000, + "jti": "jwt-id-1", + "scope": "read write", + "app_name": "desktop", + "uuid": "user-uuid-1", + "source": "docker_pat|1", + "session_id": "session-1", + "email": "alice@example.com", + "username": "alice" + } +}` + +const profileWire = `{ + "user_id": "docker/auth/hub/alice", + "original_sign_in_app": "desktop", + "username": "alice", + "email": "alice@example.com", + "sign_in_date": "2026-08-01T10:00:00Z" +}` + +type fakeEngine struct { + testhelper.MockResolver + err error +} + +func (f fakeEngine) GetSecrets(ctx context.Context, pattern secrets.Pattern) ([]secrets.Envelope, error) { + if f.err != nil { + return nil, f.err + } + return f.MockResolver.GetSecrets(ctx, pattern) +} + +func serving(store map[string]string) fakeEngine { + resolved := make(map[secrets.ID]string, len(store)) + for id, value := range store { + resolved[secrets.MustParseID(id)] = value + } + return fakeEngine{MockResolver: testhelper.MockResolver{Store: resolved}} +} + +type nilIDEngine map[string]string + +func (e nilIDEngine) GetSecrets(_ context.Context, pattern secrets.Pattern) ([]secrets.Envelope, error) { + var envelopes []secrets.Envelope + for _, id := range slices.Sorted(maps.Keys(e)) { + if pattern.Match(secrets.MustParseID(id)) { + envelopes = append(envelopes, secrets.Envelope{Value: []byte(e[id])}) + } + } + if len(envelopes) == 0 { + return nil, secrets.ErrNotFound + } + return envelopes, nil +} + +type staticEngine struct { + envelopes []secrets.Envelope +} + +func (s staticEngine) GetSecrets(context.Context, secrets.Pattern) ([]secrets.Envelope, error) { + return s.envelopes, nil +} + +func envelope(value string) secrets.Envelope { + return secrets.Envelope{Value: []byte(value), Provider: "docker-auth", Version: "0.0.1"} +} + +func hub(t *testing.T, engine secrets.Resolver, opts ...dockerhub.Option) dockerhub.ClientAuth { + t.Helper() + return dockerhub.New(engine, opts...) +} + +func TestNew(t *testing.T) { + t.Parallel() + require.Panics(t, func() { dockerhub.New(nil) }) +} + +func TestGetSession(t *testing.T) { + t.Parallel() + t.Run("decodes the wire format", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/hub/alice": sessionWire, + }) + session, err := hub(t, engine).GetSession(t.Context(), "alice") + require.NoError(t, err) + assert.Equal(t, "token-alice", session.AccessToken) + assert.Equal(t, "https://auth.docker.io", session.Claims.Issuer) + assert.Equal(t, "user-uuid-1", session.Claims.Subject) + assert.Equal(t, dockerhub.Audience{"audience.docker.io"}, session.Claims.Audience) + require.NotNil(t, session.Claims.ExpiresAt) + assert.Equal(t, int64(1755500000), session.Claims.ExpiresAt.Unix()) + require.NotNil(t, session.Claims.NotBefore) + assert.Equal(t, int64(1755400000), session.Claims.NotBefore.Unix()) + require.NotNil(t, session.Claims.IssuedAt) + assert.Equal(t, int64(1755400000), session.Claims.IssuedAt.Unix()) + assert.Equal(t, "jwt-id-1", session.Claims.ID) + assert.Equal(t, "read write", session.Claims.Scope) + assert.Equal(t, "desktop", session.Claims.AppName) + assert.Equal(t, "user-uuid-1", session.Claims.UUID) + assert.Equal(t, "docker_pat|1", session.Claims.Source) + assert.Equal(t, "session-1", session.Claims.SessionID) + assert.Equal(t, "alice@example.com", session.Claims.Email) + assert.Equal(t, "alice", session.Claims.Username) + }) + t.Run("audience as single string", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/hub/alice": `{"access_token":"tok","claims":{"aud":"audience.docker.io","app_name":"cli","uuid":"u","source":"s","session_id":"sid","email":"e","username":"alice"}}`, + }) + session, err := hub(t, engine).GetSession(t.Context(), "alice") + require.NoError(t, err) + assert.Equal(t, dockerhub.Audience{"audience.docker.io"}, session.Claims.Audience) + }) + t.Run("fractional expiry seconds", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/hub/alice": `{"access_token":"tok","claims":{"exp":1755500000.5,"app_name":"cli","uuid":"u","source":"s","session_id":"sid","email":"e","username":"alice"}}`, + }) + session, err := hub(t, engine).GetSession(t.Context(), "alice") + require.NoError(t, err) + require.NotNil(t, session.Claims.ExpiresAt) + assert.Equal(t, int64(1755500000), session.Claims.ExpiresAt.Unix()) + assert.Equal(t, 500000000, session.Claims.ExpiresAt.Nanosecond()) + }) + t.Run("payload without claims", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/hub/alice": `{"access_token":"tok"}`, + }) + session, err := hub(t, engine).GetSession(t.Context(), "alice") + require.NoError(t, err) + assert.Equal(t, "tok", session.AccessToken) + assert.Equal(t, dockerhub.Claims{}, session.Claims) + }) + t.Run("skips undecodable envelopes", func(t *testing.T) { + engine := staticEngine{envelopes: []secrets.Envelope{ + envelope(`not json`), envelope(`{"access_token":""}`), envelope(`{"access_token":"tok"}`), + }} + session, err := hub(t, engine).GetSession(t.Context(), "alice") + require.NoError(t, err) + assert.Equal(t, "tok", session.AccessToken) + }) + t.Run("all envelopes undecodable", func(t *testing.T) { + engine := staticEngine{envelopes: []secrets.Envelope{ + envelope(`not json`), envelope(`{"access_token":""}`), + }} + _, err := hub(t, engine).GetSession(t.Context(), "alice") + require.ErrorContains(t, err, "decode user session") + require.ErrorContains(t, err, "no access token in payload") + }) + t.Run("no stored credential", func(t *testing.T) { + _, err := hub(t, serving(nil)).GetSession(t.Context(), "alice") + require.ErrorIs(t, err, dockerhub.ErrNoSession) + }) + t.Run("engine reports not found", func(t *testing.T) { + _, err := hub(t, fakeEngine{err: client.ErrSecretNotFound}).GetSession(t.Context(), "alice") + require.ErrorIs(t, err, dockerhub.ErrNoSession) + }) + t.Run("provider returns no envelopes without error", func(t *testing.T) { + _, err := hub(t, staticEngine{}).GetSession(t.Context(), "alice") + require.ErrorIs(t, err, dockerhub.ErrNoSession) + }) + t.Run("rejects wildcard usernames", func(t *testing.T) { + _, err := hub(t, serving(nil)).GetSession(t.Context(), "*") + require.ErrorContains(t, err, "invalid username") + _, err = hub(t, serving(nil)).GetSession(t.Context(), "") + require.ErrorContains(t, err, "invalid username") + }) + t.Run("rejects multi-component usernames", func(t *testing.T) { + _, err := hub(t, serving(map[string]string{ + "docker/auth/hub/alice/extra": sessionWire, + })).GetSession(t.Context(), "alice/extra") + require.ErrorContains(t, err, "must not contain '/'") + }) + t.Run("engine unavailable", func(t *testing.T) { + _, err := hub(t, fakeEngine{err: client.ErrSecretsEngineNotAvailable}).GetSession(t.Context(), "alice") + require.ErrorIs(t, err, client.ErrSecretsEngineNotAvailable) + }) +} + +func TestGetDefaultProfile(t *testing.T) { + t.Parallel() + t.Run("returns the default profile", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": profileWire, + }) + profile, err := hub(t, engine).GetDefaultProfile(t.Context()) + require.NoError(t, err) + assert.Equal(t, dockerhub.Profile{ + UserID: "docker/auth/hub/alice", + OriginalSignInApp: "desktop", + Username: "alice", + Email: "alice@example.com", + SignInDate: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC), + }, profile) + }) + t.Run("no default profile", func(t *testing.T) { + _, err := hub(t, serving(nil)).GetDefaultProfile(t.Context()) + require.ErrorIs(t, err, dockerhub.ErrNoDefaultProfile) + }) + t.Run("engine reports not found", func(t *testing.T) { + _, err := hub(t, fakeEngine{err: client.ErrSecretNotFound}).GetDefaultProfile(t.Context()) + require.ErrorIs(t, err, dockerhub.ErrNoDefaultProfile) + }) + t.Run("provider returns no envelopes without error", func(t *testing.T) { + _, err := hub(t, staticEngine{}).GetDefaultProfile(t.Context()) + require.ErrorIs(t, err, dockerhub.ErrNoDefaultProfile) + }) + t.Run("profile without user id", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": `{"username":"alice"}`, + }) + _, err := hub(t, engine).GetDefaultProfile(t.Context()) + require.ErrorContains(t, err, "decode profile metadata") + }) +} + +func TestGetDefaultSession(t *testing.T) { + t.Parallel() + t.Run("resolves the default profile to its session", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": profileWire, + "docker/auth/hub/alice": sessionWire, + }) + session, err := hub(t, engine).GetDefaultSession(t.Context()) + require.NoError(t, err) + assert.Equal(t, "token-alice", session.AccessToken) + assert.Equal(t, "alice", session.Claims.Username) + }) + t.Run("no default profile", func(t *testing.T) { + _, err := hub(t, serving(nil)).GetDefaultSession(t.Context()) + require.ErrorIs(t, err, dockerhub.ErrNoDefaultProfile) + require.ErrorIs(t, err, dockerhub.ErrNoSession) + }) + t.Run("profile points at a missing credential", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": profileWire, + }) + _, err := hub(t, engine).GetDefaultSession(t.Context()) + require.ErrorIs(t, err, dockerhub.ErrNoSession) + }) + t.Run("rejects a wildcard user id", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": `{"user_id":"docker/auth/hub/*"}`, + "docker/auth/hub/alice": sessionWire, + }) + _, err := hub(t, engine).GetDefaultSession(t.Context()) + require.ErrorContains(t, err, "default profile user id") + }) + t.Run("rejects a fan-out wildcard user id", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": `{"user_id":"docker/**"}`, + "docker/auth/hub/alice": sessionWire, + }) + _, err := hub(t, engine).GetDefaultSession(t.Context()) + require.ErrorContains(t, err, "default profile user id") + }) + t.Run("rejects a nested user id", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": `{"user_id":"docker/auth/hub/alice/extra"}`, + "docker/auth/hub/alice/extra": sessionWire, + }) + _, err := hub(t, engine).GetDefaultSession(t.Context()) + require.ErrorContains(t, err, "not an account entry in the docker/auth/hub/** realm") + }) + t.Run("rejects a user id outside the accounts realm", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": `{"user_id":"docker/mcp/oauth/github"}`, + "docker/mcp/oauth/github": sessionWire, + }) + _, err := hub(t, engine).GetDefaultSession(t.Context()) + require.ErrorContains(t, err, "not an account entry in the docker/auth/hub/** realm") + }) + t.Run("rejects a staging user id on the production realm", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": `{"user_id":"docker/auth/hub-staging/alice"}`, + "docker/auth/hub-staging/alice": sessionWire, + }) + _, err := hub(t, engine).GetDefaultSession(t.Context()) + require.ErrorContains(t, err, "not an account entry in the docker/auth/hub/** realm") + }) + t.Run("rejects a production user id on the staging realm", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub-staging/default": profileWire, + "docker/auth/hub/alice": sessionWire, + }) + _, err := hub(t, engine, dockerhub.Staging()).GetDefaultSession(t.Context()) + require.ErrorContains(t, err, "not an account entry in the docker/auth/hub-staging/** realm") + }) +} + +func TestListProfiles(t *testing.T) { + t.Parallel() + t.Run("lists accounts and skips the default entry", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/default": profileWire, + "docker/auth/metadata/hub/alice": profileWire, + "docker/auth/metadata/hub/bob": `{"user_id":"docker/auth/hub/bob","username":"bob"}`, + }) + profiles, err := hub(t, engine).ListProfiles(t.Context()) + require.NoError(t, err) + require.Len(t, profiles, 2) + assert.Equal(t, "alice", profiles[0].Username) + assert.Equal(t, "bob", profiles[1].Username) + }) + t.Run("dedupes entries sharing a user id", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/alice": profileWire, + "docker/auth/metadata/hub/alias": profileWire, + }) + profiles, err := hub(t, engine).ListProfiles(t.Context()) + require.NoError(t, err) + require.Len(t, profiles, 1) + assert.Equal(t, "alice", profiles[0].Username) + }) + t.Run("skips the default entry without envelope IDs", func(t *testing.T) { + engine := nilIDEngine{ + "docker/auth/metadata/hub/default": profileWire, + "docker/auth/metadata/hub/alice": profileWire, + "docker/auth/metadata/hub/bob": `{"user_id":"docker/auth/hub/bob","username":"bob"}`, + } + profiles, err := hub(t, engine).ListProfiles(t.Context()) + require.NoError(t, err) + require.Len(t, profiles, 2) + assert.ElementsMatch(t, []string{"alice", "bob"}, []string{profiles[0].Username, profiles[1].Username}) + }) + t.Run("no accounts", func(t *testing.T) { + profiles, err := hub(t, serving(nil)).ListProfiles(t.Context()) + require.NoError(t, err) + assert.Empty(t, profiles) + }) + t.Run("engine reports not found", func(t *testing.T) { + profiles, err := hub(t, fakeEngine{err: client.ErrSecretNotFound}).ListProfiles(t.Context()) + require.NoError(t, err) + assert.Empty(t, profiles) + }) + t.Run("skips undecodable entries", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/alice": profileWire, + "docker/auth/metadata/hub/broken": `not json`, + }) + profiles, err := hub(t, engine).ListProfiles(t.Context()) + require.NoError(t, err) + require.Len(t, profiles, 1) + assert.Equal(t, "alice", profiles[0].Username) + }) + t.Run("all entries undecodable", func(t *testing.T) { + engine := serving(map[string]string{ + "docker/auth/metadata/hub/broken": `not json`, + }) + _, err := hub(t, engine).ListProfiles(t.Context()) + require.ErrorContains(t, err, "decode profile metadata") + }) + t.Run("engine unavailable", func(t *testing.T) { + _, err := hub(t, fakeEngine{err: client.ErrSecretsEngineNotAvailable}).ListProfiles(t.Context()) + require.ErrorIs(t, err, client.ErrSecretsEngineNotAvailable) + }) +} + +func TestStaging(t *testing.T) { + t.Parallel() + engine := serving(map[string]string{ + "docker/auth/metadata/hub-staging/default": `{"user_id":"docker/auth/hub-staging/alice"}`, + "docker/auth/hub-staging/alice": sessionWire, + }) + + session, err := hub(t, engine, dockerhub.Staging()).GetDefaultSession(t.Context()) + require.NoError(t, err) + assert.Equal(t, "token-alice", session.AccessToken) + + session, err = hub(t, engine, dockerhub.Staging()).GetSession(t.Context(), "alice") + require.NoError(t, err) + assert.Equal(t, "token-alice", session.AccessToken) +} + +func TestClaimsRoundTrip(t *testing.T) { + t.Parallel() + var session dockerhub.UserSession + require.NoError(t, json.Unmarshal([]byte(sessionWire), &session)) + + data, err := json.Marshal(session.Claims) + require.NoError(t, err) + assert.Contains(t, string(data), `"exp":1755500000`) + assert.Contains(t, string(data), `"aud":["audience.docker.io"]`) + + var claims dockerhub.Claims + require.NoError(t, json.Unmarshal(data, &claims)) + assert.Equal(t, session.Claims.ExpiresAt.Unix(), claims.ExpiresAt.Unix()) + assert.Equal(t, session.Claims.Audience, claims.Audience) + assert.Equal(t, session.Claims.Username, claims.Username) +} + +func TestNumericDate(t *testing.T) { + t.Parallel() + t.Run("rejects out of range epochs", func(t *testing.T) { + var date dockerhub.NumericDate + for _, wire := range []string{`1e19`, `-1e19`, `10000000000000000000`, `9223372036854775807`} { + require.ErrorContains(t, json.Unmarshal([]byte(wire), &date), "out of range", wire) + } + }) + t.Run("parses fractional seconds", func(t *testing.T) { + var date dockerhub.NumericDate + require.NoError(t, json.Unmarshal([]byte(`1755500000.1`), &date)) + assert.InDelta(t, 100000000, date.Nanosecond(), 200) + assert.Equal(t, int64(1755500000), date.Unix()) + }) + t.Run("exponent form", func(t *testing.T) { + var date dockerhub.NumericDate + require.NoError(t, json.Unmarshal([]byte(`1.7555e9`), &date)) + assert.Equal(t, int64(1755500000), date.Unix()) + }) + t.Run("null is a no-op", func(t *testing.T) { + date := dockerhub.NumericDate{Time: time.Unix(5, 0)} + require.NoError(t, json.Unmarshal([]byte(`null`), &date)) + assert.Equal(t, int64(5), date.Unix()) + }) + t.Run("marshal truncates to whole seconds", func(t *testing.T) { + date := dockerhub.NumericDate{Time: time.Unix(1755500000, 500000000)} + data, err := json.Marshal(date) + require.NoError(t, err) + assert.Equal(t, `1755500000`, string(data)) + }) +} + +func TestAudienceDecoding(t *testing.T) { + t.Parallel() + var audience dockerhub.Audience + require.NoError(t, json.Unmarshal([]byte(`null`), &audience)) + assert.Nil(t, audience) + require.Error(t, json.Unmarshal([]byte(`{"not":"audience"}`), &audience)) + require.Error(t, json.Unmarshal([]byte(`[1]`), &audience)) +} diff --git a/client/realms/docker.go b/client/realms/docker.go index a6de6656..d730fca3 100644 --- a/client/realms/docker.go +++ b/client/realms/docker.go @@ -26,7 +26,7 @@ // docker/auth/hub/** – Docker Hub authentication (OAuth login) // docker/auth/hub-staging/** – Docker Hub staging authentication // docker/auth/registry/docker/** – Docker Registry authentication -// docker/auth/metadata/hub/** – metadata for the default Hub user +// docker/auth/metadata/hub/** – Hub account profile metadata // docker/mcp/** – MCP-related secrets // docker/mcp/oauth/** – MCP OAuth credentials // docker/mcp/oauth-dcr/** – MCP Dynamic Client Registration configs @@ -58,9 +58,12 @@ var ( ) var ( - // DockerHubAuthenticationMetadata is a pointer to the default user signed in to Docker + // DockerHubAuthenticationMetadata holds one profile entry per signed-in + // Docker Hub account, plus a default entry duplicating the default + // account's profile. DockerHubAuthenticationMetadata = xrealms.DockerHubAuthenticationMetadata - // DockerHubStagingAuthenticationMetadata is a pointer to the default staging user signed in to Docker + // DockerHubStagingAuthenticationMetadata is the staging variant of + // [DockerHubAuthenticationMetadata]. DockerHubStagingAuthenticationMetadata = xrealms.DockerHubStagingAuthenticationMetadata ) diff --git a/plugins/pass/commands/run_test.go b/plugins/pass/commands/run_test.go index bb0e38dd..e9098088 100644 --- a/plugins/pass/commands/run_test.go +++ b/plugins/pass/commands/run_test.go @@ -34,6 +34,7 @@ import ( "github.com/stretchr/testify/require" "github.com/docker/secrets-engine/client" + "github.com/docker/secrets-engine/client/dockerhub" "github.com/docker/secrets-engine/x/secrets" "github.com/docker/secrets-engine/x/testhelper" ) @@ -361,6 +362,10 @@ func (p pingClient) Authorize(context.Context, ...secrets.Pattern) (time.Time, e return time.Time{}, nil } +func (pingClient) HubAuth(...dockerhub.Option) dockerhub.ClientAuth { + return nil +} + func TestPreflightPing(t *testing.T) { t.Parallel() diff --git a/x/realms/docker.go b/x/realms/docker.go index 7d985df8..7c93b234 100644 --- a/x/realms/docker.go +++ b/x/realms/docker.go @@ -34,9 +34,12 @@ var ( ) var ( - // DockerHubAuthenticationMetadata is a pointer to the default user signed in to Docker + // DockerHubAuthenticationMetadata holds one profile entry per signed-in + // Docker Hub account, plus a default entry duplicating the default + // account's profile. DockerHubAuthenticationMetadata = secrets.MustParsePattern("docker/auth/metadata/hub/**") - // DockerHubStagingAuthenticationMetadata is a pointer to the default staging user signed in to Docker + // DockerHubStagingAuthenticationMetadata is the staging variant of + // [DockerHubAuthenticationMetadata]. DockerHubStagingAuthenticationMetadata = secrets.MustParsePattern("docker/auth/metadata/hub-staging/**") )