diff --git a/file_store.go b/file_store.go index 0e5cdab..b57b0b5 100644 --- a/file_store.go +++ b/file_store.go @@ -16,18 +16,12 @@ limitations under the License. package credentials import ( - "bytes" "context" - "encoding/base64" - "encoding/json" "errors" "fmt" - "os" - "path/filepath" "strings" - "sync" - "github.com/oras-project/oras-credentials-go/internal/ioutil" + "github.com/oras-project/oras-credentials-go/internal/config" "oras.land/oras-go/v2/registry/remote/auth" ) @@ -38,120 +32,35 @@ type FileStore struct { // If DisablePut is set to true, Put() will return ErrPlaintextPutDisabled. DisablePut bool - // configPath is the path to the config file. - configPath string - // content is the content of the config file. - // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.1/cli/config/configfile/file.go#L17-L45 - content map[string]json.RawMessage - // authsCache is a cache of the auths field of the config field. - // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.1/cli/config/configfile/file.go#L19 - authsCache map[string]json.RawMessage - // rwLock is a read-write-lock for the file store. - rwLock sync.RWMutex + config *config.Config } -// configFieldAuths is the "auths" field in the config file. -// Reference: https://github.com/docker/cli/blob/v24.0.0-beta.1/cli/config/configfile/file.go#L19 -const configFieldAuths = "auths" - var ( - // ErrInvalidConfigFormat is returned when the config format is invalid. - ErrInvalidConfigFormat = errors.New("invalid config format") // ErrPlaintextPutDisabled is returned by Put() when DisablePut is set // to true. ErrPlaintextPutDisabled = errors.New("putting plaintext credentials is disabled") + // ErrBadCredentialFormat is returned by Put() when the credential format + // is bad. + ErrBadCredentialFormat = errors.New("bad credential format") ) -// authConfig contains authorization information for connecting to a Registry. -// References: -// - https://github.com/docker/cli/blob/v24.0.0-beta.1/cli/config/configfile/file.go#L17-L45 -// - https://github.com/docker/cli/blob/v24.0.0-beta.1/cli/config/types/authconfig.go#L3-L22 -type authConfig struct { - // Auth is a base64-encoded string of "{username}:{password}". - Auth string `json:"auth,omitempty"` - // IdentityToken is used to authenticate the user and get. - // an access token for the registry. - IdentityToken string `json:"identitytoken,omitempty"` - // RegistryToken is a bearer token to be sent to a registry. - RegistryToken string `json:"registrytoken,omitempty"` - - Username string `json:"username,omitempty"` // legacy field for compatibility - Password string `json:"password,omitempty"` // legacy field for compatibility -} - -// newAuthConfig creates an authConfig based on cred. -func newAuthConfig(cred auth.Credential) authConfig { - return authConfig{ - Auth: encodeAuth(cred.Username, cred.Password), - IdentityToken: cred.RefreshToken, - RegistryToken: cred.AccessToken, - } -} - -// Credential returns an auth.Credential based on ac. -func (ac authConfig) Credential() (auth.Credential, error) { - cred := auth.Credential{ - Username: ac.Username, - Password: ac.Password, - RefreshToken: ac.IdentityToken, - AccessToken: ac.RegistryToken, - } - if ac.Auth != "" { - var err error - // override username and password - cred.Username, cred.Password, err = decodeAuth(ac.Auth) - if err != nil { - return auth.EmptyCredential, fmt.Errorf("failed to decode auth field: %w: %v", ErrInvalidConfigFormat, err) - } - } - return cred, nil -} - // NewFileStore creates a new file credentials store. func NewFileStore(configPath string) (*FileStore, error) { - fs := &FileStore{configPath: configPath} - configFile, err := os.Open(configPath) + cfg, err := config.Load(configPath) if err != nil { - if os.IsNotExist(err) { - // init content map and auths cache if the content file does not exist - fs.content = make(map[string]json.RawMessage) - fs.authsCache = make(map[string]json.RawMessage) - return fs, nil - } - return nil, fmt.Errorf("failed to open config file at %s: %w", configPath, err) + return nil, err } - defer configFile.Close() + return newFileStore(cfg), nil +} - // decode config content if the config file exists - if err := json.NewDecoder(configFile).Decode(&fs.content); err != nil { - return nil, fmt.Errorf("failed to decode config file at %s: %w: %v", configPath, ErrInvalidConfigFormat, err) - } - authsBytes, ok := fs.content[configFieldAuths] - if !ok { - // init auths cache - fs.authsCache = make(map[string]json.RawMessage) - return fs, nil - } - if err := json.Unmarshal(authsBytes, &fs.authsCache); err != nil { - return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err) - } - return fs, nil +// newFileStore creates a file credentials store based on the given config instance. +func newFileStore(cfg *config.Config) *FileStore { + return &FileStore{config: cfg} } // Get retrieves credentials from the store for the given server address. func (fs *FileStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) { - fs.rwLock.RLock() - defer fs.rwLock.RUnlock() - - authCfgBytes, ok := fs.authsCache[serverAddress] - if !ok { - return auth.EmptyCredential, nil - } - var authCfg authConfig - if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil { - return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err) - } - return authCfg.Credential() + return fs.config.GetCredential(serverAddress) } // Put saves credentials into the store for the given server address. @@ -160,90 +69,25 @@ func (fs *FileStore) Put(_ context.Context, serverAddress string, cred auth.Cred if fs.DisablePut { return ErrPlaintextPutDisabled } - - fs.rwLock.Lock() - defer fs.rwLock.Unlock() - - authCfg := newAuthConfig(cred) - authCfgBytes, err := json.Marshal(authCfg) - if err != nil { - return fmt.Errorf("failed to marshal auth field: %w", err) + if err := validateCredentialFormat(cred); err != nil { + return err } - fs.authsCache[serverAddress] = authCfgBytes - return fs.saveFile() + + return fs.config.PutCredential(serverAddress, cred) } // Delete removes credentials from the store for the given server address. func (fs *FileStore) Delete(_ context.Context, serverAddress string) error { - fs.rwLock.Lock() - defer fs.rwLock.Unlock() - - if _, ok := fs.authsCache[serverAddress]; !ok { - // no ops - return nil - } - delete(fs.authsCache, serverAddress) - return fs.saveFile() + return fs.config.DeleteCredential(serverAddress) } -// saveFile saves fs.content into fs.configPath. -func (fs *FileStore) saveFile() (returnErr error) { - // marshal content - authsBytes, err := json.Marshal(fs.authsCache) - if err != nil { - return fmt.Errorf("failed to marshal credentials: %w", err) - } - fs.content[configFieldAuths] = authsBytes - jsonBytes, err := json.MarshalIndent(fs.content, "", "\t") - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - // write the content to a ingest file for atomicity - configDir := filepath.Dir(fs.configPath) - if err := os.MkdirAll(configDir, 0700); err != nil { - return fmt.Errorf("failed to make directory %s: %w", configDir, err) - } - ingest, err := ioutil.Ingest(configDir, bytes.NewReader(jsonBytes)) - if err != nil { - return fmt.Errorf("failed to save config file: %w", err) - } - defer func() { - if returnErr != nil { - // clean up the ingest file in case of error - os.Remove(ingest) - } - }() - - // overwrite the config file - if err := os.Rename(ingest, fs.configPath); err != nil { - return fmt.Errorf("failed to save config file: %w", err) +// validateCredentialFormat validates the format of cred. +func validateCredentialFormat(cred auth.Credential) error { + if strings.ContainsRune(cred.Username, ':') { + // Username and password will be encoded in the base64(username:password) + // format in the file. The decoded result will be wrong if username + // contains colon(s). + return fmt.Errorf("%w: colons(:) are not allowed in username", ErrBadCredentialFormat) } return nil } - -// encodeAuth base64-encodes username and password into base64(username:password). -func encodeAuth(username, password string) string { - if username == "" && password == "" { - return "" - } - return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) -} - -// decodeAuth decodes a base64 encoded string and returns username and password. -func decodeAuth(authStr string) (username string, password string, err error) { - if authStr == "" { - return "", "", nil - } - - decoded, err := base64.StdEncoding.DecodeString(authStr) - if err != nil { - return "", "", err - } - decodedStr := string(decoded) - username, password, ok := strings.Cut(decodedStr, ":") - if !ok { - return "", "", fmt.Errorf("auth '%s' does not conform the base64(username:password) format", decodedStr) - } - return username, password, nil -} diff --git a/file_store_test.go b/file_store_test.go index 86e50d3..a4b78b5 100644 --- a/file_store_test.go +++ b/file_store_test.go @@ -24,27 +24,10 @@ import ( "reflect" "testing" + "github.com/oras-project/oras-credentials-go/internal/config/configtest" "oras.land/oras-go/v2/registry/remote/auth" ) -type testAuthConfig struct { - SomeAuthField string `json:"some_auth_field,omitempty"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - Auth string `json:"auth,omitempty"` - - // IdentityToken is used to authenticate the user and get - // an access token for the registry. - IdentityToken string `json:"identitytoken,omitempty"` - // RegistryToken is a bearer token to be sent to a registry - RegistryToken string `json:"registrytoken,omitempty"` -} - -type testConfig struct { - SomeConfigField int `json:"some_config_field"` - AuthConfigs map[string]testAuthConfig `json:"auths"` -} - func TestNewFileStore_badPath(t *testing.T) { tempDir := t.TempDir() @@ -79,28 +62,28 @@ func TestNewFileStore_badFormat(t *testing.T) { tests := []struct { name string configPath string - wantErr error + wantErr bool }{ { name: "Bad JSON format", configPath: "testdata/bad_config", - wantErr: ErrInvalidConfigFormat, + wantErr: true, }, { name: "Invalid auths format", configPath: "testdata/invalid_auths_config.json", - wantErr: ErrInvalidConfigFormat, + wantErr: true, }, { name: "No auths field", configPath: "testdata/no_auths_config.json", - wantErr: nil, + wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := NewFileStore(tt.configPath) - if !errors.Is(err, tt.wantErr) { + if (err != nil) != tt.wantErr { t.Errorf("NewFileStore() error = %v, wantErr %v", err, tt.wantErr) return } @@ -210,31 +193,31 @@ func TestFileStore_Get_invalidConfig(t *testing.T) { name string serverAddress string want auth.Credential - wantErr error + wantErr bool }{ { name: "Invalid auth encode", serverAddress: "registry1.example.com", want: auth.EmptyCredential, - wantErr: ErrInvalidConfigFormat, + wantErr: true, }, { name: "Invalid auths format", serverAddress: "registry2.example.com", want: auth.EmptyCredential, - wantErr: ErrInvalidConfigFormat, + wantErr: true, }, { name: "Invalid type", serverAddress: "registry3.example.com", want: auth.EmptyCredential, - wantErr: ErrInvalidConfigFormat, + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := fs.Get(ctx, tt.serverAddress) - if !errors.Is(err, tt.wantErr) { + if (err != nil) != tt.wantErr { t.Errorf("FileStore.Get() error = %v, wantErr %v", err, tt.wantErr) return } @@ -343,12 +326,12 @@ func TestFileStore_Put_notExistConfig(t *testing.T) { } defer configFile.Close() - var cfg testConfig + var cfg configtest.Config if err := json.NewDecoder(configFile).Decode(&cfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } - want := testConfig{ - AuthConfigs: map[string]testAuthConfig{ + want := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: "refresh_token", @@ -384,8 +367,8 @@ func TestFileStore_Put_addNew(t *testing.T) { AccessToken: "access_token", } - cfg := testConfig{ - AuthConfigs: map[string]testAuthConfig{ + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ server1: { SomeAuthField: "whatever", Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", @@ -425,12 +408,12 @@ func TestFileStore_Put_addNew(t *testing.T) { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() - var gotCfg testConfig + var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } - wantCfg := testConfig{ - AuthConfigs: map[string]testAuthConfig{ + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ server1: { SomeAuthField: "whatever", Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", @@ -474,8 +457,8 @@ func TestFileStore_Put_updateOld(t *testing.T) { // prepare test content server := "registry.example.com" - cfg := testConfig{ - AuthConfigs: map[string]testAuthConfig{ + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ server: { SomeAuthField: "whatever", Username: "foo", @@ -513,12 +496,12 @@ func TestFileStore_Put_updateOld(t *testing.T) { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() - var gotCfg testConfig + var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } - wantCfg := testConfig{ - AuthConfigs: map[string]testAuthConfig{ + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", RegistryToken: "access_token", @@ -540,7 +523,7 @@ func TestFileStore_Put_updateOld(t *testing.T) { } } -func TestStore_Put_disableSave(t *testing.T) { +func TestFileStore_Put_disablePut(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") ctx := context.Background() @@ -564,6 +547,51 @@ func TestStore_Put_disableSave(t *testing.T) { } } +func TestFileStore_Put_usernameContainsColon(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + serverAddr := "test.example.com" + cred := auth.Credential{ + Username: "x:y", + Password: "z", + } + if err := fs.Put(ctx, serverAddr, cred); err == nil { + t.Fatal("FileStore.Put() error is nil, want", ErrBadCredentialFormat) + } +} + +func TestFileStore_Put_passwordContainsColon(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + serverAddr := "test.example.com" + cred := auth.Credential{ + Username: "y", + Password: "y:z", + } + if err := fs.Put(ctx, serverAddr, cred); err != nil { + t.Fatal("FileStore.Put() error =", err) + } + got, err := fs.Get(ctx, serverAddr) + if err != nil { + t.Fatal("FileStore.Get() error =", err) + } + if !reflect.DeepEqual(got, cred) { + t.Errorf("FileStore.Get() = %v, want %v", got, cred) + } +} + func TestFileStore_Delete(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") @@ -585,8 +613,8 @@ func TestFileStore_Delete(t *testing.T) { AccessToken: "access_token_2", } - cfg := testConfig{ - AuthConfigs: map[string]testAuthConfig{ + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ server1: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred1.RefreshToken, @@ -639,12 +667,12 @@ func TestFileStore_Delete(t *testing.T) { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() - var gotCfg testConfig + var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } - wantCfg := testConfig{ - AuthConfigs: map[string]testAuthConfig{ + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ server2: cfg.AuthConfigs[server2], }, SomeConfigField: cfg.SomeConfigField, @@ -684,8 +712,8 @@ func TestFileStore_Delete_lastConfig(t *testing.T) { AccessToken: "access_token", } - cfg := testConfig{ - AuthConfigs: map[string]testAuthConfig{ + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred.RefreshToken, @@ -726,12 +754,12 @@ func TestFileStore_Delete_lastConfig(t *testing.T) { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() - var gotCfg testConfig + var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } - wantCfg := testConfig{ - AuthConfigs: map[string]testAuthConfig{}, + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{}, SomeConfigField: cfg.SomeConfigField, } if !reflect.DeepEqual(gotCfg, wantCfg) { @@ -761,8 +789,8 @@ func TestFileStore_Delete_notExistRecord(t *testing.T) { RefreshToken: "refresh_token", AccessToken: "access_token", } - cfg := testConfig{ - AuthConfigs: map[string]testAuthConfig{ + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred.RefreshToken, @@ -803,12 +831,12 @@ func TestFileStore_Delete_notExistRecord(t *testing.T) { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() - var gotCfg testConfig + var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } - wantCfg := testConfig{ - AuthConfigs: map[string]testAuthConfig{ + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ server: cfg.AuthConfigs[server], }, SomeConfigField: cfg.SomeConfigField, @@ -850,105 +878,32 @@ func TestFileStore_Delete_notExistConfig(t *testing.T) { } } -func Test_encodeAuth(t *testing.T) { +func Test_validateCredentialFormat(t *testing.T) { tests := []struct { - name string - username string - password string - want string + name string + cred auth.Credential + wantErr error }{ { - name: "Username and password", - username: "username", - password: "password", - want: "dXNlcm5hbWU6cGFzc3dvcmQ=", - }, - { - name: "Username only", - username: "username", - password: "", - want: "dXNlcm5hbWU6", - }, - { - name: "Password only", - username: "", - password: "password", - want: "OnBhc3N3b3Jk", - }, - { - name: "Empty username and empty password", - username: "", - password: "", - want: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := encodeAuth(tt.username, tt.password); got != tt.want { - t.Errorf("encodeAuth() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_decodeAuth(t *testing.T) { - tests := []struct { - name string - authStr string - username string - password string - wantErr bool - }{ - { - name: "Valid base64", - authStr: "dXNlcm5hbWU6cGFzc3dvcmQ=", // username:password - username: "username", - password: "password", - }, - { - name: "Valid base64, username only", - authStr: "dXNlcm5hbWU6", // username: - username: "username", - }, - { - name: "Valid base64, password only", - authStr: "OnBhc3N3b3Jk", // :password - password: "password", - }, - { - name: "Valid base64, bad format", - authStr: "d2hhdGV2ZXI=", // whatever - username: "", - password: "", - wantErr: true, - }, - { - name: "Invalid base64", - authStr: "whatever", - username: "", - password: "", - wantErr: true, + name: "Username contains colon", + cred: auth.Credential{ + Username: "x:y", + Password: "z", + }, + wantErr: ErrBadCredentialFormat, }, { - name: "Empty string", - authStr: "", - username: "", - password: "", - wantErr: false, + name: "Password contains colon", + cred: auth.Credential{ + Username: "x", + Password: "y:z", + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotUsername, gotPassword, err := decodeAuth(tt.authStr) - if (err != nil) != tt.wantErr { - t.Errorf("decodeAuth() error = %v, wantErr %v", err, tt.wantErr) - return - } - if gotUsername != tt.username { - t.Errorf("decodeAuth() got = %v, want %v", gotUsername, tt.username) - } - if gotPassword != tt.password { - t.Errorf("decodeAuth() got1 = %v, want %v", gotPassword, tt.password) + if err := validateCredentialFormat(tt.cred); !errors.Is(err, tt.wantErr) { + t.Errorf("validateCredentialFormat() error = %v, wantErr %v", err, tt.wantErr) } }) } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1758c5e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,300 @@ +/* +Copyright The ORAS Authors. +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 config + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/oras-project/oras-credentials-go/internal/ioutil" + "oras.land/oras-go/v2/registry/remote/auth" +) + +const ( + // configFieldAuths is the "auths" field in the config file. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19 + configFieldAuths = "auths" + // configFieldCredentialsStore is the "credsStore" field in the config file. + configFieldCredentialsStore = "credsStore" + // configFieldCredentialHelpers is the "credHelpers" field in the config file. + configFieldCredentialHelpers = "credHelpers" +) + +// ErrInvalidConfigFormat is returned when the config format is invalid. +var ErrInvalidConfigFormat = errors.New("invalid config format") + +// AuthConfig contains authorization information for connecting to a Registry. +// References: +// - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L45 +// - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/types/authconfig.go#L3-L22 +type AuthConfig struct { + // Auth is a base64-encoded string of "{username}:{password}". + Auth string `json:"auth,omitempty"` + // IdentityToken is used to authenticate the user and get an access token + // for the registry. + IdentityToken string `json:"identitytoken,omitempty"` + // RegistryToken is a bearer token to be sent to a registry. + RegistryToken string `json:"registrytoken,omitempty"` + + Username string `json:"username,omitempty"` // legacy field for compatibility + Password string `json:"password,omitempty"` // legacy field for compatibility +} + +// NewAuthConfig creates an authConfig based on cred. +func NewAuthConfig(cred auth.Credential) AuthConfig { + return AuthConfig{ + Auth: encodeAuth(cred.Username, cred.Password), + IdentityToken: cred.RefreshToken, + RegistryToken: cred.AccessToken, + } +} + +// Credential returns an auth.Credential based on ac. +func (ac AuthConfig) Credential() (auth.Credential, error) { + cred := auth.Credential{ + Username: ac.Username, + Password: ac.Password, + RefreshToken: ac.IdentityToken, + AccessToken: ac.RegistryToken, + } + if ac.Auth != "" { + var err error + // override username and password + cred.Username, cred.Password, err = decodeAuth(ac.Auth) + if err != nil { + return auth.EmptyCredential, fmt.Errorf("failed to decode auth field: %w: %v", ErrInvalidConfigFormat, err) + } + } + return cred, nil +} + +// Config represents a docker configuration file. +// Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44 +type Config struct { + // path is the path to the config file. + path string + // rwLock is a read-write-lock for the file store. + rwLock sync.RWMutex + // content is the content of the config file. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44 + content map[string]json.RawMessage + // authsCache is a cache of the auths field of the config. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19 + authsCache map[string]json.RawMessage + // credentialsStore is the credsStore field of the config. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L28 + credentialsStore string + // credentialHelpers is the credHelpers field of the config. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L29 + credentialHelpers map[string]string +} + +// Load loads Config from the given config path. +func Load(configPath string) (*Config, error) { + cfg := &Config{path: configPath} + configFile, err := os.Open(configPath) + if err != nil { + if os.IsNotExist(err) { + // init content and caches if the content file does not exist + cfg.content = make(map[string]json.RawMessage) + cfg.authsCache = make(map[string]json.RawMessage) + return cfg, nil + } + return nil, fmt.Errorf("failed to open config file at %s: %w", configPath, err) + } + defer configFile.Close() + + // decode config content if the config file exists + if err := json.NewDecoder(configFile).Decode(&cfg.content); err != nil { + return nil, fmt.Errorf("failed to decode config file at %s: %w: %v", configPath, ErrInvalidConfigFormat, err) + } + + if credsStoreBytes, ok := cfg.content[configFieldCredentialsStore]; ok { + if err := json.Unmarshal(credsStoreBytes, &cfg.credentialsStore); err != nil { + return nil, fmt.Errorf("failed to unmarshal creds store field: %w: %v", ErrInvalidConfigFormat, err) + } + } + + if credHelpersBytes, ok := cfg.content[configFieldCredentialHelpers]; ok { + if err := json.Unmarshal(credHelpersBytes, &cfg.credentialHelpers); err != nil { + return nil, fmt.Errorf("failed to unmarshal cred helpers field: %w: %v", ErrInvalidConfigFormat, err) + } + } + + if authsBytes, ok := cfg.content[configFieldAuths]; ok { + if err := json.Unmarshal(authsBytes, &cfg.authsCache); err != nil { + return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err) + } + } + if cfg.authsCache == nil { + cfg.authsCache = make(map[string]json.RawMessage) + } + + return cfg, nil +} + +// GetAuthConfig returns an auth.Credential for serverAddress. +func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error) { + cfg.rwLock.RLock() + defer cfg.rwLock.RUnlock() + + authCfgBytes, ok := cfg.authsCache[serverAddress] + if !ok { + return auth.EmptyCredential, nil + } + var authCfg AuthConfig + if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil { + return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err) + } + return authCfg.Credential() +} + +// PutAuthConfig puts cred for serverAddress. +func (cfg *Config) PutCredential(serverAddress string, cred auth.Credential) error { + cfg.rwLock.Lock() + defer cfg.rwLock.Unlock() + + authCfg := NewAuthConfig(cred) + authCfgBytes, err := json.Marshal(authCfg) + if err != nil { + return fmt.Errorf("failed to marshal auth field: %w", err) + } + cfg.authsCache[serverAddress] = authCfgBytes + return cfg.saveFile() +} + +// DeleteAuthConfig deletes the corresponding credential for serverAddress. +func (cfg *Config) DeleteCredential(serverAddress string) error { + cfg.rwLock.Lock() + defer cfg.rwLock.Unlock() + + if _, ok := cfg.authsCache[serverAddress]; !ok { + // no ops + return nil + } + delete(cfg.authsCache, serverAddress) + return cfg.saveFile() +} + +// GetCredentialHelper returns the credential helpers for serverAddress. +func (cfg *Config) GetCredentialHelper(serverAddress string) string { + return cfg.credentialHelpers[serverAddress] +} + +// CredentialsStore returns the configured credentials store. +func (cfg *Config) CredentialsStore() string { + cfg.rwLock.RLock() + defer cfg.rwLock.RUnlock() + + return cfg.credentialsStore +} + +// SetCredentialsStore puts the configured credentials store. +func (cfg *Config) SetCredentialsStore(credsStore string) error { + cfg.rwLock.Lock() + defer cfg.rwLock.Unlock() + + cfg.credentialsStore = credsStore + return cfg.saveFile() +} + +// IsAuthConfigured returns whether there is authentication configured in this +// config file or not. +func (cfg *Config) IsAuthConfigured() bool { + return cfg.credentialsStore != "" || + len(cfg.credentialHelpers) > 0 || + len(cfg.authsCache) > 0 +} + +// saveFile saves Config into the file. +func (cfg *Config) saveFile() (returnErr error) { + // marshal content + // credentialHelpers is skipped as it's never set + if cfg.credentialsStore != "" { + credsStoreBytes, err := json.Marshal(cfg.credentialsStore) + if err != nil { + return fmt.Errorf("failed to marshal creds store: %w", err) + } + cfg.content[configFieldCredentialsStore] = credsStoreBytes + } else { + // omit empty + delete(cfg.content, configFieldCredentialsStore) + } + authsBytes, err := json.Marshal(cfg.authsCache) + if err != nil { + return fmt.Errorf("failed to marshal credentials: %w", err) + } + cfg.content[configFieldAuths] = authsBytes + jsonBytes, err := json.MarshalIndent(cfg.content, "", "\t") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // write the content to a ingest file for atomicity + configDir := filepath.Dir(cfg.path) + if err := os.MkdirAll(configDir, 0700); err != nil { + return fmt.Errorf("failed to make directory %s: %w", configDir, err) + } + ingest, err := ioutil.Ingest(configDir, bytes.NewReader(jsonBytes)) + if err != nil { + return fmt.Errorf("failed to save config file: %w", err) + } + defer func() { + if returnErr != nil { + // clean up the ingest file in case of error + os.Remove(ingest) + } + }() + + // overwrite the config file + if err := os.Rename(ingest, cfg.path); err != nil { + return fmt.Errorf("failed to save config file: %w", err) + } + return nil +} + +// encodeAuth base64-encodes username and password into base64(username:password). +func encodeAuth(username, password string) string { + if username == "" && password == "" { + return "" + } + return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) +} + +// decodeAuth decodes a base64 encoded string and returns username and password. +func decodeAuth(authStr string) (username string, password string, err error) { + if authStr == "" { + return "", "", nil + } + + decoded, err := base64.StdEncoding.DecodeString(authStr) + if err != nil { + return "", "", err + } + decodedStr := string(decoded) + username, password, ok := strings.Cut(decodedStr, ":") + if !ok { + return "", "", fmt.Errorf("auth '%s' does not conform the base64(username:password) format", decodedStr) + } + return username, password, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..a82971d --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,393 @@ +/* +Copyright The ORAS Authors. +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 config + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/oras-project/oras-credentials-go/internal/config/configtest" +) + +func TestConfig_IsAuthConfigured(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + fileName string + shouldCreateFile bool + cfg configtest.Config + want bool + }{ + { + name: "not existing file", + fileName: "config.json", + shouldCreateFile: false, + cfg: configtest.Config{}, + want: false, + }, + { + name: "no auth", + fileName: "config.json", + shouldCreateFile: true, + cfg: configtest.Config{ + SomeConfigField: 123, + }, + want: false, + }, + { + name: "empty auths exist", + fileName: "empty_auths.json", + shouldCreateFile: true, + cfg: configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{}, + }, + want: false, + }, + { + name: "auths exist, but no credential", + fileName: "no_cred_auths.json", + shouldCreateFile: true, + cfg: configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": {}, + }, + }, + want: true, + }, + { + name: "auths exist", + fileName: "auths.json", + shouldCreateFile: true, + cfg: configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + }, + }, + want: true, + }, + { + name: "credsStore exists", + fileName: "credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + CredentialsStore: "teststore", + }, + want: true, + }, + { + name: "empty credHelpers exist", + fileName: "empty_credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + CredentialHelpers: map[string]string{}, + }, + want: false, + }, + { + name: "credHelpers exist", + fileName: "credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + CredentialHelpers: map[string]string{ + "test.example.com": "testhelper", + }, + }, + want: true, + }, + { + name: "all exist", + fileName: "credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + SomeConfigField: 123, + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": {}, + }, + CredentialsStore: "teststore", + CredentialHelpers: map[string]string{ + "test.example.com": "testhelper", + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // prepare test content + configPath := filepath.Join(tempDir, tt.fileName) + if tt.shouldCreateFile { + jsonStr, err := json.Marshal(tt.cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatal("LoadConfigFile() error =", err) + } + if got := cfg.IsAuthConfigured(); got != tt.want { + t.Errorf("IsAuthConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfig_saveFile(t *testing.T) { + tempDir := t.TempDir() + tests := []struct { + name string + fileName string + shouldCreateFile bool + oldCfg configtest.Config + newCfg configtest.Config + wantCfg configtest.Config + }{ + { + name: "set credsStore in a non-existing file", + fileName: "config.json", + oldCfg: configtest.Config{}, + newCfg: configtest.Config{ + CredentialsStore: "teststore", + }, + wantCfg: configtest.Config{ + AuthConfigs: make(map[string]configtest.AuthConfig), + CredentialsStore: "teststore", + }, + shouldCreateFile: false, + }, + { + name: "set credsStore in empty file", + fileName: "empty.json", + oldCfg: configtest.Config{}, + newCfg: configtest.Config{ + CredentialsStore: "teststore", + }, + wantCfg: configtest.Config{ + AuthConfigs: make(map[string]configtest.AuthConfig), + CredentialsStore: "teststore", + }, + shouldCreateFile: true, + }, + { + name: "set credsStore in a no-auth-configured file", + fileName: "empty.json", + oldCfg: configtest.Config{ + SomeConfigField: 123, + }, + newCfg: configtest.Config{ + CredentialsStore: "teststore", + }, + wantCfg: configtest.Config{ + SomeConfigField: 123, + AuthConfigs: make(map[string]configtest.AuthConfig), + CredentialsStore: "teststore", + }, + shouldCreateFile: true, + }, + { + name: "Set credsStore and credHelpers in an auth-configured file", + fileName: "auth_configured.json", + oldCfg: configtest.Config{ + SomeConfigField: 123, + AuthConfigs: map[string]configtest.AuthConfig{ + "registry1.example.com": { + SomeAuthField: "something", + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + }, + CredentialsStore: "oldstore", + CredentialHelpers: map[string]string{ + "registry2.example.com": "testhelper", + }, + }, + newCfg: configtest.Config{ + AuthConfigs: make(map[string]configtest.AuthConfig), + SomeConfigField: 123, + CredentialsStore: "newstore", + CredentialHelpers: map[string]string{ + "xxx": "yyy", + }, + }, + wantCfg: configtest.Config{ + SomeConfigField: 123, + AuthConfigs: map[string]configtest.AuthConfig{ + "registry1.example.com": { + SomeAuthField: "something", + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + }, + CredentialsStore: "newstore", + CredentialHelpers: map[string]string{ + "registry2.example.com": "testhelper", // cred helpers will not be updated + }, + }, + shouldCreateFile: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // prepare test content + configPath := filepath.Join(tempDir, tt.fileName) + if tt.shouldCreateFile { + jsonStr, err := json.Marshal(tt.oldCfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatal("LoadConfigFile() error =", err) + } + cfg.credentialsStore = tt.newCfg.CredentialsStore + cfg.credentialHelpers = tt.newCfg.CredentialHelpers + if err := cfg.saveFile(); err != nil { + t.Fatal("saveFile() error =", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + if !reflect.DeepEqual(gotCfg, tt.wantCfg) { + t.Errorf("Decoded config = %v, want %v", gotCfg, tt.wantCfg) + } + }) + } +} + +func Test_encodeAuth(t *testing.T) { + tests := []struct { + name string + username string + password string + want string + }{ + { + name: "Username and password", + username: "username", + password: "password", + want: "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + { + name: "Username only", + username: "username", + password: "", + want: "dXNlcm5hbWU6", + }, + { + name: "Password only", + username: "", + password: "password", + want: "OnBhc3N3b3Jk", + }, + { + name: "Empty username and empty password", + username: "", + password: "", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := encodeAuth(tt.username, tt.password); got != tt.want { + t.Errorf("encodeAuth() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_decodeAuth(t *testing.T) { + tests := []struct { + name string + authStr string + username string + password string + wantErr bool + }{ + { + name: "Valid base64", + authStr: "dXNlcm5hbWU6cGFzc3dvcmQ=", // username:password + username: "username", + password: "password", + }, + { + name: "Valid base64, username only", + authStr: "dXNlcm5hbWU6", // username: + username: "username", + }, + { + name: "Valid base64, password only", + authStr: "OnBhc3N3b3Jk", // :password + password: "password", + }, + { + name: "Valid base64, bad format", + authStr: "d2hhdGV2ZXI=", // whatever + username: "", + password: "", + wantErr: true, + }, + { + name: "Invalid base64", + authStr: "whatever", + username: "", + password: "", + wantErr: true, + }, + { + name: "Empty string", + authStr: "", + username: "", + password: "", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotUsername, gotPassword, err := decodeAuth(tt.authStr) + if (err != nil) != tt.wantErr { + t.Errorf("decodeAuth() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotUsername != tt.username { + t.Errorf("decodeAuth() got = %v, want %v", gotUsername, tt.username) + } + if gotPassword != tt.password { + t.Errorf("decodeAuth() got1 = %v, want %v", gotPassword, tt.password) + } + }) + } +} diff --git a/internal/config/configtest/config.go b/internal/config/configtest/config.go new file mode 100644 index 0000000..5945e12 --- /dev/null +++ b/internal/config/configtest/config.go @@ -0,0 +1,39 @@ +/* +Copyright The ORAS Authors. +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 configtest + +// Config represents the structure of a config file for testing purpose. +type Config struct { + AuthConfigs map[string]AuthConfig `json:"auths"` + CredentialsStore string `json:"credsStore,omitempty"` + CredentialHelpers map[string]string `json:"credHelpers,omitempty"` + SomeConfigField int `json:"some_config_field"` +} + +// AuthConfig represents the structure of the "auths" field of a config file +// for testing purpose. +type AuthConfig struct { + SomeAuthField string `json:"some_auth_field,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Auth string `json:"auth,omitempty"` + + // IdentityToken is used to authenticate the user and get + // an access token for the registry. + IdentityToken string `json:"identitytoken,omitempty"` + // RegistryToken is a bearer token to be sent to a registry + RegistryToken string `json:"registrytoken,omitempty"` +} diff --git a/native_store.go b/native_store.go index 5e5c7a3..7e7f19f 100644 --- a/native_store.go +++ b/native_store.go @@ -17,6 +17,7 @@ package credentials import ( "context" + "os/exec" "github.com/docker/docker-credential-helpers/client" "github.com/docker/docker-credential-helpers/credentials" @@ -81,3 +82,12 @@ func (ns *nativeStore) Put(_ context.Context, serverAddress string, cred auth.Cr func (ns *nativeStore) Delete(_ context.Context, serverAddress string) error { return client.Erase(ns.programFunc, serverAddress) } + +// getDefaultHelperSuffix returns the default credential helper suffix. +func getDefaultHelperSuffix() string { + platformDefault := getPlatformDefaultHelperSuffix() + if _, err := exec.LookPath(remoteCredentialsPrefix + platformDefault); err == nil { + return platformDefault + } + return "" +} diff --git a/native_store_darwin.go b/native_store_darwin.go new file mode 100644 index 0000000..1a9aca6 --- /dev/null +++ b/native_store_darwin.go @@ -0,0 +1,23 @@ +/* +Copyright The ORAS Authors. +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 credentials + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + return "osxkeychain" +} diff --git a/native_store_generic.go b/native_store_generic.go new file mode 100644 index 0000000..5c7d4a3 --- /dev/null +++ b/native_store_generic.go @@ -0,0 +1,25 @@ +//go:build !windows && !darwin && !linux + +/* +Copyright The ORAS Authors. +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 credentials + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + return "" +} diff --git a/native_store_linux.go b/native_store_linux.go new file mode 100644 index 0000000..f182923 --- /dev/null +++ b/native_store_linux.go @@ -0,0 +1,29 @@ +/* +Copyright The ORAS Authors. +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 credentials + +import "os/exec" + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + if _, err := exec.LookPath("pass"); err == nil { + return "pass" + } + + return "secretservice" +} diff --git a/native_store_windows.go b/native_store_windows.go new file mode 100644 index 0000000..e334cc7 --- /dev/null +++ b/native_store_windows.go @@ -0,0 +1,23 @@ +/* +Copyright The ORAS Authors. +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 credentials + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + return "wincred" +} diff --git a/store.go b/store.go index b3de935..87e8abb 100644 --- a/store.go +++ b/store.go @@ -17,7 +17,10 @@ package credentials import ( "context" + "fmt" + "sync" + "github.com/oras-project/oras-credentials-go/internal/config" "oras.land/oras-go/v2/registry/remote/auth" ) @@ -31,6 +34,112 @@ type Store interface { Delete(ctx context.Context, serverAddress string) error } +// dynamicStore dynamically determines which store to use based on the settings +// in the config file. +type dynamicStore struct { + config *config.Config + options StoreOptions + detectedCredsStore string + setCredsStoreOnce sync.Once +} + +// StoreOptions provides options for NewStore. +type StoreOptions struct { + // AllowPlaintextPut allows saving credentials in plaintext in the config + // file. + // - If AllowPlaintextPut is set to false (default value), Put() will + // return an error when native store is not available. + // - If AllowPlaintextPut is set to true, Put() will save credentials in + // plaintext in the config file when native store is not available. + AllowPlaintextPut bool +} + +// NewStore returns a Store based on the given configuration file. +// +// For Get(), Put() and Delete(), the returned Store will dynamically determine which underlying credentials +// store to used for the given server address. +// The underlying credentials store is determined in the following order: +// 1. Native server-specific credential helper +// 2. Native credentials store +// 3. The plain-text config file itself +// +// If the config file has no authentication information, a platform-default +// native store will be used. +// - Windows: "wincred" +// - Linux: "pass" or "secretservice" +// - macOS: "osxkeychain" +// +// Reference: https://docs.docker.com/engine/reference/commandline/login/#credentials-store +func NewStore(configPath string, opts StoreOptions) (Store, error) { + cfg, err := config.Load(configPath) + if err != nil { + return nil, err + } + ds := &dynamicStore{ + config: cfg, + options: opts, + } + if !cfg.IsAuthConfigured() { + // no authentication configured, detect the default credentials store + ds.detectedCredsStore = getDefaultHelperSuffix() + } + return ds, nil +} + +// Get retrieves credentials from the store for the given server address. +func (ds *dynamicStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { + return ds.getStore(serverAddress).Get(ctx, serverAddress) +} + +// Put saves credentials into the store for the given server address. +// Returns ErrPlaintextPutDisabled if native store is not available and +// StoreOptions.AllowPlaintextPut is set to false. +func (ds *dynamicStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) (returnErr error) { + if err := ds.getStore(serverAddress).Put(ctx, serverAddress, cred); err != nil { + return err + } + // save the detected creds store back to the config file on first put + ds.setCredsStoreOnce.Do(func() { + if ds.detectedCredsStore != "" { + if err := ds.config.SetCredentialsStore(ds.detectedCredsStore); err != nil { + returnErr = fmt.Errorf("failed to set credsStore: %w", err) + } + } + }) + return returnErr +} + +// Delete removes credentials from the store for the given server address. +func (ds *dynamicStore) Delete(ctx context.Context, serverAddress string) error { + return ds.getStore(serverAddress).Delete(ctx, serverAddress) +} + +// getHelperSuffix returns the credential helper suffix for the given server +// address. +func (ds *dynamicStore) getHelperSuffix(serverAddress string) string { + // 1. Look for a server-specific credential helper first + if helper := ds.config.GetCredentialHelper(serverAddress); helper != "" { + return helper + } + // 2. Then look for the configured native store + if credsStore := ds.config.CredentialsStore(); credsStore != "" { + return credsStore + } + // 3. Use the detected default store + return ds.detectedCredsStore +} + +// getStore returns a store for the given server address. +func (ds *dynamicStore) getStore(serverAddress string) Store { + if helper := ds.getHelperSuffix(serverAddress); helper != "" { + return NewNativeStore(helper) + } + + fs := newFileStore(ds.config) + fs.DisablePut = !ds.options.AllowPlaintextPut + return fs +} + // storeWithFallbacks is a store that has multiple fallback stores. type storeWithFallbacks struct { stores []Store diff --git a/store_test.go b/store_test.go index 4960356..96cd0f7 100644 --- a/store_test.go +++ b/store_test.go @@ -17,12 +17,360 @@ package credentials import ( "context" + "encoding/json" + "errors" + "os" + "path/filepath" "reflect" "testing" + "github.com/oras-project/oras-credentials-go/internal/config/configtest" "oras.land/oras-go/v2/registry/remote/auth" ) +func Test_dynamicStore_authConfigured(t *testing.T) { + // prepare test content + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "auth_configured.json") + config := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "xxx": {}, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + store, err := NewStore(configPath, StoreOptions{AllowPlaintextPut: true}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + ds := store.(*dynamicStore) + serverAddr := "test.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + } + ctx := context.Background() + + // test put + if err := ds.Put(ctx, serverAddr, cred); err != nil { + t.Fatal("dynamicStore.Get() error =", err) + } + + // test get + got, err := ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("dynamicStore.Get() error =", err) + } + if want := cred; got != want { + t.Errorf("dynamicStore.Get() = %v, want %v", got, want) + } + + // test delete + err = ds.Delete(ctx, serverAddr) + if err != nil { + t.Fatal("dynamicStore.Delete() error =", err) + } + + // verify delete + got, err = ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("dynamicStore.Get() error =", err) + } + if want := auth.EmptyCredential; got != want { + t.Errorf("dynamicStore.Get() = %v, want %v", got, want) + } +} + +func Test_dynamicStore_noAuthConfigured(t *testing.T) { + // prepare test content + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "no_auth_configured.json") + cfg := configtest.Config{ + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + store, err := NewStore(configPath, StoreOptions{AllowPlaintextPut: true}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + ds := store.(*dynamicStore) + serverAddr := "test.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + } + ctx := context.Background() + + // Get() should not set detected store back to config + if _, err := ds.Get(ctx, serverAddr); err != nil { + t.Fatal("dynamicStore.Get() error =", err) + } + if got := ds.config.CredentialsStore(); got != "" { + t.Errorf("ds.config.CredentialsStore() = %v, want empty", got) + } + + // test put + if err := ds.Put(ctx, serverAddr, cred); err != nil { + t.Fatal("dynamicStore.Put() error =", err) + } + + // Put() should set detected store back to config + if defaultStore := getDefaultHelperSuffix(); defaultStore != "" { + if got := ds.config.CredentialsStore(); got != defaultStore { + t.Errorf("ds.config.CredentialsStore() = %v, want %v", got, defaultStore) + } + } + + // test get + got, err := ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("dynamicStore.Get() error =", err) + } + if want := cred; got != want { + t.Errorf("dynamicStore.Get() = %v, want %v", got, want) + } + + // test delete + err = ds.Delete(ctx, serverAddr) + if err != nil { + t.Fatal("dynamicStore.Delete() error =", err) + } + + // verify delete + got, err = ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("dynamicStore.Get() error =", err) + } + if want := auth.EmptyCredential; got != want { + t.Errorf("dynamicStore.Get() = %v, want %v", got, want) + } +} + +func Test_dynamicStore_fileStore_AllowPlainTextPut(t *testing.T) { + // prepare test content + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + serverAddr := "newtest.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + } + ctx := context.Background() + + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": {}, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // test default option + ds, err := NewStore(configPath, StoreOptions{}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + err = ds.Put(ctx, serverAddr, cred) + if wantErr := ErrPlaintextPutDisabled; !errors.Is(err, wantErr) { + t.Errorf("dynamicStore.Put() error = %v, wantErr %v", err, wantErr) + } + + // test AllowPlainTextPut = true + ds, err = NewStore(configPath, StoreOptions{AllowPlaintextPut: true}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + if err := ds.Put(ctx, serverAddr, cred); err != nil { + t.Error("dynamicStore.Put() error =", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": {}, + serverAddr: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + }, + SomeConfigField: cfg.SomeConfigField, + } + if !reflect.DeepEqual(gotCfg, wantCfg) { + t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) + } +} + +func Test_dynamicStore_getHelperSuffix(t *testing.T) { + tests := []struct { + name string + configPath string + serverAddress string + want string + }{ + { + name: "Get cred helper: registry_helper1", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry1.example.com", + want: "registry1-helper", + }, + { + name: "Get cred helper: registry_helper2", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry2.example.com", + want: "registry2-helper", + }, + { + name: "Empty cred helper configured", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry3.example.com", + want: "", + }, + { + name: "No cred helper and creds store configured", + configPath: "testdata/credHelpers_config.json", + serverAddress: "whatever.example.com", + want: "", + }, + { + name: "Choose cred helper over creds store", + configPath: "testdata/credsStore_config.json", + serverAddress: "test.example.com", + want: "test-helper", + }, + { + name: "No cred helper configured, choose cred store", + configPath: "testdata/credsStore_config.json", + serverAddress: "whatever.example.com", + want: "teststore", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, err := NewStore(tt.configPath, StoreOptions{}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + ds := store.(*dynamicStore) + if got := ds.getHelperSuffix(tt.serverAddress); got != tt.want { + t.Errorf("dynamicStore.getHelperSuffix() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dynamicStore_getStore_nativeStore(t *testing.T) { + tests := []struct { + name string + configPath string + serverAddress string + }{ + { + name: "Cred helper configured for registry1.example.com", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry1.example.com", + }, + { + name: "Cred helper configured for registry2.example.com", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry2.example.com", + }, + { + name: "Cred helper configured for test.example.com", + configPath: "testdata/credsStore_config.json", + serverAddress: "test.example.com", + }, + { + name: "No cred helper configured, use creds store", + configPath: "testdata/credsStore_config.json", + serverAddress: "whaterver.example.com", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, err := NewStore(tt.configPath, StoreOptions{}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + ds := store.(*dynamicStore) + gotStore := ds.getStore(tt.serverAddress) + if _, ok := gotStore.(*nativeStore); !ok { + t.Errorf("gotStore is not a native store") + } + }) + } +} + +func Test_dynamicStore_getStore_fileStore(t *testing.T) { + tests := []struct { + name string + configPath string + serverAddress string + }{ + { + name: "Empty cred helper configured for registry3.example.com", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry3.example.com", + }, + { + name: "No cred helper configured", + configPath: "testdata/credHelpers_config.json", + serverAddress: "whatever.example.com", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, err := NewStore(tt.configPath, StoreOptions{}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + ds := store.(*dynamicStore) + gotStore := ds.getStore(tt.serverAddress) + gotFS1, ok := gotStore.(*FileStore) + if !ok { + t.Errorf("gotStore is not a file store") + } + + // get again, the two file stores should be based on the same config instance + gotStore = ds.getStore(tt.serverAddress) + gotFS2, ok := gotStore.(*FileStore) + if !ok { + t.Errorf("gotStore is not a file store") + } + if gotFS1.config != gotFS2.config { + t.Errorf("gotFS1 and gotFS2 are not based on the same config") + } + }) + } +} + func TestStoreWithFallbacks(t *testing.T) { // Initialize a StoreWithFallbacks primaryStore := &testStore{} diff --git a/testdata/credHelpers_config.json b/testdata/credHelpers_config.json new file mode 100644 index 0000000..f33a98e --- /dev/null +++ b/testdata/credHelpers_config.json @@ -0,0 +1,15 @@ +{ + "auths": { + "registry1.example.com": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=" + }, + "registry3.example.com": { + "auth": "Zm9vOmJhcg==" + } + }, + "credHelpers": { + "registry1.example.com": "registry1-helper", + "registry2.example.com": "registry2-helper", + "registry3.example.com": "" + } +} diff --git a/testdata/credsStore_config.json b/testdata/credsStore_config.json new file mode 100644 index 0000000..40eb384 --- /dev/null +++ b/testdata/credsStore_config.json @@ -0,0 +1,6 @@ +{ + "credHelpers": { + "test.example.com": "test-helper" + }, + "credsStore": "teststore" +}