Skip to content

Commit 51025e1

Browse files
authored
Merge pull request #6008 from Benehiko/env-credentials-store
Use `DOCKER_AUTH_CONFIG` env as credential store
2 parents ab2d683 + 9b83d5b commit 51025e1

File tree

4 files changed

+467
-2
lines changed

4 files changed

+467
-2
lines changed

cli/config/configfile/file.go

Lines changed: 83 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,31 @@ 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+
// dockerEnvConfig is an environment variable that contains a JSON encoded
60+
// credential config. It only supports storing the credentials as a base64
61+
// encoded string in the format base64("username:pat").
62+
//
63+
// Adding additional fields will produce a parsing error.
64+
//
65+
// Example:
66+
//
67+
// {
68+
// "auths": {
69+
// "example.test": {
70+
// "auth": base64-encoded-username-pat
71+
// }
72+
// }
73+
// }
74+
const dockerEnvConfig = "DOCKER_AUTH_CONFIG"
75+
4976
// ProxyConfig contains proxy configuration settings
5077
type ProxyConfig struct {
5178
HTTPProxy string `json:"httpProxy,omitempty"`
@@ -263,10 +290,64 @@ func decodeAuth(authStr string) (string, string, error) {
263290
// GetCredentialsStore returns a new credentials store from the settings in the
264291
// configuration file
265292
func (configFile *ConfigFile) GetCredentialsStore(registryHostname string) credentials.Store {
293+
store := credentials.NewFileStore(configFile)
294+
266295
if helper := getConfiguredCredentialStore(configFile, registryHostname); helper != "" {
267-
return newNativeStore(configFile, helper)
296+
store = newNativeStore(configFile, helper)
297+
}
298+
299+
envConfig := os.Getenv(dockerEnvConfig)
300+
if envConfig == "" {
301+
return store
302+
}
303+
304+
authConfig, err := parseEnvConfig(envConfig)
305+
if err != nil {
306+
_, _ = fmt.Fprintln(os.Stderr, "Failed to create credential store from DOCKER_AUTH_CONFIG: ", err)
307+
return store
308+
}
309+
310+
// use DOCKER_AUTH_CONFIG if set
311+
// it uses the native or file store as a fallback to fetch and store credentials
312+
envStore, err := memorystore.New(
313+
memorystore.WithAuthConfig(authConfig),
314+
memorystore.WithFallbackStore(store),
315+
)
316+
if err != nil {
317+
_, _ = fmt.Fprintln(os.Stderr, "Failed to create credential store from DOCKER_AUTH_CONFIG: ", err)
318+
return store
319+
}
320+
321+
return envStore
322+
}
323+
324+
func parseEnvConfig(v string) (map[string]types.AuthConfig, error) {
325+
envConfig := &configEnv{}
326+
decoder := json.NewDecoder(strings.NewReader(v))
327+
decoder.DisallowUnknownFields()
328+
if err := decoder.Decode(envConfig); err != nil && !errors.Is(err, io.EOF) {
329+
return nil, err
330+
}
331+
if decoder.More() {
332+
return nil, errors.New("DOCKER_AUTH_CONFIG does not support more than one JSON object")
333+
}
334+
335+
authConfigs := make(map[string]types.AuthConfig)
336+
for addr, envAuth := range envConfig.AuthConfigs {
337+
if envAuth.Auth == "" {
338+
return nil, fmt.Errorf("DOCKER_AUTH_CONFIG environment variable is missing key `auth` for %s", addr)
339+
}
340+
username, password, err := decodeAuth(envAuth.Auth)
341+
if err != nil {
342+
return nil, err
343+
}
344+
authConfigs[addr] = types.AuthConfig{
345+
Username: username,
346+
Password: password,
347+
ServerAddress: addr,
348+
}
268349
}
269-
return credentials.NewFileStore(configFile)
350+
return authConfigs, nil
270351
}
271352

272353
// var for unit testing.

cli/config/configfile/file_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,133 @@ 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("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("malformed DOCKER_AUTH_CONFIG should fallback to underlying store", func(t *testing.T) {
522+
fallbackStore := map[string]types.AuthConfig{
523+
"fallback.example.test": {
524+
Username: "fallback_user",
525+
Password: "fallback_pass",
526+
ServerAddress: "fallback.example.test",
527+
},
528+
}
529+
config := &ConfigFile{
530+
AuthConfigs: fallbackStore,
531+
}
532+
533+
t.Setenv("DOCKER_AUTH_CONFIG", envTestUserPassConfig)
534+
535+
authConfigs, err := config.GetAllCredentials()
536+
assert.NilError(t, err)
537+
538+
expected := fallbackStore
539+
assert.Check(t, is.DeepEqual(authConfigs, expected))
540+
})
541+
542+
t.Run("can fetch credentials from DOCKER_AUTH_CONFIG and underlying store", func(t *testing.T) {
543+
configFile := New("filename")
544+
exampleAuth := types.AuthConfig{
545+
Username: "user",
546+
Password: "pass",
547+
}
548+
configFile.AuthConfigs["foo.example.test"] = exampleAuth
549+
550+
t.Setenv("DOCKER_AUTH_CONFIG", envTestAuthConfig)
551+
552+
authConfigs, err := configFile.GetAllCredentials()
553+
assert.NilError(t, err)
554+
555+
expected := map[string]types.AuthConfig{
556+
"foo.example.test": exampleAuth,
557+
"env.example.test": {
558+
Username: "env_user",
559+
Password: "env_pass",
560+
ServerAddress: "env.example.test",
561+
},
562+
}
563+
assert.Check(t, is.DeepEqual(authConfigs, expected))
564+
565+
fooConfig, err := configFile.GetAuthConfig("foo.example.test")
566+
assert.NilError(t, err)
567+
expectedAuth := expected["foo.example.test"]
568+
assert.Check(t, is.DeepEqual(fooConfig, expectedAuth))
569+
570+
envConfig, err := configFile.GetAuthConfig("env.example.test")
571+
assert.NilError(t, err)
572+
expectedAuth = expected["env.example.test"]
573+
assert.Check(t, is.DeepEqual(envConfig, expectedAuth))
574+
})
575+
576+
t.Run("env is ignored when empty", func(t *testing.T) {
577+
configFile := New("filename")
578+
579+
t.Setenv("DOCKER_AUTH_CONFIG", "")
580+
581+
authConfigs, err := configFile.GetAllCredentials()
582+
assert.NilError(t, err)
583+
assert.Check(t, is.Len(authConfigs, 0))
584+
})
585+
}
586+
587+
func TestParseEnvConfig(t *testing.T) {
588+
t.Run("should error on unexpected fields", func(t *testing.T) {
589+
_, err := parseEnvConfig(envTestUserPassConfig)
590+
assert.ErrorContains(t, err, "json: unknown field \"username\"")
591+
})
592+
t.Run("should be able to load env credentials", func(t *testing.T) {
593+
got, err := parseEnvConfig(envTestAuthConfig)
594+
assert.NilError(t, err)
595+
expected := map[string]types.AuthConfig{
596+
"env.example.test": {
597+
Username: "env_user",
598+
Password: "env_pass",
599+
ServerAddress: "env.example.test",
600+
},
601+
}
602+
assert.NilError(t, err)
603+
assert.Check(t, is.DeepEqual(got, expected))
604+
})
605+
t.Run("should not support multiple JSON objects", func(t *testing.T) {
606+
_, err := parseEnvConfig(`{"auths":{"env.example.test":{"auth":"something"}}}{}`)
607+
assert.ErrorContains(t, err, "does not support more than one JSON object")
608+
})
609+
}
610+
484611
func TestSave(t *testing.T) {
485612
configFile := New("test-save")
486613
defer os.Remove("test-save")

cli/config/memorystore/store.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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 Config struct {
23+
lock sync.RWMutex
24+
memoryCredentials map[string]types.AuthConfig
25+
fallbackStore credentials.Store
26+
}
27+
28+
func (e *Config) 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 *Config) 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 *Config) 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 *Config) 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 operations 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) Options {
99+
return func(s *Config) error {
100+
s.fallbackStore = store
101+
return nil
102+
}
103+
}
104+
105+
// WithAuthConfig allows to set the initial credentials in the memory store.
106+
func WithAuthConfig(config map[string]types.AuthConfig) Options {
107+
return func(s *Config) error {
108+
s.memoryCredentials = config
109+
return nil
110+
}
111+
}
112+
113+
type Options func(*Config) error
114+
115+
// New creates a new in memory credential store
116+
func New(opts ...Options) (credentials.Store, error) {
117+
m := &Config{
118+
memoryCredentials: make(map[string]types.AuthConfig),
119+
}
120+
for _, opt := range opts {
121+
if err := opt(m); err != nil {
122+
return nil, err
123+
}
124+
}
125+
return m, nil
126+
}

0 commit comments

Comments
 (0)