Files
Alano Terblanche 0c2d0f700c vendor: github.com/docker/secrets-engine/client v0.2.2
Add the Docker secrets engine client, used to read the Docker Desktop session for Docker Hub. It brings in github.com/docker/secrets-engine/x v0.8.5 and connectrpc.com/connect v1.19.1.

The client module requires go1.26.10, which raises the go directive in vendor.mod from 1.26.0.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
2026-10-22 11:10:40 +02:00

416 lines
13 KiB
Go

// 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/base64"
"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)
// ErrMissingAccessToken means the stored credential has no access token.
// It wraps [ErrNoSession].
ErrMissingAccessToken error = &noSessionError{"no access token in payload"}
// ErrInvalidAccessToken means the stored access token is not a JWT. It
// wraps [ErrNoSession].
ErrInvalidAccessToken error = &noSessionError{"access token is not a JWT"}
)
type noSessionError struct{ msg string }
func (e *noSessionError) Error() string { return e.msg }
func (*noSessionError) Unwrap() error { return 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"`
}
// Expired reports whether the access token has expired. It is
// ExpiresWithin(0).
func (s UserSession) Expired() bool {
return s.ExpiresWithin(0)
}
// ExpiresWithin reports whether the access token will expire within the next given duration.
// Sessions without [Claims.ExpiresAt] report as expired by default.
func (s UserSession) ExpiresWithin(leeway time.Duration) bool {
exp := s.Claims.ExpiresAt
if exp == nil || exp.IsZero() {
return true
}
return !time.Now().Add(leeway).Before(exp.Time)
}
// 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{}, fmt.Errorf("decode user session: %w", ErrMissingAccessToken)
}
if err := checkJWT(session.AccessToken); err != nil {
return UserSession{}, fmt.Errorf("decode user session: %w: %w", ErrInvalidAccessToken, err)
}
return session, nil
}
func checkJWT(token string) error {
segments := strings.Split(token, ".")
if len(segments) != 3 {
return fmt.Errorf("expected 3 dot-separated segments, got %d", len(segments))
}
var header struct {
Alg string `json:"alg"`
}
if err := decodeSegment(segments[0], &header); err != nil {
return fmt.Errorf("header: %w", err)
}
if header.Alg == "" {
return errors.New("header: missing alg")
}
var payload map[string]json.RawMessage
if err := decodeSegment(segments[1], &payload); err != nil {
return fmt.Errorf("payload: %w", err)
}
if payload == nil {
return errors.New("payload: not a JSON object")
}
if segments[2] == "" {
return errors.New("missing signature")
}
if _, err := base64.RawURLEncoding.DecodeString(segments[2]); err != nil {
return fmt.Errorf("signature: %w", err)
}
return nil
}
func decodeSegment(segment string, v any) error {
data, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// 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...)
}