From a05fcc7b6decdd511d3f5933d89ed5e08c4738f5 Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:25:17 +0200 Subject: [PATCH] feat(client)!: add Docker Hub auth sub-client Add a client/dockerhub package that reads Docker Hub access tokens and account profiles from the secrets engine and decodes them into typed values, so consumers no longer need to know the realm layout or the JSON payload format. The Client interface gains a HubAuth(...dockerhub.Option) accessor returning the dockerhub.ClientAuth sub-client; dockerhub.New wires the same sub-client over any bare secrets.Resolver. dockerhub.Staging() switches the lookups to the Docker Hub staging realms. ClientAuth resolves the default signed-in account through the profile metadata realm (docker/auth/metadata/hub/default), fetches a specific account under docker/auth/hub/, and lists all signed-in profiles. Usernames and stored user ids are validated to name exactly one account entry inside the accounts realm, so a tampered profile or crafted username cannot address another secret. Claim decoding is dependency-free: NumericDate accepts integer, fractional, and exponent epochs and marshals whole seconds (matching golang-jwt/jwt v5 defaults), and Audience accepts a single string or an array. BREAKING CHANGE: the Client interface gains a HubAuth method; implementations must add it. Co-Authored-By: Claude Fable 5 --- README.md | 49 +++ client/client.go | 8 + client/client_test.go | 8 + client/dockerhub/dockerhub.go | 345 +++++++++++++++++++++ client/dockerhub/dockerhub_test.go | 470 +++++++++++++++++++++++++++++ client/realms/docker.go | 9 +- plugins/pass/commands/run_test.go | 5 + x/realms/docker.go | 7 +- 8 files changed, 896 insertions(+), 5 deletions(-) create mode 100644 client/dockerhub/dockerhub.go create mode 100644 client/dockerhub/dockerhub_test.go 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/**") )