mirror of
https://github.com/docker/cli.git
synced 2026-01-13 18:22:35 +03:00
Merge pull request #6008 from Benehiko/env-credentials-store
Use `DOCKER_AUTH_CONFIG` env as credential store
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
126
cli/config/memorystore/store.go
Normal file
126
cli/config/memorystore/store.go
Normal file
@@ -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
|
||||
}
|
||||
131
cli/config/memorystore/store_test.go
Normal file
131
cli/config/memorystore/store_test.go
Normal file
@@ -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))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user