Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion go/pkg/credhelper/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
load("@rules_go//go:def.bzl", "go_library")
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "go_default_library",
Expand All @@ -13,3 +13,9 @@ go_library(
"@com_github_sirupsen_logrus//:go_default_library",
],
)

go_test(
name = "credhelper_test",
srcs = ["docker_test.go"],
embed = [":go_default_library"],
)
26 changes: 24 additions & 2 deletions go/pkg/credhelper/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,19 @@ func RegistryHostsFromDockerConfig() docker.RegistryHosts {
return []docker.RegistryHost{registryHost}, nil
}

registryHost.Authorizer = docker.NewDockerAuthorizer(docker.WithAuthCreds(func(host string) (string, string, error) {
p := helperclient.NewShellProgramFunc(fmt.Sprintf("docker-credential-%s", helperName))
// Probe the helper once. If it returns the static-Bearer sentinel,
// install Authorization on the host directly and skip the docker
// challenge-response auth flow entirely. Without this, helpers that
// return a raw bearer token (e.g. an environment-provided identity
// token) get routed through Basic auth or an OAuth2 token exchange,
// which doesn't match what the registry expects.
p := helperclient.NewShellProgramFunc(fmt.Sprintf("docker-credential-%s", helperName))
if creds, err := helperclient.Get(p, fmt.Sprintf("%s://%s", registryHost.Scheme, registryHost.Host)); err == nil && creds.Username == staticBearerSentinel {
registryHost.Header = http.Header{"Authorization": []string{"Bearer " + creds.Secret}}
return []docker.RegistryHost{registryHost}, nil
}

registryHost.Authorizer = docker.NewDockerAuthorizer(docker.WithAuthCreds(func(host string) (string, string, error) {
creds, err := helperclient.Get(p, fmt.Sprintf("%s://%s", registryHost.Scheme, registryHost.Host))
if err != nil {
return "", "", err
Expand All @@ -145,3 +155,15 @@ func RegistryHostsFromDockerConfig() docker.RegistryHosts {
return []docker.RegistryHost{registryHost}, nil
}
}

// staticBearerSentinel is the value a docker-credential helper sets in its
// Username field to opt into static-Bearer mode: the helper's Secret is
// applied as a literal "Authorization: Bearer <Secret>" header on every
// request to the registry, and the challenge-response auth flow is skipped.
//
// This is the docker-credential-helpers analogue of go-containerregistry's
// "<token>" username convention, but tighter — go-containerregistry's
// "<token>" still goes through an OAuth2 refresh_token exchange against the
// challenge realm, which we don't want when the upstream accepts the token
// directly as a bearer.
const staticBearerSentinel = "<static_bearer>"
88 changes: 88 additions & 0 deletions go/pkg/credhelper/docker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package credhelper

import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
)

// writeHelperOnPath writes a fake docker-credential-<name> shell script onto
// PATH that emits the given username and secret as a docker-credential-helper
// Get response. Returns the helper name.
func writeHelperOnPath(t *testing.T, username, secret string) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("shell helper not portable to windows")
}

binDir := t.TempDir()
name := "credhelper-test"
script := "#!/bin/sh\ncat > /dev/null\n" +
`printf '{"ServerURL":"","Username":"` + username + `","Secret":"` + secret + `"}\n'` + "\n"
helperPath := filepath.Join(binDir, "docker-credential-"+name)
if err := os.WriteFile(helperPath, []byte(script), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}

orig := os.Getenv("PATH")
t.Setenv("PATH", binDir+string(os.PathListSeparator)+orig)
return name
}

// writeDockerConfig writes a docker config.json in a temp dir, points
// DOCKER_CONFIG at it, and maps the given host to the given credHelper name.
// Setting DOCKER_CONFIG explicitly sidesteps homedir.Dir caching between tests.
func writeDockerConfig(t *testing.T, host, helperName string) {
t.Helper()

dockerDir := t.TempDir()
t.Setenv("DOCKER_CONFIG", dockerDir)

cfg := map[string]any{
"credHelpers": map[string]string{
host: helperName,
},
}
body, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal config: %v", err)
}
if err := os.WriteFile(filepath.Join(dockerDir, "config.json"), body, 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
}

// TestRegistryHostsStaticBearerSentinel verifies that a docker-credential
// helper returning the "<bearer>" sentinel username produces a RegistryHost
// with a static "Authorization: Bearer <Secret>" header and no Authorizer —
// so the challenge-response auth flow is skipped entirely.
func TestRegistryHostsStaticBearerSentinel(t *testing.T) {
const host = "registry.example.com"
const token = "tok-abc-123"

helperName := writeHelperOnPath(t, staticBearerSentinel, token)
writeDockerConfig(t, host, helperName)

hosts, err := RegistryHostsFromDockerConfig()(host)
if err != nil {
t.Fatalf("RegistryHostsFromDockerConfig: %v", err)
}
if len(hosts) != 1 {
t.Fatalf("want 1 host, got %d", len(hosts))
}
h := hosts[0]

if got := h.Header.Get("Authorization"); got != "Bearer "+token {
t.Errorf("Header[Authorization] = %q, want %q", got, "Bearer "+token)
}
if h.Authorizer != nil {
t.Errorf("Authorizer must be nil in static-Bearer mode; got %T", h.Authorizer)
}
}

// Note: the non-sentinel (challenge-response) branch is not exercised here
// because seedAuthHeaders hardcodes https:// and does a network round-trip,
// which is awkward to unit test. That code path is unchanged by this patch
// and is covered by existing rules_oci integration usage.
Loading