Skip to content

Commit 5f2a40a

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. The `DOCKER_AUTH_CONFIG` value should be a JSON object and must store the credentials in a base64 encoded string under the `auth` key. Specifying additional fields will cause the parser to fail. For example: `printf "username:pat" | openssl base64 -A` `export DOCKER_AUTH_CONFIG='{ "auths": { "https://index.docker.io/v1/": { "auth": "aGk6KTpkY2tyX3BhdF9oZWxsbw==" } } }'` 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 5f2a40a

4 files changed

Lines changed: 463 additions & 2 deletions

File tree

cli/config/configfile/file.go

Lines changed: 80 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,61 @@ 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+
// use DOCKER_AUTH_CONFIG if set
305+
// if a parse error occurs it falls back to the native or file store
306+
envStore, err := memorystore.New(
307+
withEnvConfig(envConfig),
308+
memorystore.WithFallbackStore(store),
309+
)
310+
if err != nil {
311+
_, _ = fmt.Fprintln(os.Stderr, "Failed to create credential store from DOCKER_AUTH_CONFIG: ", err)
312+
return store
313+
}
314+
315+
return envStore
316+
}
317+
318+
func withEnvConfig(v string) memorystore.Options {
319+
return func(c *memorystore.Config) error {
320+
envConfig := &configEnv{}
321+
decoder := json.NewDecoder(strings.NewReader(v))
322+
decoder.DisallowUnknownFields()
323+
if err := decoder.Decode(envConfig); err != nil && !errors.Is(err, io.EOF) {
324+
return err
325+
}
326+
if decoder.More() {
327+
return errors.New("DOCKER_AUTH_CONFIG does not support more than one JSON object")
328+
}
329+
330+
authConfigs := make(map[string]types.AuthConfig)
331+
for addr, envAuth := range envConfig.AuthConfigs {
332+
if envAuth.Auth == "" {
333+
return fmt.Errorf("DOCKER_AUTH_CONFIG environment variable is missing key `auth` for %s", addr)
334+
}
335+
username, password, err := decodeAuth(envAuth.Auth)
336+
if err != nil {
337+
return err
338+
}
339+
authConfigs[addr] = types.AuthConfig{
340+
Username: username,
341+
Password: password,
342+
ServerAddress: addr,
343+
}
344+
}
345+
346+
return memorystore.WithAuthConfig(authConfigs)(c)
268347
}
269-
return credentials.NewFileStore(configFile)
270348
}
271349

272350
// var for unit testing.

cli/config/configfile/file_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99

1010
"github.com/docker/cli/cli/config/credentials"
11+
"github.com/docker/cli/cli/config/memorystore"
1112
"github.com/docker/cli/cli/config/types"
1213
"gotest.tools/v3/assert"
1314
is "gotest.tools/v3/assert/cmp"
@@ -481,6 +482,132 @@ func TestLoadFromReaderWithUsernamePassword(t *testing.T) {
481482
}
482483
}
483484

