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 }