mirror of
https://github.com/docker/cli.git
synced 2026-11-09 14:33:06 -04:00
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>
416 lines
13 KiB
Go
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...)
|
|
}
|