1
0
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:
Paweł Gronowski
2025-06-18 18:07:07 +00:00
committed by GitHub
4 changed files with 467 additions and 2 deletions

View File

@@ -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.

View File

@@ -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")

View 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
}

View 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))
})
}