oras.land/oras-go/v2@v2.5.1-0.20240520045656-aef90e4d04c4/registry/remote/credentials/internal/config/config.go (about) 1 /* 2 Copyright The ORAS Authors. 3 Licensed under the Apache License, Version 2.0 (the "License"); 4 you may not use this file except in compliance with the License. 5 You may obtain a copy of the License at 6 7 http://www.apache.org/licenses/LICENSE-2.0 8 9 Unless required by applicable law or agreed to in writing, software 10 distributed under the License is distributed on an "AS IS" BASIS, 11 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 See the License for the specific language governing permissions and 13 limitations under the License. 14 */ 15 16 package config 17 18 import ( 19 "bytes" 20 "encoding/base64" 21 "encoding/json" 22 "errors" 23 "fmt" 24 "os" 25 "path/filepath" 26 "strings" 27 "sync" 28 29 "oras.land/oras-go/v2/registry/remote/auth" 30 "oras.land/oras-go/v2/registry/remote/credentials/internal/ioutil" 31 ) 32 33 const ( 34 // configFieldAuths is the "auths" field in the config file. 35 // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19 36 configFieldAuths = "auths" 37 // configFieldCredentialsStore is the "credsStore" field in the config file. 38 configFieldCredentialsStore = "credsStore" 39 // configFieldCredentialHelpers is the "credHelpers" field in the config file. 40 configFieldCredentialHelpers = "credHelpers" 41 ) 42 43 // ErrInvalidConfigFormat is returned when the config format is invalid. 44 var ErrInvalidConfigFormat = errors.New("invalid config format") 45 46 // AuthConfig contains authorization information for connecting to a Registry. 47 // References: 48 // - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L45 49 // - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/types/authconfig.go#L3-L22 50 type AuthConfig struct { 51 // Auth is a base64-encoded string of "{username}:{password}". 52 Auth string `json:"auth,omitempty"` 53 // IdentityToken is used to authenticate the user and get an access token 54 // for the registry. 55 IdentityToken string `json:"identitytoken,omitempty"` 56 // RegistryToken is a bearer token to be sent to a registry. 57 RegistryToken string `json:"registrytoken,omitempty"` 58 59 Username string `json:"username,omitempty"` // legacy field for compatibility 60 Password string `json:"password,omitempty"` // legacy field for compatibility 61 } 62 63 // NewAuthConfig creates an authConfig based on cred. 64 func NewAuthConfig(cred auth.Credential) AuthConfig { 65 return AuthConfig{ 66 Auth: encodeAuth(cred.Username, cred.Password), 67 IdentityToken: cred.RefreshToken, 68 RegistryToken: cred.AccessToken, 69 } 70 } 71 72 // Credential returns an auth.Credential based on ac. 73 func (ac AuthConfig) Credential() (auth.Credential, error) { 74 cred := auth.Credential{ 75 Username: ac.Username, 76 Password: ac.Password, 77 RefreshToken: ac.IdentityToken, 78 AccessToken: ac.RegistryToken, 79 } 80 if ac.Auth != "" { 81 var err error 82 // override username and password 83 cred.Username, cred.Password, err = decodeAuth(ac.Auth) 84 if err != nil { 85 return auth.EmptyCredential, fmt.Errorf("failed to decode auth field: %w: %v", ErrInvalidConfigFormat, err) 86 } 87 } 88 return cred, nil 89 } 90 91 // Config represents a docker configuration file. 92 // References: 93 // - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties 94 // - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44 95 type Config struct { 96 // path is the path to the config file. 97 path string 98 // rwLock is a read-write-lock for the file store. 99 rwLock sync.RWMutex 100 // content is the content of the config file. 101 // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44 102 content map[string]json.RawMessage 103 // authsCache is a cache of the auths field of the config. 104 // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19 105 authsCache map[string]json.RawMessage 106 // credentialsStore is the credsStore field of the config. 107 // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L28 108 credentialsStore string 109 // credentialHelpers is the credHelpers field of the config. 110 // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L29 111 credentialHelpers map[string]string 112 } 113 114 // Load loads Config from the given config path. 115 func Load(configPath string) (*Config, error) { 116 cfg := &Config{path: configPath} 117 configFile, err := os.Open(configPath) 118 if err != nil { 119 if os.IsNotExist(err) { 120 // init content and caches if the content file does not exist 121 cfg.content = make(map[string]json.RawMessage) 122 cfg.authsCache = make(map[string]json.RawMessage) 123 return cfg, nil 124 } 125 return nil, fmt.Errorf("failed to open config file at %s: %w", configPath, err) 126 } 127 defer configFile.Close() 128 129 // decode config content if the config file exists 130 if err := json.NewDecoder(configFile).Decode(&cfg.content); err != nil { 131 return nil, fmt.Errorf("failed to decode config file at %s: %w: %v", configPath, ErrInvalidConfigFormat, err) 132 } 133 134 if credsStoreBytes, ok := cfg.content[configFieldCredentialsStore]; ok { 135 if err := json.Unmarshal(credsStoreBytes, &cfg.credentialsStore); err != nil { 136 return nil, fmt.Errorf("failed to unmarshal creds store field: %w: %v", ErrInvalidConfigFormat, err) 137 } 138 } 139 140 if credHelpersBytes, ok := cfg.content[configFieldCredentialHelpers]; ok { 141 if err := json.Unmarshal(credHelpersBytes, &cfg.credentialHelpers); err != nil { 142 return nil, fmt.Errorf("failed to unmarshal cred helpers field: %w: %v", ErrInvalidConfigFormat, err) 143 } 144 } 145 146 if authsBytes, ok := cfg.content[configFieldAuths]; ok { 147 if err := json.Unmarshal(authsBytes, &cfg.authsCache); err != nil { 148 return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err) 149 } 150 } 151 if cfg.authsCache == nil { 152 cfg.authsCache = make(map[string]json.RawMessage) 153 } 154 155 return cfg, nil 156 } 157 158 // GetAuthConfig returns an auth.Credential for serverAddress. 159 func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error) { 160 cfg.rwLock.RLock() 161 defer cfg.rwLock.RUnlock() 162 163 authCfgBytes, ok := cfg.authsCache[serverAddress] 164 if !ok { 165 // NOTE: the auth key for the server address may have been stored with 166 // a http/https prefix in legacy config files, e.g. "registry.example.com" 167 // can be stored as "https://registry.example.com/". 168 var matched bool 169 for addr, auth := range cfg.authsCache { 170 if toHostname(addr) == serverAddress { 171 matched = true 172 authCfgBytes = auth 173 break 174 } 175 } 176 if !matched { 177 return auth.EmptyCredential, nil 178 } 179 } 180 var authCfg AuthConfig 181 if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil { 182 return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err) 183 } 184 return authCfg.Credential() 185 } 186 187 // PutAuthConfig puts cred for serverAddress. 188 func (cfg *Config) PutCredential(serverAddress string, cred auth.Credential) error { 189 cfg.rwLock.Lock() 190 defer cfg.rwLock.Unlock() 191 192 authCfg := NewAuthConfig(cred) 193 authCfgBytes, err := json.Marshal(authCfg) 194 if err != nil { 195 return fmt.Errorf("failed to marshal auth field: %w", err) 196 } 197 cfg.authsCache[serverAddress] = authCfgBytes 198 return cfg.saveFile() 199 } 200 201 // DeleteAuthConfig deletes the corresponding credential for serverAddress. 202 func (cfg *Config) DeleteCredential(serverAddress string) error { 203 cfg.rwLock.Lock() 204 defer cfg.rwLock.Unlock() 205 206 if _, ok := cfg.authsCache[serverAddress]; !ok { 207 // no ops 208 return nil 209 } 210 delete(cfg.authsCache, serverAddress) 211 return cfg.saveFile() 212 } 213 214 // GetCredentialHelper returns the credential helpers for serverAddress. 215 func (cfg *Config) GetCredentialHelper(serverAddress string) string { 216 return cfg.credentialHelpers[serverAddress] 217 } 218 219 // CredentialsStore returns the configured credentials store. 220 func (cfg *Config) CredentialsStore() string { 221 cfg.rwLock.RLock() 222 defer cfg.rwLock.RUnlock() 223 224 return cfg.credentialsStore 225 } 226 227 // Path returns the path to the config file. 228 func (cfg *Config) Path() string { 229 return cfg.path 230 } 231 232 // SetCredentialsStore puts the configured credentials store. 233 func (cfg *Config) SetCredentialsStore(credsStore string) error { 234 cfg.rwLock.Lock() 235 defer cfg.rwLock.Unlock() 236 237 cfg.credentialsStore = credsStore 238 return cfg.saveFile() 239 } 240 241 // IsAuthConfigured returns whether there is authentication configured in this 242 // config file or not. 243 func (cfg *Config) IsAuthConfigured() bool { 244 return cfg.credentialsStore != "" || 245 len(cfg.credentialHelpers) > 0 || 246 len(cfg.authsCache) > 0 247 } 248 249 // saveFile saves Config into the file. 250 func (cfg *Config) saveFile() (returnErr error) { 251 // marshal content 252 // credentialHelpers is skipped as it's never set 253 if cfg.credentialsStore != "" { 254 credsStoreBytes, err := json.Marshal(cfg.credentialsStore) 255 if err != nil { 256 return fmt.Errorf("failed to marshal creds store: %w", err) 257 } 258 cfg.content[configFieldCredentialsStore] = credsStoreBytes 259 } else { 260 // omit empty 261 delete(cfg.content, configFieldCredentialsStore) 262 } 263 authsBytes, err := json.Marshal(cfg.authsCache) 264 if err != nil { 265 return fmt.Errorf("failed to marshal credentials: %w", err) 266 } 267 cfg.content[configFieldAuths] = authsBytes 268 jsonBytes, err := json.MarshalIndent(cfg.content, "", "\t") 269 if err != nil { 270 return fmt.Errorf("failed to marshal config: %w", err) 271 } 272 273 // write the content to a ingest file for atomicity 274 configDir := filepath.Dir(cfg.path) 275 if err := os.MkdirAll(configDir, 0700); err != nil { 276 return fmt.Errorf("failed to make directory %s: %w", configDir, err) 277 } 278 ingest, err := ioutil.Ingest(configDir, bytes.NewReader(jsonBytes)) 279 if err != nil { 280 return fmt.Errorf("failed to save config file: %w", err) 281 } 282 defer func() { 283 if returnErr != nil { 284 // clean up the ingest file in case of error 285 os.Remove(ingest) 286 } 287 }() 288 289 // overwrite the config file 290 if err := os.Rename(ingest, cfg.path); err != nil { 291 return fmt.Errorf("failed to save config file: %w", err) 292 } 293 return nil 294 } 295 296 // encodeAuth base64-encodes username and password into base64(username:password). 297 func encodeAuth(username, password string) string { 298 if username == "" && password == "" { 299 return "" 300 } 301 return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) 302 } 303 304 // decodeAuth decodes a base64 encoded string and returns username and password. 305 func decodeAuth(authStr string) (username string, password string, err error) { 306 if authStr == "" { 307 return "", "", nil 308 } 309 310 decoded, err := base64.StdEncoding.DecodeString(authStr) 311 if err != nil { 312 return "", "", err 313 } 314 decodedStr := string(decoded) 315 username, password, ok := strings.Cut(decodedStr, ":") 316 if !ok { 317 return "", "", fmt.Errorf("auth '%s' does not conform the base64(username:password) format", decodedStr) 318 } 319 return username, password, nil 320 } 321 322 // toHostname normalizes a server address to just its hostname, removing 323 // the scheme and the path parts. 324 // It is used to match keys in the auths map, which may be either stored as 325 // hostname or as hostname including scheme (in legacy docker config files). 326 // Reference: https://github.com/docker/cli/blob/v24.0.6/cli/config/credentials/file_store.go#L71 327 func toHostname(addr string) string { 328 addr = strings.TrimPrefix(addr, "http://") 329 addr = strings.TrimPrefix(addr, "https://") 330 addr, _, _ = strings.Cut(addr, "/") 331 return addr 332 }