diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index 24969ef69..396f90034 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -3,12 +3,14 @@ package configfile import ( "encoding/base64" "encoding/json" + "fmt" "io" "os" "path/filepath" "strings" "github.com/docker/cli/cli/config/credentials" + "github.com/docker/cli/cli/config/memorystore" "github.com/docker/cli/cli/config/types" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -46,6 +48,31 @@ type ConfigFile struct { Experimental string `json:"experimental,omitempty"` } +type configEnvAuth struct { + Auth string `json:"auth"` +} + +type configEnv struct { + AuthConfigs map[string]configEnvAuth `json:"auths"` +} + +// dockerEnvConfig is an environment variable that contains a JSON encoded +// credential config. It only supports storing the credentials as a base64 +// encoded string in the format base64("username:pat"). +// +// Adding additional fields will produce a parsing error. +// +// Example: +// +// { +// "auths": { +// "example.test": { +// "auth": base64-encoded-username-pat +// } +// } +// } +const dockerEnvConfig = "DOCKER_AUTH_CONFIG" + // ProxyConfig contains proxy configuration settings type ProxyConfig struct { HTTPProxy string `json:"httpProxy,omitempty"` @@ -263,10 +290,64 @@ func decodeAuth(authStr string) (string, string, error) { // GetCredentialsStore returns a new credentials store from the settings in the // configuration file func (configFile *ConfigFile) GetCredentialsStore(registryHostname string) credentials.Store { + store := credentials.NewFileStore(configFile) + if helper := getConfiguredCredentialStore(configFile, registryHostname); helper != "" { - return newNativeStore(configFile, helper) + store = newNativeStore(configFile, helper) } - return credentials.NewFileStore(configFile) + + envConfig := os.Getenv(dockerEnvConfig) + if envConfig == "" { + return store + } + + authConfig, err := parseEnvConfig(envConfig) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Failed to create credential store from DOCKER_AUTH_CONFIG: ", err) + return store + } + + // use DOCKER_AUTH_CONFIG if set + // it uses the native or file store as a fallback to fetch and store credentials + envStore, err := memorystore.New( + memorystore.WithAuthConfig(authConfig), + memorystore.WithFallbackStore(store), + ) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Failed to create credential store from DOCKER_AUTH_CONFIG: ", err) + return store + } + + return envStore +} + +func parseEnvConfig(v string) (map[string]types.AuthConfig, error) { + envConfig := &configEnv{} + decoder := json.NewDecoder(strings.NewReader(v)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(envConfig); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if decoder.More() { + return nil, errors.New("DOCKER_AUTH_CONFIG does not support more than one JSON object") + } + + authConfigs := make(map[string]types.AuthConfig) + for addr, envAuth := range envConfig.AuthConfigs { + if envAuth.Auth == "" { + return nil, fmt.Errorf("DOCKER_AUTH_CONFIG environment variable is missing key `auth` for %s", addr) + } + username, password, err := decodeAuth(envAuth.Auth) + if err != nil { + return nil, err + } + authConfigs[addr] = types.AuthConfig{ + Username: username, + Password: password, + ServerAddress: addr, + } + } + return authConfigs, nil } // var for unit testing. diff --git a/cli/config/configfile/file_test.go b/cli/config/configfile/file_test.go index 6d8533d18..92df02c74 100644 --- a/cli/config/configfile/file_test.go +++ b/cli/config/configfile/file_test.go @@ -481,6 +481,133 @@ func TestLoadFromReaderWithUsernamePassword(t *testing.T) { } } +const envTestUserPassConfig = `{ + "auths": { + "env.example.test": { + "username": "env_user", + "password": "env_pass", + "serveraddress": "env.example.test" + } + } +}` + +const envTestAuthConfig = `{ + "auths": { + "env.example.test": { + "auth": "ZW52X3VzZXI6ZW52X3Bhc3M=" + } + } +}` + +func TestGetAllCredentialsFromEnvironment(t *testing.T) { + t.Run("can parse DOCKER_AUTH_CONFIG auth field", func(t *testing.T) { + config := &ConfigFile{} + + t.Setenv("DOCKER_AUTH_CONFIG", envTestAuthConfig) + + authConfigs, err := config.GetAllCredentials() + assert.NilError(t, err) + + expected := map[string]types.AuthConfig{ + "env.example.test": { + Username: "env_user", + Password: "env_pass", + ServerAddress: "env.example.test", + }, + } + assert.Check(t, is.DeepEqual(authConfigs, expected)) + }) + + t.Run("malformed DOCKER_AUTH_CONFIG should fallback to underlying store", func(t *testing.T) { + fallbackStore := map[string]types.AuthConfig{ + "fallback.example.test": { + Username: "fallback_user", + Password: "fallback_pass", + ServerAddress: "fallback.example.test", + }, + } + config := &ConfigFile{ + AuthConfigs: fallbackStore, + } + + t.Setenv("DOCKER_AUTH_CONFIG", envTestUserPassConfig) + + authConfigs, err := config.GetAllCredentials() + assert.NilError(t, err) + + expected := fallbackStore + assert.Check(t, is.DeepEqual(authConfigs, expected)) + }) + + t.Run("can fetch credentials from DOCKER_AUTH_CONFIG and underlying store", func(t *testing.T) { + configFile := New("filename") + exampleAuth := types.AuthConfig{ + Username: "user", + Password: "pass", + } + configFile.AuthConfigs["foo.example.test"] = exampleAuth + + t.Setenv("DOCKER_AUTH_CONFIG", envTestAuthConfig) + + authConfigs, err := configFile.GetAllCredentials() + assert.NilError(t, err) + + expected := map[string]types.AuthConfig{ + "foo.example.test": exampleAuth, + "env.example.test": { + Username: "env_user", + Password: "env_pass", + ServerAddress: "env.example.test", + }, + } + assert.Check(t, is.DeepEqual(authConfigs, expected)) + + fooConfig, err := configFile.GetAuthConfig("foo.example.test") + assert.NilError(t, err) + expectedAuth := expected["foo.example.test"] + assert.Check(t, is.DeepEqual(fooConfig, expectedAuth)) + + envConfig, err := configFile.GetAuthConfig("env.example.test") + assert.NilError(t, err) + expectedAuth = expected["env.example.test"] + assert.Check(t, is.DeepEqual(envConfig, expectedAuth)) + }) + + t.Run("env is ignored when empty", func(t *testing.T) { + configFile := New("filename") + + t.Setenv("DOCKER_AUTH_CONFIG", "") + + authConfigs, err := configFile.GetAllCredentials() + assert.NilError(t, err) + assert.Check(t, is.Len(authConfigs, 0)) + }) +} + +func TestParseEnvConfig(t *testing.T) { + t.Run("should error on unexpected fields", func(t *testing.T) { + _, err := parseEnvConfig(envTestUserPassConfig) + assert.ErrorContains(t, err, "json: unknown field \"username\"") + }) + t.Run("should be able to load env credentials", func(t *testing.T) { + got, err := parseEnvConfig(envTestAuthConfig) + assert.NilError(t, err) + expected := map[string]types.AuthConfig{ + "env.example.test": { + Username: "env_user", + Password: "env_pass", + ServerAddress: "env.example.test", + }, + } + assert.NilError(t, err) + assert.Check(t, is.DeepEqual(got, expected)) + }) + t.Run("should not support multiple JSON objects", func(t *testing.T) { + _, err := parseEnvConfig(`{"auths":{"env.example.test":{"auth":"something"}}}{}`) + assert.ErrorContains(t, err, "does not support more than one JSON object") + }) +} + func TestSave(t *testing.T) { configFile := New("test-save") defer os.Remove("test-save") diff --git a/cli/config/memorystore/store.go b/cli/config/memorystore/store.go new file mode 100644 index 000000000..199083464 --- /dev/null +++ b/cli/config/memorystore/store.go @@ -0,0 +1,126 @@ +//go:build go1.23 + +package memorystore + +import ( + "errors" + "fmt" + "maps" + "os" + "sync" + + "github.com/docker/cli/cli/config/credentials" + "github.com/docker/cli/cli/config/types" +) + +var errValueNotFound = errors.New("value not found") + +func IsErrValueNotFound(err error) bool { + return errors.Is(err, errValueNotFound) +} + +type Config struct { + lock sync.RWMutex + memoryCredentials map[string]types.AuthConfig + fallbackStore credentials.Store +} + +func (e *Config) Erase(serverAddress string) error { + e.lock.Lock() + defer e.lock.Unlock() + delete(e.memoryCredentials, serverAddress) + + if e.fallbackStore != nil { + err := e.fallbackStore.Erase(serverAddress) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "memorystore: ", err) + } + } + + return nil +} + +func (e *Config) Get(serverAddress string) (types.AuthConfig, error) { + e.lock.RLock() + defer e.lock.RUnlock() + authConfig, ok := e.memoryCredentials[serverAddress] + if !ok { + if e.fallbackStore != nil { + return e.fallbackStore.Get(serverAddress) + } + return types.AuthConfig{}, errValueNotFound + } + return authConfig, nil +} + +func (e *Config) GetAll() (map[string]types.AuthConfig, error) { + e.lock.RLock() + defer e.lock.RUnlock() + creds := make(map[string]types.AuthConfig) + + if e.fallbackStore != nil { + fileCredentials, err := e.fallbackStore.GetAll() + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "memorystore: ", err) + } else { + creds = fileCredentials + } + } + + maps.Copy(creds, e.memoryCredentials) + return creds, nil +} + +func (e *Config) Store(authConfig types.AuthConfig) error { + e.lock.Lock() + defer e.lock.Unlock() + e.memoryCredentials[authConfig.ServerAddress] = authConfig + + if e.fallbackStore != nil { + return e.fallbackStore.Store(authConfig) + } + return nil +} + +// WithFallbackStore sets a fallback store. +// +// Write operations will be performed on both the memory store and the +// fallback store. +// +// Read operations will first check the memory store, and if the credential +// is not found, it will then check the fallback store. +// +// Retrieving all credentials will return from both the memory store and the +// fallback store, merging the results from both stores into a single map. +// +// Data stored in the memory store will take precedence over data in the +// fallback store. +func WithFallbackStore(store credentials.Store) Options { + return func(s *Config) error { + s.fallbackStore = store + return nil + } +} + +// WithAuthConfig allows to set the initial credentials in the memory store. +func WithAuthConfig(config map[string]types.AuthConfig) Options { + return func(s *Config) error { + s.memoryCredentials = config + return nil + } +} + +type Options func(*Config) error + +// New creates a new in memory credential store +func New(opts ...Options) (credentials.Store, error) { + m := &Config{ + memoryCredentials: make(map[string]types.AuthConfig), + } + for _, opt := range opts { + if err := opt(m); err != nil { + return nil, err + } + } + return m, nil +} diff --git a/cli/config/memorystore/store_test.go b/cli/config/memorystore/store_test.go new file mode 100644 index 000000000..a92a170a3 --- /dev/null +++ b/cli/config/memorystore/store_test.go @@ -0,0 +1,131 @@ +package memorystore + +import ( + "testing" + + "github.com/docker/cli/cli/config/types" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestMemoryStore(t *testing.T) { + config := map[string]types.AuthConfig{ + "https://example.test": { + Username: "something-something", + ServerAddress: "https://example.test", + Auth: "super_secret_token", + }, + } + + fallbackConfig := map[string]types.AuthConfig{ + "https://only-in-file.example.test": { + Username: "something-something", + ServerAddress: "https://only-in-file.example.test", + Auth: "super_secret_token", + }, + } + + fallbackStore, err := New(WithAuthConfig(fallbackConfig)) + assert.NilError(t, err) + + memoryStore, err := New(WithAuthConfig(config), WithFallbackStore(fallbackStore)) + assert.NilError(t, err) + + t.Run("get credentials from memory store", func(t *testing.T) { + c, err := memoryStore.Get("https://example.test") + assert.NilError(t, err) + assert.Equal(t, c, config["https://example.test"]) + }) + + t.Run("get credentials from fallback store", func(t *testing.T) { + c, err := memoryStore.Get("https://only-in-file.example.test") + assert.NilError(t, err) + assert.Equal(t, c, fallbackConfig["https://only-in-file.example.test"]) + }) + + t.Run("storing credentials in memory store should also be in defined fallback store", func(t *testing.T) { + err := memoryStore.Store(types.AuthConfig{ + Username: "not-in-store", + ServerAddress: "https://not-in-store.example.test", + Auth: "not-in-store_token", + }) + assert.NilError(t, err) + c, err := memoryStore.Get("https://not-in-store.example.test") + assert.NilError(t, err) + assert.Equal(t, c.Username, "not-in-store") + assert.Equal(t, c.ServerAddress, "https://not-in-store.example.test") + assert.Equal(t, c.Auth, "not-in-store_token") + + cc, err := fallbackStore.Get("https://not-in-store.example.test") + assert.NilError(t, err) + assert.Equal(t, cc.Username, "not-in-store") + assert.Equal(t, cc.ServerAddress, "https://not-in-store.example.test") + assert.Equal(t, cc.Auth, "not-in-store_token") + }) + + t.Run("delete credentials should remove credentials from memory store and fallback store", func(t *testing.T) { + err := memoryStore.Store(types.AuthConfig{ + Username: "a-new-credential", + ServerAddress: "https://a-new-credential.example.test", + Auth: "a-new-credential_token", + }) + assert.NilError(t, err) + err = memoryStore.Erase("https://a-new-credential.example.test") + assert.NilError(t, err) + _, err = memoryStore.Get("https://a-new-credential.example.test") + assert.Check(t, is.ErrorIs(err, errValueNotFound)) + _, err = fallbackStore.Get("https://a-new-credential.example.test") + assert.Check(t, is.ErrorIs(err, errValueNotFound)) + }) +} + +func TestMemoryStoreWithoutFallback(t *testing.T) { + config := map[string]types.AuthConfig{ + "https://example.test": { + Username: "something-something", + ServerAddress: "https://example.test", + Auth: "super_secret_token", + }, + } + + memoryStore, err := New(WithAuthConfig(config)) + assert.NilError(t, err) + + t.Run("get credentials from memory store without fallback", func(t *testing.T) { + c, err := memoryStore.Get("https://example.test") + assert.NilError(t, err) + assert.Equal(t, c, config["https://example.test"]) + }) + + t.Run("get non-existing credentials from memory store should error", func(t *testing.T) { + _, err := memoryStore.Get("https://not-in-store.example.test") + assert.Check(t, is.ErrorIs(err, errValueNotFound)) + }) + + t.Run("case store credentials", func(t *testing.T) { + err := memoryStore.Store(types.AuthConfig{ + Username: "not-in-store", + ServerAddress: "https://not-in-store.example.test", + Auth: "not-in-store_token", + }) + assert.NilError(t, err) + c, err := memoryStore.Get("https://not-in-store.example.test") + assert.NilError(t, err) + assert.Equal(t, c.Username, "not-in-store") + assert.Equal(t, c.ServerAddress, "https://not-in-store.example.test") + assert.Equal(t, c.Auth, "not-in-store_token") + }) + + t.Run("delete credentials should remove credentials from memory store", func(t *testing.T) { + err := memoryStore.Store(types.AuthConfig{ + Username: "a-new-credential", + ServerAddress: "https://a-new-credential.example.test", + Auth: "a-new-credential_token", + }) + assert.NilError(t, err) + err = memoryStore.Erase("https://a-new-credential.example.test") + assert.NilError(t, err) + _, err = memoryStore.Get("https://a-new-credential.example.test") + assert.Check(t, is.ErrorIs(err, errValueNotFound)) + }) +}