Skip to content

Commit 5e95a08

Browse files
committed
Use DOCKER_AUTH_CONFIG env as credential store
This patch enables the CLI to natively pick up the `DOCKER_AUTH_CONFIG` environment variable and use it as a credential store. Credentials stored in `DOCKER_AUTH_CONFIG` would take precedence over any credential stored in the file store (`~/.docker/config.json`) or native store (credential helper). Destructive actions, such as deleting a credential would result in a noop if found in the environment credential. Credentials found in the file or native store would get removed. Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
1 parent 9e50654 commit 5e95a08

4 files changed

Lines changed: 406 additions & 2 deletions

File tree

cli/config/configfile/file.go

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package configfile
33
import (
44
"encoding/base64"
55
"encoding/json"
6+
"fmt"
67
"io"
78
"os"
89
"path/filepath"
910
"strings"
1011

1112
"github.com/docker/cli/cli/config/credentials"
13+
"github.com/docker/cli/cli/config/memorystore"
1214
"github.com/docker/cli/cli/config/types"
1315
"github.com/pkg/errors"
1416
"github.com/sirupsen/logrus"
@@ -46,6 +48,47 @@ type ConfigFile struct {
4648
Experimental string `json:"experimental,omitempty"`
4749
}
4850

51+
type ConfigEnvAuth struct {
52+
Auth string `json:"auth"`
53+
}
54+
55+
type ConfigEnv struct {
56+
AuthConfigs map[string]ConfigEnvAuth `json:"auths"`
57+
}
58+
59+
func (c *ConfigEnv) LoadFromEnv() error {
60+
v := os.Getenv("DOCKER_AUTH_CONFIG")
61+
if v == "" {
62+
return fmt.Errorf("DOCKER_AUTH_CONFIG environment variable is not set")
63+
}
64+
if c.AuthConfigs == nil {
65+
c.AuthConfigs = make(map[string]ConfigEnvAuth)
66+
}
67+
if err := json.NewDecoder(strings.NewReader(v)).Decode(c); err != nil && !errors.Is(err, io.EOF) {
68+
return err
69+
}
70+
return nil
71+
}
72+
73+
func (c *ConfigEnv) GetAuthConfigs() (map[string]types.AuthConfig, error) {
74+
authConfigs := make(map[string]types.AuthConfig)
75+
for addr, envAuth := range c.AuthConfigs {
76+
if envAuth.Auth == "" {
77+
return authConfigs, fmt.Errorf("DOCKER_AUTH_CONFIG environment variable is missing auth for %s", addr)
78+
}
79+
username, password, err := decodeAuth(envAuth.Auth)
80+
if err != nil {
81+
return nil, err
82+
}
83+
authConfigs[addr] = types.AuthConfig{
84+
Username: username,
85+
Password: password,
86+
ServerAddress: addr,
87+
}
88+
}
89+
return authConfigs, nil
90+
}
91+
4992
// ProxyConfig contains proxy configuration settings
5093
type ProxyConfig struct {
5194
HTTPProxy string `json:"httpProxy,omitempty"`
@@ -263,10 +306,30 @@ func decodeAuth(authStr string) (string, string, error) {
263306
// GetCredentialsStore returns a new credentials store from the settings in the
264307
// configuration file
265308
func (configFile *ConfigFile) GetCredentialsStore(registryHostname string) credentials.Store {
309+
store := credentials.NewFileStore(configFile)
310+
266311
if helper := getConfiguredCredentialStore(configFile, registryHostname); helper != "" {
267-
return newNativeStore(configFile, helper)
312+
store = newNativeStore(configFile, helper)
268313
}
269-
return credentials.NewFileStore(configFile)
314+
315+
// if DOCKER_AUTH_CONFIG is set, we need to use the env store instead
316+
// it falls back to native or file store if a value is not found
317+
// in the environment
318+
envConfig := &ConfigEnv{}
319+
if err := envConfig.LoadFromEnv(); err != nil {
320+
return store
321+
}
322+
323+
authConfigs, err := envConfig.GetAuthConfigs()
324+
if err != nil {
325+
_, _ = fmt.Fprintln(os.Stderr, "Failed to load credentials from DOCKER_AUTH_CONFIG environment variable: ", err)
326+
return store
327+
}
328+
329+
return memorystore.New(
330+
memorystore.WithAuthConfig(authConfigs),
331+
memorystore.WithFallbackStore(store),
332+
)
270333
}
271334

272335
// var for unit testing.

cli/config/configfile/file_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,100 @@ func TestLoadFromReaderWithUsernamePassword(t *testing.T) {
481481
}
482482
}
483483

484+
const envTestUserPassConfig = `{
485+
"auths": {
486+
"env.example.test": {
487+
"username": "env_user",
488+
"password": "env_pass",
489+
"serveraddress": "env.example.test"
490+
}
491+
}
492+
}`
493+
494+
const envTestAuthConfig = `{
495+
"auths": {
496+
"env.example.test": {
497+
"auth": "ZW52X3VzZXI6ZW52X3Bhc3M="
498+
}
499+
}
500+
}`
501+
502+
func TestGetAllCredentialsFromEnvironment(t *testing.T) {
503+
t.Run("case=can parse DOCKER_AUTH_CONFIG auth field", func(t *testing.T) {
504+
config := &ConfigFile{}
505+
506+
t.Setenv("DOCKER_AUTH_CONFIG", envTestAuthConfig)
507+
508+
authConfigs, err := config.GetAllCredentials()
509+
assert.NilError(t, err)
510+
511+
expected := map[string]types.AuthConfig{
512+
"env.example.test": {
513+
Username: "env_user",
514+
Password: "env_pass",
515+
ServerAddress: "env.example.test",
516+
},
517+
}
518+
assert.Check(t, is.DeepEqual(authConfigs, expected))
519+
})
520+
521+
t.Run("case=DOCKER_AUTH_CONFIG should ignore username and password fields", func(t *testing.T) {
522+
config := &ConfigFile{}
523+
524+
t.Setenv("DOCKER_AUTH_CONFIG", envTestUserPassConfig)
525+
526+
authConfigs, err := config.GetAllCredentials()
527+
assert.NilError(t, err)
528+
529+
expected := map[string]types.AuthConfig{}
530+
assert.Check(t, is.DeepEqual(authConfigs, expected))
531+
})
532+
533+
t.Run("case=can fetch credentials from DOCKER_AUTH_CONFIG and underlying store", func(t *testing.T) {
534+
configFile := New("filename")
535+
exampleAuth := types.AuthConfig{
536+
Username: "user",
537+
Password: "pass",
538+
}
539+
configFile.AuthConfigs["foo.example.test"] = exampleAuth
540+
541+
t.Setenv("DOCKER_AUTH_CONFIG", envTestAuthConfig)
542+
543+
authConfigs, err := configFile.GetAllCredentials()
544+
assert.NilError(t, err)
545+
546+
expected := map[string]types.AuthConfig{
547+
"foo.example.test": exampleAuth,
548+
"env.example.test": {
549+
Username: "env_user",
550+
Password: "env_pass",
551+
ServerAddress: "env.example.test",
552+
},
553+
}
554+
assert.Check(t, is.DeepEqual(authConfigs, expected))
555+
556+
fooConfig, err := configFile.GetAuthConfig("foo.example.test")
557+
assert.NilError(t, err)
558+
expectedAuth := expected["foo.example.test"]
559+
assert.Check(t, is.DeepEqual(fooConfig, expectedAuth))
560+
561+
envConfig, err := configFile.GetAuthConfig("env.example.test")
562+
assert.NilError(t, err)
563+
expectedAuth = expected["env.example.test"]
564+
assert.Check(t, is.DeepEqual(envConfig, expectedAuth))
565+
})
566+
567+
t.Run("case=env is ignored when empty", func(t *testing.T) {
568+
configFile := New("filename")
569+
570+
t.Setenv("DOCKER_AUTH_CONFIG", "")
571+
572+
authConfigs, err := configFile.GetAllCredentials()
573+
assert.NilError(t, err)
574+
assert.Check(t, is.Len(authConfigs, 0))
575+
})
576+
}
577+
484578
func TestSave(t *testing.T) {
485579
configFile := New("test-save")
486580
defer os.Remove("test-save")

cli/config/memorystore/store.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//go:build go1.23
2+
3+
package memorystore
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"maps"
9+
"os"
10+
"sync"
11+
12+
"github.com/docker/cli/cli/config/credentials"
13+
"github.com/docker/cli/cli/config/types"
14+
)
15+
16+
var errValueNotFound = errors.New("value not found")
17+
18+
func IsErrValueNotFound(err error) bool {
19+
return errors.Is(err, errValueNotFound)
20+
}
21+
22+
type memoryStore struct {
23+
lock sync.RWMutex
24+
memoryCredentials map[string]types.AuthConfig
25+
fallbackStore credentials.Store
26+
}
27+
28+
func (e *memoryStore) Erase(serverAddress string) error {
29+
e.lock.Lock()
30+
defer e.lock.Unlock()
31+
delete(e.memoryCredentials, serverAddress)
32+
33+
if e.fallbackStore != nil {
34+
err := e.fallbackStore.Erase(serverAddress)
35+
if err != nil {
36+
_, _ = fmt.Fprintln(os.Stderr, "memorystore: ", err)
37+
}
38+
}
39+
40+
return nil
41+
}
42+
43+
func (e *memoryStore) Get(serverAddress string) (types.AuthConfig, error) {
44+
e.lock.RLock()
45+
defer e.lock.RUnlock()
46+
authConfig, ok := e.memoryCredentials[serverAddress]
47+
if !ok {
48+
if e.fallbackStore != nil {
49+
return e.fallbackStore.Get(serverAddress)
50+
}
51+
return types.AuthConfig{}, errValueNotFound
52+
}
53+
return authConfig, nil
54+
}
55+
56+
func (e *memoryStore) GetAll() (map[string]types.AuthConfig, error) {
57+
e.lock.RLock()
58+
defer e.lock.RUnlock()
59+
creds := make(map[string]types.AuthConfig)
60+
61+
if e.fallbackStore != nil {
62+
fileCredentials, err := e.fallbackStore.GetAll()
63+
if err != nil {
64+
_, _ = fmt.Fprintln(os.Stderr, "memorystore: ", err)
65+
} else {
66+
creds = fileCredentials
67+
}
68+
}
69+
70+
maps.Copy(creds, e.memoryCredentials)
71+
return creds, nil
72+
}
73+
74+
func (e *memoryStore) Store(authConfig types.AuthConfig) error {
75+
e.lock.Lock()
76+
defer e.lock.Unlock()
77+
e.memoryCredentials[authConfig.ServerAddress] = authConfig
78+
79+
if e.fallbackStore != nil {
80+
return e.fallbackStore.Store(authConfig)
81+
}
82+
return nil
83+
}
84+
85+
// WithFallbackStore sets a fallback store.
86+
//
87+
// Write opterations will be performed on both the memory store and the
88+
// fallback store.
89+
//
90+
// Read operations will first check the memory store, and if the credential
91+
// is not found, it will then check the fallback store.
92+
//
93+
// Retrieving all credentials will return from both the memory store and the
94+
// fallback store, merging the results from both stores into a single map.
95+
//
96+
// Data stored in the memory store will take precedence over data in the
97+
// fallback store.
98+
func WithFallbackStore(store credentials.Store) func(*memoryStore) {
99+
return func(s *memoryStore) {
100+
s.fallbackStore = store
101+
}
102+
}
103+
104+
// WithAuthConfig allows to set the initial credentials in the memory store.
105+
func WithAuthConfig(config map[string]types.AuthConfig) func(*memoryStore) {
106+
return func(s *memoryStore) {
107+
s.memoryCredentials = config
108+
}
109+
}
110+
111+
// New creates a new in memory credential store
112+
func New(opts ...func(*memoryStore)) credentials.Store {
113+
m := &memoryStore{
114+
memoryCredentials: make(map[string]types.AuthConfig),
115+
}
116+
for _, opt := range opts {
117+
opt(m)
118+
}
119+
return m
120+
}

0 commit comments

Comments
 (0)