cuelabs.dev/go/oci/ociregistry@v0.0.0-20240906074133-82eb438dd565/ociauth/authfile.go (about)

     1  package ociauth
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/base64"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"os"
    10  	"os/exec"
    11  	"path/filepath"
    12  	"runtime"
    13  	"slices"
    14  	"strings"
    15  )
    16  
    17  // AuthConfig represents access to system level (e.g. config-file or command-execution based)
    18  // configuration information.
    19  //
    20  // It's OK to call EntryForRegistry concurrently.
    21  type Config interface {
    22  	// EntryForRegistry returns auth information for the given host.
    23  	// If there's no information available, it should return the zero ConfigEntry
    24  	// and nil.
    25  	EntryForRegistry(host string) (ConfigEntry, error)
    26  }
    27  
    28  // ConfigEntry holds auth information for a registry.
    29  // It mirrors the information obtainable from the .docker/config.json
    30  // file and from the docker credential helper protocol
    31  type ConfigEntry struct {
    32  	// RefreshToken holds a token that can be used to obtain an access token.
    33  	RefreshToken string
    34  	// AccessToken holds a bearer token to be sent to a registry.
    35  	AccessToken string
    36  	// Username holds the username for use with basic auth.
    37  	Username string
    38  	// Password holds the password for use with Username.
    39  	Password string
    40  }
    41  
    42  // ConfigFile holds auth information for OCI registries as read from a configuration file.
    43  // It implements [Config].
    44  type ConfigFile struct {
    45  	data   configData
    46  	runner HelperRunner
    47  }
    48  
    49  var ErrHelperNotFound = errors.New("helper not found")
    50  
    51  // HelperRunner is the function used to execute auth "helper"
    52  // commands. It's passed the helper name as specified in the configuration file,
    53  // without the "docker-credential-helper-" prefix.
    54  //
    55  // If the credentials are not found, it should return the zero AuthInfo
    56  // and no error.
    57  //
    58  // If the helper doesn't exist, it should return an [ErrHelperNotFound] error.
    59  type HelperRunner = func(helperName string, serverURL string) (ConfigEntry, error)
    60  
    61  // configData holds the part of ~/.docker/config.json that pertains to auth.
    62  type configData struct {
    63  	Auths       map[string]authConfig `json:"auths"`
    64  	CredsStore  string                `json:"credsStore,omitempty"`
    65  	CredHelpers map[string]string     `json:"credHelpers,omitempty"`
    66  }
    67  
    68  // authConfig contains authorization information for connecting to a Registry.
    69  type authConfig struct {
    70  	// derivedFrom records the entries from which this one was derived.
    71  	// If this is empty, the entry was explicitly present.
    72  	derivedFrom []string
    73  
    74  	Username string `json:"username,omitempty"`
    75  	Password string `json:"password,omitempty"`
    76  	// Auth is an alternative way of specifying username and password
    77  	// (in base64(username:password) form.
    78  	Auth string `json:"auth,omitempty"`
    79  
    80  	// IdentityToken is used to authenticate the user and get
    81  	// an access token for the registry.
    82  	IdentityToken string `json:"identitytoken,omitempty"`
    83  
    84  	// RegistryToken is a bearer token to be sent to a registry
    85  	RegistryToken string `json:"registrytoken,omitempty"`
    86  }
    87  
    88  // LoadWithEnv is like [Load] but takes environment variables in the form
    89  // returned by [os.Environ] instead of calling [os.Getenv]. If env
    90  // is nil, the current process's environment will be used.
    91  func LoadWithEnv(runner HelperRunner, env []string) (*ConfigFile, error) {
    92  	if runner == nil {
    93  		runner = ExecHelperWithEnv(env)
    94  	}
    95  	getenv := os.Getenv
    96  	if env != nil {
    97  		getenv = getenvFunc(env)
    98  	}
    99  	for _, f := range configFileLocations {
   100  		filename := f(getenv)
   101  		if filename == "" {
   102  			continue
   103  		}
   104  		data, err := os.ReadFile(filename)
   105  		if err != nil {
   106  			if os.IsNotExist(err) {
   107  				continue
   108  			}
   109  			return nil, err
   110  		}
   111  		f, err := decodeConfigFile(data)
   112  		if err != nil {
   113  			return nil, fmt.Errorf("invalid config file %q: %v", filename, err)
   114  		}
   115  		return &ConfigFile{
   116  			data:   f,
   117  			runner: runner,
   118  		}, nil
   119  	}
   120  	return &ConfigFile{
   121  		runner: runner,
   122  	}, nil
   123  }
   124  
   125  // Load loads the auth configuration from the first location it can find.
   126  // It uses runner to run any external helper commands; if runner
   127  // is nil, [ExecHelper] will be used.
   128  //
   129  // In order it tries:
   130  // - $DOCKER_CONFIG/config.json
   131  // - ~/.docker/config.json
   132  // - $XDG_RUNTIME_DIR/containers/auth.json
   133  func Load(runner HelperRunner) (*ConfigFile, error) {
   134  	return LoadWithEnv(runner, nil)
   135  }
   136  
   137  func getenvFunc(env []string) func(string) string {
   138  	return func(key string) string {
   139  		for i := len(env) - 1; i >= 0; i-- {
   140  			if e := env[i]; len(e) >= len(key)+1 && e[len(key)] == '=' && e[:len(key)] == key {
   141  				return e[len(key)+1:]
   142  			}
   143  		}
   144  		return ""
   145  	}
   146  }
   147  
   148  var configFileLocations = []func(func(string) string) string{
   149  	func(getenv func(string) string) string {
   150  		if d := getenv("DOCKER_CONFIG"); d != "" {
   151  			return filepath.Join(d, "config.json")
   152  		}
   153  		return ""
   154  	},
   155  	func(getenv func(string) string) string {
   156  		if home := userHomeDir(getenv); home != "" {
   157  			return filepath.Join(home, ".docker", "config.json")
   158  		}
   159  		return ""
   160  	},
   161  	// If neither of the above locations was found, look for Podman's auth at
   162  	// $XDG_RUNTIME_DIR/containers/auth.json and attempt to load it as a
   163  	// Docker config.
   164  	func(getenv func(string) string) string {
   165  		if d := getenv("XDG_RUNTIME_DIR"); d != "" {
   166  			return filepath.Join(d, "containers", "auth.json")
   167  		}
   168  		return ""
   169  	},
   170  }
   171  
   172  // userHomeDir returns the current user's home directory.
   173  // The logic in this is directly derived from the logic in
   174  // [os.UserHomeDir] as of go 1.22.0.
   175  //
   176  // It's defined as a variable so it can be patched in tests.
   177  var userHomeDir = func(getenv func(string) string) string {
   178  	env := "HOME"
   179  	switch runtime.GOOS {
   180  	case "windows":
   181  		env = "USERPROFILE"
   182  	case "plan9":
   183  		env = "home"
   184  	}
   185  	if v := getenv(env); v != "" {
   186  		return v
   187  	}
   188  	// On some geese the home directory is not always defined.
   189  	switch runtime.GOOS {
   190  	case "android":
   191  		return "/sdcard"
   192  	case "ios":
   193  		return "/"
   194  	}
   195  	return ""
   196  }
   197  
   198  // EntryForRegistry implements [Authorizer.InfoForRegistry].
   199  // If no registry is found, it returns the zero [ConfigEntry] and a nil error.
   200  func (c *ConfigFile) EntryForRegistry(registryHostname string) (ConfigEntry, error) {
   201  	helper, ok := c.data.CredHelpers[registryHostname]
   202  	explicit := true
   203  	if !ok {
   204  		helper = c.data.CredsStore
   205  		explicit = false
   206  	}
   207  	if helper != "" {
   208  		entry, err := c.runner(helper, registryHostname)
   209  		if err == nil || explicit || !errors.Is(err, ErrHelperNotFound) {
   210  			return entry, err
   211  		}
   212  		// The helper command isn't found and it's a fallback default.
   213  		// Don't treat that as an error, because it's common for
   214  		// a helper default to be set up without the helper actually
   215  		// existing. See https://github.com/cue-lang/cue/issues/2934.
   216  	}
   217  	auth := c.data.Auths[registryHostname]
   218  	if auth.IdentityToken != "" && auth.Username != "" {
   219  		return ConfigEntry{}, fmt.Errorf("ambiguous auth credentials")
   220  	}
   221  	if len(auth.derivedFrom) > 1 {
   222  		return ConfigEntry{}, fmt.Errorf("more than one auths entry for %q (%s)", registryHostname, strings.Join(auth.derivedFrom, ", "))
   223  	}
   224  
   225  	return ConfigEntry{
   226  		RefreshToken: auth.IdentityToken,
   227  		AccessToken:  auth.RegistryToken,
   228  		Username:     auth.Username,
   229  		Password:     auth.Password,
   230  	}, nil
   231  }
   232  
   233  func decodeConfigFile(data []byte) (configData, error) {
   234  	var f configData
   235  	if err := json.Unmarshal(data, &f); err != nil {
   236  		return configData{}, fmt.Errorf("decode failed: %v", err)
   237  	}
   238  	for addr, ac := range f.Auths {
   239  		if ac.Auth != "" {
   240  			var err error
   241  			ac.Username, ac.Password, err = decodeAuth(ac.Auth)
   242  			if err != nil {
   243  				return configData{}, fmt.Errorf("cannot decode auth field for %q: %v", addr, err)
   244  			}
   245  		}
   246  		f.Auths[addr] = ac
   247  		if !strings.Contains(addr, "//") {
   248  			continue
   249  		}
   250  		// It looks like it might be a URL, so follow the original logic
   251  		// and extract the host name for later lookup. Explicit
   252  		// entries override implicit, and if several entries map to
   253  		// the same host, we record that so we can return an error
   254  		// later if that host is looked up (this avoids the nondeterministic
   255  		// behavior found in the original code when this happens).
   256  		addr1 := urlHost(addr)
   257  		if addr1 == addr {
   258  			continue
   259  		}
   260  		if ac1, ok := f.Auths[addr1]; ok {
   261  			if len(ac1.derivedFrom) == 0 {
   262  				// Don't override an explicit entry.
   263  				continue
   264  			}
   265  			ac = ac1
   266  		}
   267  		ac.derivedFrom = append(ac.derivedFrom, addr)
   268  		slices.Sort(ac.derivedFrom)
   269  		f.Auths[addr1] = ac
   270  	}
   271  	return f, nil
   272  }
   273  
   274  // urlHost returns the host part of a registry URL.
   275  // Mimics [github.com/docker/docker/registry.ConvertToHostname]
   276  // to keep the logic the same as that.
   277  func urlHost(url string) string {
   278  	stripped := url
   279  	if strings.HasPrefix(url, "http://") {
   280  		stripped = strings.TrimPrefix(url, "http://")
   281  	} else if strings.HasPrefix(url, "https://") {
   282  		stripped = strings.TrimPrefix(url, "https://")
   283  	}
   284  
   285  	hostName, _, _ := strings.Cut(stripped, "/")
   286  	return hostName
   287  }
   288  
   289  // decodeAuth decodes a base64 encoded string and returns username and password
   290  func decodeAuth(authStr string) (string, string, error) {
   291  	s, err := base64.StdEncoding.DecodeString(authStr)
   292  	if err != nil {
   293  		return "", "", fmt.Errorf("invalid base64-encoded string")
   294  	}
   295  	username, password, ok := strings.Cut(string(s), ":")
   296  	if !ok || username == "" {
   297  		return "", "", errors.New("no username found")
   298  	}
   299  	// The zero-byte-trimming logic here mimics the logic in the
   300  	// docker CLI configfile package.
   301  	return username, strings.Trim(password, "\x00"), nil
   302  }
   303  
   304  // ExecHelper executes an external program to get the credentials from a native store.
   305  // It implements [HelperRunner].
   306  func ExecHelper(helperName string, serverURL string) (ConfigEntry, error) {
   307  	return ExecHelperWithEnv(nil)(helperName, serverURL)
   308  }
   309  
   310  // ExecHelperWithEnv returns a [HelperRunner] that behaves like [ExecHelper]
   311  // except that, if env is non-nil, it will be used as the set of environment
   312  // variables to pass to the executed helper command. If env is nil,
   313  // the current process's environment will be used.
   314  func ExecHelperWithEnv(env []string) HelperRunner {
   315  	return func(helperName string, serverURL string) (ConfigEntry, error) {
   316  		var out bytes.Buffer
   317  		cmd := exec.Command("docker-credential-"+helperName, "get")
   318  		// TODO this doesn't produce a decent error message for
   319  		// other helpers such as gcloud that print errors to stderr.
   320  		cmd.Stdin = strings.NewReader(serverURL)
   321  		cmd.Stdout = &out
   322  		cmd.Stderr = &out
   323  		cmd.Env = env
   324  		if err := cmd.Run(); err != nil {
   325  			if !errors.As(err, new(*exec.ExitError)) {
   326  				if errors.Is(err, exec.ErrNotFound) {
   327  					return ConfigEntry{}, fmt.Errorf("%w: %v", ErrHelperNotFound, err)
   328  				}
   329  				return ConfigEntry{}, fmt.Errorf("cannot run auth helper: %v", err)
   330  			}
   331  			t := strings.TrimSpace(out.String())
   332  			if t == "credentials not found in native keychain" {
   333  				return ConfigEntry{}, nil
   334  			}
   335  			return ConfigEntry{}, fmt.Errorf("error getting credentials: %s", t)
   336  		}
   337  
   338  		// helperCredentials defines the JSON encoding of the data printed
   339  		// by credentials helper programs.
   340  		type helperCredentials struct {
   341  			Username string
   342  			Secret   string
   343  		}
   344  		var creds helperCredentials
   345  		if err := json.Unmarshal(out.Bytes(), &creds); err != nil {
   346  			return ConfigEntry{}, err
   347  		}
   348  		if creds.Username == "<token>" {
   349  			return ConfigEntry{
   350  				RefreshToken: creds.Secret,
   351  			}, nil
   352  		}
   353  		return ConfigEntry{
   354  			Password: creds.Secret,
   355  			Username: creds.Username,
   356  		}, nil
   357  	}
   358  }