From 5f4d07c363b667744305005df58827788dedd4aa Mon Sep 17 00:00:00 2001 From: Mortadha Teffaha Date: Mon, 13 Apr 2026 17:56:12 +0200 Subject: [PATCH 1/2] [auth] Fix Bearer credential helper sending Basic auth to token endpoint When a credHelper in config.json returns Username="Bearer"/Secret="", the previous code passed those to WithAuthCreds which constructed Authorization: Basic base64("Bearer:") for the token endpoint. The registry rejects this with 400 Bad Request. Detect Username=="Bearer" and inject the JWT as a static Authorization: Bearer header via a custom http.RoundTripper, bypassing the token-exchange flow entirely. Non-Bearer credentials continue through the existing path. Same root cause as DataDog/rules_oci_bootstrap#13 (fixed there for blob pulls); this fixes the equivalent bug in ocitool's OCI image pull path. --- go/pkg/credhelper/docker.go | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/go/pkg/credhelper/docker.go b/go/pkg/credhelper/docker.go index e4746d3..70b6b6a 100644 --- a/go/pkg/credhelper/docker.go +++ b/go/pkg/credhelper/docker.go @@ -99,6 +99,18 @@ func seedAuthHeaders(host docker.RegistryHost) error { return nil } +// staticBearerTransport injects a pre-issued Bearer token into every request, +// bypassing the standard Docker token-exchange flow. +type staticBearerTransport struct { + token string +} + +func (t *staticBearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.Header.Set("Authorization", "Bearer "+t.token) + return http.DefaultTransport.RoundTrip(clone) +} + func RegistryHostsFromDockerConfig() docker.RegistryHosts { return func(host string) ([]docker.RegistryHost, error) { // FIXME This should be cached somewhere @@ -126,14 +138,25 @@ 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)) + p := helperclient.NewShellProgramFunc(fmt.Sprintf("docker-credential-%s", helperName)) + creds, err := helperclient.Get(p, fmt.Sprintf("%s://%s", registryHost.Scheme, registryHost.Host)) + if err != nil { + return nil, err + } - creds, err := helperclient.Get(p, fmt.Sprintf("%s://%s", registryHost.Scheme, registryHost.Host)) - if err != nil { - return "", "", err + if creds.Username == "Bearer" { + // The credential helper returned a pre-issued Bearer token (Username=="Bearer", + // Secret==). Sending this as Basic auth to the token endpoint produces a + // 400 Bad Request, so we inject it directly as a static Authorization header + // via a custom transport, bypassing the token-exchange flow entirely. + registryHost.Client = &http.Client{ + Transport: &staticBearerTransport{token: creds.Secret}, } + // Authorizer is nil: seedAuthHeaders is a no-op and no token exchange occurs. + return []docker.RegistryHost{registryHost}, nil + } + registryHost.Authorizer = docker.NewDockerAuthorizer(docker.WithAuthCreds(func(host string) (string, string, error) { return creds.Username, creds.Secret, nil })) From 1be5d754b3ad4a7e0a1f41378234abb1aa1fb3e6 Mon Sep 17 00:00:00 2001 From: Mortadha Teffaha Date: Mon, 13 Apr 2026 18:40:16 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20intercept=20Basic(Bearer,JWT)=20?= =?UTF-8?q?=E2=86=92=20Bearer=20JWT=20on=20token=20endpoint=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staticBearerTransport approach bypassed the token-exchange flow entirely, injecting the Vault JWT directly into all registry requests. This broke blob downloads: the registry accepts the JWT for token exchange but expects a scope-specific token for blob pulls. Replace with bearerAuthFixTransport that intercepts only the token endpoint request (where containerd sets Basic("Bearer", JWT)) and converts it to Bearer JWT on the wire. The token endpoint then issues a proper scope-specific token, which containerd uses for all subsequent manifest and blob requests. --- go/pkg/credhelper/docker.go | 44 +++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/go/pkg/credhelper/docker.go b/go/pkg/credhelper/docker.go index 70b6b6a..a3022bf 100644 --- a/go/pkg/credhelper/docker.go +++ b/go/pkg/credhelper/docker.go @@ -99,15 +99,20 @@ func seedAuthHeaders(host docker.RegistryHost) error { return nil } -// staticBearerTransport injects a pre-issued Bearer token into every request, -// bypassing the standard Docker token-exchange flow. -type staticBearerTransport struct { +// bearerAuthFixTransport intercepts requests where containerd has set +// Authorization: Basic base64("Bearer:") (from a credential helper returning +// Username="Bearer") and converts them to Authorization: Bearer . +// This allows the token endpoint to issue a proper scope-specific token, which +// containerd then uses for all subsequent registry requests (manifests and blobs). +type bearerAuthFixTransport struct { token string } -func (t *staticBearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { +func (t *bearerAuthFixTransport) RoundTrip(req *http.Request) (*http.Response, error) { clone := req.Clone(req.Context()) - clone.Header.Set("Authorization", "Bearer "+t.token) + if username, _, ok := clone.BasicAuth(); ok && username == "Bearer" { + clone.Header.Set("Authorization", "Bearer "+t.token) + } return http.DefaultTransport.RoundTrip(clone) } @@ -146,13 +151,30 @@ func RegistryHostsFromDockerConfig() docker.RegistryHosts { if creds.Username == "Bearer" { // The credential helper returned a pre-issued Bearer token (Username=="Bearer", - // Secret==). Sending this as Basic auth to the token endpoint produces a - // 400 Bad Request, so we inject it directly as a static Authorization header - // via a custom transport, bypassing the token-exchange flow entirely. - registryHost.Client = &http.Client{ - Transport: &staticBearerTransport{token: creds.Secret}, + // Secret==). containerd's WithAuthCreds flow would construct + // Authorization: Basic base64("Bearer:") for the token endpoint, which + // the registry rejects with 400 Bad Request. + // + // Fix: use a custom transport that converts Basic("Bearer", ) → + // Bearer on the wire. This lets the token endpoint issue a proper + // scope-specific token, which containerd then uses for all registry requests + // (manifests and blobs). + customClient := &http.Client{ + Transport: &bearerAuthFixTransport{token: creds.Secret}, + } + registryHost.Client = customClient + registryHost.Authorizer = docker.NewDockerAuthorizer( + docker.WithAuthCreds(func(host string) (string, string, error) { + return creds.Username, creds.Secret, nil + }), + docker.WithAuthClient(customClient), + ) + + err = seedAuthHeaders(registryHost) + if err != nil { + return nil, err } - // Authorizer is nil: seedAuthHeaders is a no-op and no token exchange occurs. + return []docker.RegistryHost{registryHost}, nil }