Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions container.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,14 +383,14 @@ func getAuthConfigsFromDockerfile(c *ContainerRequest) (map[string]registry.Auth
}

// Get the auth configs once for all images as it can be a time-consuming operation.
configs, err := getDockerAuthConfigs()
cfg, configs, err := loadDockerAuth()
if err != nil {
return nil, err
}

authConfigs := map[string]registry.AuthConfig{}
for _, image := range images {
registry, authConfig, err := dockerImageAuth(context.Background(), image, configs)
registry, authConfig, err := dockerImageAuth(context.Background(), image, cfg, configs)
if err != nil {
if !errors.Is(err, dockercfg.ErrCredentialsNotFound) {
return nil, fmt.Errorf("docker image auth %q: %w", image, err)
Expand Down
95 changes: 82 additions & 13 deletions docker_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,48 @@ import (
// defaultRegistryFn is variable overwritten in tests to check for behaviour with different default values.
var defaultRegistryFn = defaultRegistry

// getRegistryCredentials is a variable overwritten in tests to mock the dockercfg.GetRegistryCredentials function.
var getRegistryCredentials = dockercfg.GetRegistryCredentials
// getRegistryCredentials is a variable overwritten in tests to mock the credentials lookup.
// It resolves against the config already loaded, which honours DOCKER_AUTH_CONFIG, rather than
// reloading the default config file.
var getRegistryCredentials = func(cfg *dockercfg.Config, hostname string) (string, string, error) {
return cfg.GetRegistryCredentials(hostname)
}

// DockerImageAuth returns the auth config for the given Docker image, extracting first its Docker registry.
// Finally, it will use the credential helpers to extract the information from the docker config file
// for that registry, if it exists.
func DockerImageAuth(ctx context.Context, image string) (string, registry.AuthConfig, error) {
configs, err := getDockerAuthConfigs()
cfg, configs, err := loadDockerAuth()
if err != nil {
reg := core.ExtractRegistry(image, defaultRegistryFn(ctx))
return reg, registry.AuthConfig{}, err
}

return dockerImageAuth(ctx, image, configs)
return dockerImageAuth(ctx, image, cfg, configs)
}

// loadDockerAuth loads the docker config once and the auth configs derived from it,
// so that both the map lookup and the credentials store lookup see the same config.
// A missing config file is not an error: it yields an empty config.
func loadDockerAuth() (*dockercfg.Config, map[string]registry.AuthConfig, error) {
cfg, err := getDockerConfig()
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, nil, err
}
cfg = &dockercfg.Config{}
}

configs, err := getDockerAuthConfigsFromConfig(cfg)
if err != nil {
return nil, nil, err
}

return cfg, configs, nil
}

// dockerImageAuth returns the auth config for the given Docker image.
func dockerImageAuth(ctx context.Context, image string, configs map[string]registry.AuthConfig) (string, registry.AuthConfig, error) {
func dockerImageAuth(ctx context.Context, image string, cfg *dockercfg.Config, configs map[string]registry.AuthConfig) (string, registry.AuthConfig, error) {
defaultRegistry := defaultRegistryFn(ctx)
reg := core.ExtractRegistry(image, defaultRegistry)

Expand All @@ -51,13 +75,52 @@ func dockerImageAuth(ctx context.Context, image string, configs map[string]regis
reg = defaultRegistry // This is https://index.docker.io/v1/
}

if cfg, ok := getRegistryAuth(reg, configs); ok {
return reg, cfg, nil
if ac, ok := getRegistryAuth(reg, configs); ok {
return reg, ac, nil
}

ac, ok, err := credentialsStoreAuth(cfg, reg)
if err != nil {
return reg, registry.AuthConfig{}, err
}
if ok {
return reg, ac, nil
}

return reg, registry.AuthConfig{}, dockercfg.ErrCredentialsNotFound
}

// credentialsStoreAuth returns the auth config the credentials store holds for reg,
// if one is configured and it has an entry for that registry.
//
// A credentials store serves every registry, so unlike auths and credHelpers it has
// no entries to enumerate up front: it can only be asked once the registry is known.
// See https://docs.docker.com/reference/cli/docker/login/#credential-stores
func credentialsStoreAuth(cfg *dockercfg.Config, reg string) (registry.AuthConfig, bool, error) {
// A store cannot be asked about an empty host, and a missing helper binary
// already reads as "no credentials" further down.
if cfg.CredentialsStore == "" || reg == "" {
return registry.AuthConfig{}, false, nil
}

key, err := configKey(cfg)
if err != nil {
return registry.AuthConfig{}, false, err
}

var ac registry.AuthConfig
if err := creds.AuthConfig(cfg, reg, key, &ac); err != nil {
return registry.AuthConfig{}, false, err
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// The store reports an unknown registry as empty credentials rather than an error.
if ac.Username == "" && ac.Password == "" && ac.IdentityToken == "" {
return registry.AuthConfig{}, false, nil
}

return ac, true, nil
}

func getRegistryAuth(reg string, cfgs map[string]registry.AuthConfig) (registry.AuthConfig, bool) {
if cfg, ok := cfgs[reg]; ok {
return cfg, true
Expand Down Expand Up @@ -125,8 +188,8 @@ var creds = &credentialsCache{entries: map[string]credentials{}}

// AuthConfig updates the details in authConfig for the given hostname
// as determined by the details in configKey.
func (c *credentialsCache) AuthConfig(hostname, configKey string, authConfig *registry.AuthConfig) error {
u, p, err := creds.get(hostname, configKey)
func (c *credentialsCache) AuthConfig(cfg *dockercfg.Config, hostname, configKey string, authConfig *registry.AuthConfig) error {
u, p, err := creds.get(cfg, hostname, configKey)
if err != nil {
return err
}
Expand All @@ -144,7 +207,7 @@ func (c *credentialsCache) AuthConfig(hostname, configKey string, authConfig *re
// get returns the username and password for the given hostname
// as determined by the details in configPath.
// If the username is empty, the password is an identity token.
func (c *credentialsCache) get(hostname, configKey string) (string, string, error) {
func (c *credentialsCache) get(cfg *dockercfg.Config, hostname, configKey string) (string, string, error) {
key := configKey + ":" + hostname
c.mtx.RLock()
entry, ok := c.entries[key]
Expand All @@ -155,7 +218,7 @@ func (c *credentialsCache) get(hostname, configKey string) (string, string, erro
}

// No entry found, request and cache.
user, password, err := getRegistryCredentials(hostname)
user, password, err := getRegistryCredentials(cfg, hostname)
if err != nil {
return "", "", fmt.Errorf("getting credentials for %s: %w", hostname, err)
}
Expand Down Expand Up @@ -190,6 +253,12 @@ func getDockerAuthConfigs() (map[string]registry.AuthConfig, error) {
return nil, err
}

return getDockerAuthConfigsFromConfig(cfg)
}

// getDockerAuthConfigsFromConfig returns a map with the auth configs from the given docker config
// using the registry as the key
func getDockerAuthConfigsFromConfig(cfg *dockercfg.Config) (map[string]registry.AuthConfig, error) {
key, err := configKey(cfg)
if err != nil {
return nil, err
Expand All @@ -216,7 +285,7 @@ func getDockerAuthConfigs() (map[string]registry.AuthConfig, error) {
switch {
case ac.Username == "" && ac.Password == "":
// Look up credentials from the credential store.
if err := creds.AuthConfig(k, key, &ac); err != nil {
if err := creds.AuthConfig(cfg, k, key, &ac); err != nil {
results <- authConfigResult{err: err}
return
}
Expand All @@ -236,7 +305,7 @@ func getDockerAuthConfigs() (map[string]registry.AuthConfig, error) {
defer wg.Done()

var ac registry.AuthConfig
if err := creds.AuthConfig(k, key, &ac); err != nil {
if err := creds.AuthConfig(cfg, k, key, &ac); err != nil {
results <- authConfigResult{err: err}
return
}
Expand Down
89 changes: 88 additions & 1 deletion docker_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
_ "embed"
"encoding/base64"
"errors"
"fmt"
"net"
"os"
Expand Down Expand Up @@ -136,6 +137,92 @@ func TestDockerImageAuth(t *testing.T) {
require.Equal(t, base64, cfg.Auth)
})

t.Run("retrieve auth from the credentials store", func(t *testing.T) {
// A config with only credsStore serves every registry through the store,
// so it has no auths or credHelpers entries to enumerate.
t.Setenv("DOCKER_AUTH_CONFIG", `{"credsStore":"desktop"}`)
creds.reset()

old := getRegistryCredentials
t.Cleanup(func() {
getRegistryCredentials = old
creds.reset()
})
getRegistryCredentials = func(_ *dockercfg.Config, hostname string) (string, string, error) {
if hostname == exampleAuth {
return "gopher", "secret", nil
}
return "", "", nil
}

reg, cfg, err := DockerImageAuth(context.Background(), exampleAuth+"/my/image:latest")
require.NoError(t, err)
require.Equal(t, exampleAuth, reg)
require.Equal(t, "gopher", cfg.Username)
require.Equal(t, "secret", cfg.Password)
})

t.Run("retrieve auth from the credentials store for a scheme-less registry", func(t *testing.T) {
// Registries in image references carry no scheme, and that is the host the
// store is asked for, as the docker CLI does.
t.Setenv("DOCKER_AUTH_CONFIG", `{"credsStore":"desktop"}`)
creds.reset()

old := getRegistryCredentials
t.Cleanup(func() {
getRegistryCredentials = old
creds.reset()
})
getRegistryCredentials = func(_ *dockercfg.Config, hostname string) (string, string, error) {
if hostname == "example-auth.com" {
return "gopher", "secret", nil
}
return "", "", nil
}

reg, cfg, err := DockerImageAuth(context.Background(), "example-auth.com/my/image:latest")
require.NoError(t, err)
require.Equal(t, "example-auth.com", reg)
require.Equal(t, "gopher", cfg.Username)
require.Equal(t, "secret", cfg.Password)
})

t.Run("credentials store errors are reported", func(t *testing.T) {
t.Setenv("DOCKER_AUTH_CONFIG", `{"credsStore":"desktop"}`)
creds.reset()

old := getRegistryCredentials
t.Cleanup(func() {
getRegistryCredentials = old
creds.reset()
})
getRegistryCredentials = func(*dockercfg.Config, string) (string, string, error) {
return "", "", errors.New("helper exploded")
}

_, _, err := DockerImageAuth(context.Background(), exampleAuth+"/my/image:latest")
require.ErrorContains(t, err, "helper exploded")
})

t.Run("credentials store without an entry for the registry", func(t *testing.T) {
t.Setenv("DOCKER_AUTH_CONFIG", `{"credsStore":"desktop"}`)
creds.reset()

old := getRegistryCredentials
t.Cleanup(func() {
getRegistryCredentials = old
creds.reset()
})
// A store reports an unknown registry as empty credentials, not an error.
getRegistryCredentials = func(*dockercfg.Config, string) (string, string, error) {
return "", "", nil
}

_, cfg, err := DockerImageAuth(context.Background(), exampleAuth+"/my/image:latest")
require.ErrorIs(t, err, dockercfg.ErrCredentialsNotFound)
require.Empty(t, cfg)
})

t.Run("fail to match registry authentication due to invalid host", func(t *testing.T) {
imageReg := "example-auth.com"
imagePath := "/my/image:latest"
Expand Down Expand Up @@ -423,7 +510,7 @@ func Test_getDockerAuthConfigs(t *testing.T) {
getRegistryCredentials = old
creds.reset() // Ensure our mocked results aren't cached.
})
getRegistryCredentials = func(hostname string) (string, string, error) {
getRegistryCredentials = func(_ *dockercfg.Config, hostname string) (string, string, error) {
switch hostname {
case core.IndexDockerIO:
return "", "identity-token", nil
Expand Down
Loading