From b747061b921cb464e4f11bae7498b805a4e225e6 Mon Sep 17 00:00:00 2001 From: Chris Miller Date: Mon, 11 May 2026 19:37:12 -0400 Subject: [PATCH] Add static-Bearer mode to credhelper.RegistryHostsFromDockerConfig When a docker-credential helper returns Username "", install the Secret as a literal "Authorization: Bearer " header on the RegistryHost and skip the challenge-response auth flow. Used by callers whose registry accepts a bearer token directly rather than via an OAuth2 realm exchange (e.g. the Datadog Terrapin Forwarder, which authenticates the calling sandbox via a pre-minted identity JWT). Adds a unit test covering the new sentinel path. Co-Authored-By: Claude Opus 4.7 (1M context) --- go/pkg/credhelper/BUILD.bazel | 8 ++- go/pkg/credhelper/docker.go | 26 +++++++++- go/pkg/credhelper/docker_test.go | 88 ++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 go/pkg/credhelper/docker_test.go diff --git a/go/pkg/credhelper/BUILD.bazel b/go/pkg/credhelper/BUILD.bazel index dc4de28..8ed7ac7 100644 --- a/go/pkg/credhelper/BUILD.bazel +++ b/go/pkg/credhelper/BUILD.bazel @@ -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", @@ -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"], +) diff --git a/go/pkg/credhelper/docker.go b/go/pkg/credhelper/docker.go index e4746d3..9228ed6 100644 --- a/go/pkg/credhelper/docker.go +++ b/go/pkg/credhelper/docker.go @@ -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 @@ -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 " 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 +// "" username convention, but tighter — go-containerregistry's +// "" 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 = "" diff --git a/go/pkg/credhelper/docker_test.go b/go/pkg/credhelper/docker_test.go new file mode 100644 index 0000000..4ffa85e --- /dev/null +++ b/go/pkg/credhelper/docker_test.go @@ -0,0 +1,88 @@ +package credhelper + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" +) + +// writeHelperOnPath writes a fake docker-credential- 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 "" sentinel username produces a RegistryHost +// with a static "Authorization: Bearer " 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.