485+
const envTestUserPassConfig = `{
486+
"auths": {
487+
"env.example.test": {
488+
"username": "env_user",
489+
"password": "env_pass",
490+
"serveraddress": "env.example.test"
491+
}
492+
}
493+
}`
494+
495+
const envTestAuthConfig = `{
496+
"auths": {
497+
"env.example.test": {
498+
"auth": "ZW52X3VzZXI6ZW52X3Bhc3M="
499+
}
500+
}
501+
}`
502+
503+
func TestGetAllCredentialsFromEnvironment(t *testing.T) {
504+
t.Run("case=can parse DOCKER_AUTH_CONFIG auth field", func(t *testing.T) {
505+
config := &ConfigFile{}
506+
507+
t.Setenv("DOCKER_AUTH_CONFIG", envTestAuthConfig)
508+
509+
authConfigs, err := config.GetAllCredentials()
510+
assert.NilError(t, err)
511+
512+
expected := map[string]types.AuthConfig{
513+
"env.example.test": {
514+
Username: "env_user",
515+
Password: "env_pass",
516+
ServerAddress: "env.example.test",
517+
},
518+
}
519+
assert.Check(t, is.DeepEqual(authConfigs, expected))
520+
})
521+
522+
t.Run("case=malformed DOCKER_AUTH_CONFIG should fallback to underlying store", func(t *testing.T) {
523+
fallbackStore := map[string]types.AuthConfig{
524+
"fallback.example.test": {
525+
Username: "fallback_user",
526+
Password: "fallback_pass",
527+
ServerAddress: "fallback.example.test",
528+
},
529+
}
530+
config := &ConfigFile{
531+
AuthConfigs: fallbackStore,
532+
}
533+
534+
t.Setenv("DOCKER_AUTH_CONFIG", envTestUserPassConfig)
535+
536+
authConfigs, err := config.GetAllCredentials()
537+
assert.NilError(t, err)
538+
539+
expected := fallbackStore
540+
assert.Check(t, is.DeepEqual(authConfigs, expected))
541+
})
542+
543+
t.Run("case=can fetch credentials from DOCKER_AUTH_CONFIG and underlying store", func(t *testing.T) {
544+
configFile := New("filename")
545+
exampleAuth := types.AuthConfig{
546+
Username: "user",
547+
Password: "pass",
548+
}
549+
configFile.AuthConfigs["foo.example.test"] = exampleAuth
550+
551+
t.Setenv("DOCKER_AUTH_CONFIG", envTestAuthConfig)
552+
553+
authConfigs, err := configFile.GetAllCredentials()
554+
assert.NilError(t, err)
555+
556+
expected := map[string]types.AuthConfig{
557+
"foo.example.test": exampleAuth,
558+
"env.example.test": {
559+
Username: "env_user",
560+
Password: "env_pass",
561+
ServerAddress: "env.example.test",
562+
},
563+
}
564+
assert.Check(t, is.DeepEqual(authConfigs, expected))
565+
566+
fooConfig, err := configFile.GetAuthConfig("foo.example.test")
567+
assert.NilError(t, err)
568+
expectedAuth := expected["foo.example.test"]
569+
assert.Check(t, is.DeepEqual(fooConfig, expectedAuth))
570+
571+
envConfig, err := configFile.GetAuthConfig("env.example.test")
572+
assert.NilError(t, err)
573+
expectedAuth = expected["env.example.test"]
574+
assert.Check(t, is.DeepEqual(envConfig, expectedAuth))
575+
})
576+
577+
t.Run("case=env is ignored when empty", func(t *testing.T) {
578+
configFile := New("filename")
579+
580+
t.Setenv("DOCKER_AUTH_CONFIG", "")
581+
582+
authConfigs, err := configFile.GetAllCredentials()
583+
assert.NilError(t, err)
584+
assert.Check(t, is.Len(authConfigs, 0))
585+
})
586+
}
587+
588+
func TestWithEnvConfig(t *testing.T) {
589+
t.Run("case=should error on unexpected fields", func(t *testing.T) {
590+
err := withEnvConfig(envTestUserPassConfig)(nil)
591+
assert.ErrorContains(t, err, "json: unknown field \"username\"")
592+
})
593+
t.Run("case=should be able to load env credentials", func(t *testing.T) {
594+
c := &memorystore.Config{}
595+
assert.NilError(t, withEnvConfig(envTestAuthConfig)(c))
596+
expected := types.AuthConfig{
597+
Username: "env_user",
598+
Password: "env_pass",
599+
ServerAddress: "env.example.test",
600+
}
601+
got, err := c.Get("env.example.test")
602+
assert.NilError(t, err)
603+
assert.Check(t, is.DeepEqual(got, expected))
604+
})
605+
t.Run("case=should not support multiple JSON objects", func(t *testing.T) {
606+
c := &memorystore.Config{}
607+
assert.ErrorContains(t, withEnvConfig(`{"auths":{"env.example.test":{"auth":"something"}}}{}`)(c), "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 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) 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)