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  }