Skip to content

feat(client)!: add Docker Hub auth sub-client - #618

Open
Benehiko wants to merge 1 commit into
mainfrom
feat/client-dockerhub-helpers
Open

feat(client)!: add Docker Hub auth sub-client#618
Benehiko wants to merge 1 commit into
mainfrom
feat/client-dockerhub-helpers

Conversation

@Benehiko

@Benehiko Benehiko commented Aug 18, 2026

Copy link
Copy Markdown
Member

What

Adds client/dockerhub: a typed sub-client for reading Docker Hub authentication state (access tokens and account profiles) from the secrets engine.

Why

Fetching a Hub token today means knowing the realm layout (docker/auth/hub/**, docker/auth/metadata/hub/**) and the raw JSON payload format. This PR hides both behind a small typed API.

API

client.Client gains an accessor (kubernetes-clientset style); dockerhub.New wires the same sub-client over any bare secrets.Resolver:

c, _ := client.New()
hub := c.HubAuth() // or c.HubAuth(dockerhub.Staging())

session, err := hub.GetDefaultSession(ctx)
fmt.Println(session.AccessToken, session.Claims.Username)
Method Returns
GetDefaultSession(ctx) session of the default signed-in account
GetSession(ctx, username) session of a specific account
GetDefaultProfile(ctx) profile of the default account
ListProfiles(ctx) profiles of all signed-in accounts

A UserSession carries the raw JWT (AccessToken) plus decoded Claims — typed NumericDate and Audience, no JWT library dependency.

Errors: ErrNoSession (no stored credential) and ErrNoDefaultProfile (no default account set). The latter wraps the former, so callers that only care about having a usable session check one sentinel.

Safety

  • GetSession rejects usernames containing / or wildcards, so a username can only ever address one account entry.
  • GetDefaultSession validates that the stored user_id names exactly one account entry inside the accounts realm (docker/auth/hub/*), so a tampered profile cannot redirect the lookup to another secret.
  • Staging and production realms are pinned per sub-client: ids from one never resolve on the other.

Also in this PR

  • README: new "How to fetch a Docker Hub access token" guide.
  • Realm godoc for docker/auth/metadata/hub/** corrected: it holds one profile entry per signed-in account plus a default entry duplicating the default account's profile (previously described as a single default-user pointer).

Breaking: the Client interface gains a HubAuth method — implementations must add it.

🤖 Generated with Claude Code

@Benehiko Benehiko changed the title feat(client): add Docker Hub access token helpers feat(client)!: expose Docker Hub auth as HubAuth sub-client Aug 20, 2026
@Benehiko
Benehiko force-pushed the feat/client-dockerhub-helpers branch from 785b066 to 12bdd57 Compare August 21, 2026 11:26
@Benehiko
Benehiko requested review from joe0BAB and kiview August 21, 2026 15:23
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/<username>, 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 <noreply@anthropic.com>
@Benehiko
Benehiko force-pushed the feat/client-dockerhub-helpers branch from a8c778e to a05fcc7 Compare August 21, 2026 15:25
@Benehiko Benehiko changed the title feat(client)!: expose Docker Hub auth as HubAuth sub-client feat(client)!: add Docker Hub auth sub-client Aug 21, 2026
@Benehiko
Benehiko marked this pull request as ready for review August 21, 2026 15:28

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟡 NEEDS ATTENTION

This PR introduces a well-structured typed sub-client for Docker Hub authentication. The security design (realm pinning, username validation, default-session guard) is sound overall. One correctness bug was confirmed in the nil-envelope-ID path of ListProfiles, and a best-effort error-suppression behaviour is undocumented.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Default entry included in results when secrets engine omits envelope IDs

The guard at line 250 uses if envelope.ID != nil && c.cfg.defaultEntry.Match(envelope.ID) — when envelope.ID is nil the entire condition is false and the default entry is not skipped. It falls through to parseProfile and can be appended to the returned slice.

The seen-map deduplication masks this in the existing test (skips the default entry without envelope IDs) because default and alice share the same UserID — whichever is iterated first is kept, the other is dropped. If the two entries ever hold different data (e.g. the default profile was updated but the per-account entry hasn't been flushed yet, or any future provider populates them differently), a caller of ListProfiles may receive the default entry as a regular profile instead of the canonical per-account one.

Suggested fix:

// Skip the default entry: either we have an ID and it matches, or the
// engine omits IDs entirely and we fall back to matching by key name.
if envelope.ID == nil || c.cfg.defaultEntry.Match(envelope.ID) {
    continue
}

seen[profile.UserID] = true
profiles = append(profiles, profile)
}
if len(profiles) == 0 && len(errs) > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Parse errors silently discarded when at least one profile succeeds

ListProfiles returns errors only when zero profiles parsed successfully (line 264: if len(profiles) == 0 && len(errs) > 0). If 9 out of 10 stored profiles have a corrupt payload, the caller receives 1 profile and a nil error — with no indication that any accounts are missing.

The test skips undecodable entries explicitly asserts require.NoError in this scenario, so the best-effort behaviour is deliberate. However, the API contract is not documented, which could mislead callers who need to know whether the returned list is complete.

Consider adding a note to the ListProfiles godoc — e.g. "Profiles that cannot be decoded are silently skipped; the error is returned only if no profiles could be decoded at all." — so callers that need reliable completeness can decide whether to handle this at their layer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants