Merge pull request #3627 from tonistiigi/imagetools-auth

imagetools: use dockerconfig for auth
This commit is contained in:
Tõnis Tiigi
2026-02-03 13:14:33 -05:00
committed by GitHub
7 changed files with 384 additions and 76 deletions
-5
View File
@@ -8,7 +8,6 @@ import (
"github.com/docker/buildx/store"
"github.com/docker/buildx/util/progress"
clitypes "github.com/docker/cli/cli/config/types"
controlapi "github.com/moby/buildkit/api/services/control"
"github.com/moby/buildkit/client"
"github.com/pkg/errors"
@@ -58,10 +57,6 @@ type Info struct {
DynamicNodes []store.Node
}
type Auth interface {
GetAuthConfig(registryHostname string) (clitypes.AuthConfig, error)
}
type Driver interface {
Factory() Factory
Bootstrap(context.Context, progress.Logger) error
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/docker/cli/cli/context/store"
"github.com/moby/buildkit/client"
"github.com/moby/buildkit/session/auth/authprovider"
"github.com/moby/buildkit/util/tracing/delegated"
dockerclient "github.com/moby/moby/client"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
@@ -35,7 +36,7 @@ type InitConfig struct {
BuildkitdFlags []string
Files map[string][]byte
DriverOpts map[string]string
Auth Auth
Auth authprovider.AuthConfigProvider
Platforms []ocispecs.Platform
ContextPathHash string
DialMeta map[string][]string
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"github.com/docker/buildx/store"
"github.com/docker/buildx/util/confutil"
"github.com/docker/buildx/util/dockerutil"
"github.com/docker/buildx/util/dockerutil/dockerconfig"
"github.com/docker/buildx/util/imagetools"
"github.com/docker/buildx/util/resolver"
"github.com/docker/cli/cli/command"
@@ -109,7 +110,7 @@ func GetNodeGroup(txn *store.Txn, dockerCli command.Cli, name string) (*store.No
}
func GetImageConfig(dockerCli command.Cli, ng *store.NodeGroup) (opt imagetools.Opt, err error) {
opt.Auth = dockerCli.ConfigFile()
opt.Auth = dockerconfig.LoadAuthConfig(dockerCli)
if ng == nil || len(ng.Nodes) == 0 {
return opt, nil
@@ -43,6 +43,13 @@ type authConfigProvider struct {
}
func (ap *authConfigProvider) load(ctx context.Context, host string, scopes []string, cacheExpireCheck authprovider.ExpireCachedAuthCheck) (types.AuthConfig, error) {
if cacheExpireCheck == nil {
cacheExpireCheck = func(created time.Time, _ string) bool {
// Tokens for Google Artifact Registry via Workload Identity expire after 5 minutes.
return time.Since(created) > 4*time.Minute+50*time.Second
}
}
ac, err := ap.loadHost(ctx, host, scopes, cacheExpireCheck)
if err != nil {
return types.AuthConfig{}, err
+6 -60
View File
@@ -5,68 +5,14 @@ import (
"encoding/base64"
"encoding/json"
"net/http"
"sync"
"time"
"github.com/containerd/containerd/v2/core/remotes/docker"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config/types"
"github.com/moby/buildkit/session/auth/authprovider"
)
type authConfig struct {
mu sync.Mutex
authConfigCache map[string]authConfigCacheEntry
cfg Auth
}
type authConfigCacheEntry struct {
Created time.Time
Auth types.AuthConfig
}
func newAuthConfig(a Auth) *authConfig {
return &authConfig{
authConfigCache: map[string]authConfigCacheEntry{},
cfg: a,
}
}
func (a *authConfig) credentials(host string) (string, string, error) {
ac, err := a.authConfig(host)
if err != nil {
return "", "", err
}
if ac.IdentityToken != "" {
return "", ac.IdentityToken, nil
}
return ac.Username, ac.Password, nil
}
func (a *authConfig) authConfig(host string) (types.AuthConfig, error) {
const defaultExpiration = 2 * time.Minute
if host == "registry-1.docker.io" {
host = "https://index.docker.io/v1/"
}
a.mu.Lock()
defer a.mu.Unlock()
if c, ok := a.authConfigCache[host]; ok && time.Since(c.Created) <= defaultExpiration {
return c.Auth, nil
}
ac, err := a.cfg.GetAuthConfig(host)
if err != nil {
return types.AuthConfig{}, err
}
a.authConfigCache[host] = authConfigCacheEntry{
Created: time.Now(),
Auth: ac,
}
return ac, nil
}
func RegistryAuthForRef(ref string, a Auth) (string, error) {
if a == nil {
func RegistryAuthForRef(ref string, auth authprovider.AuthConfigProvider) (string, error) {
if auth == nil {
return "", nil
}
r, err := parseRef(ref)
@@ -77,7 +23,7 @@ func RegistryAuthForRef(ref string, a Auth) (string, error) {
if host == "docker.io" {
host = "https://index.docker.io/v1/"
}
ac, err := a.GetAuthConfig(host)
ac, err := auth(context.TODO(), host, nil, nil)
if err != nil {
return "", err
}
@@ -90,11 +36,11 @@ func RegistryAuthForRef(ref string, a Auth) (string, error) {
type withBearerAuthorizer struct {
docker.Authorizer
AuthConfig *authConfig
AuthConfig authprovider.AuthConfigProvider
}
func (a *withBearerAuthorizer) Authorize(ctx context.Context, req *http.Request) error {
ac, err := a.AuthConfig.authConfig(req.Host)
ac, err := a.AuthConfig(ctx, req.Host, nil, nil)
if err == nil && ac.RegistryToken != "" {
req.Header.Set("Authorization", "Bearer "+ac.RegistryToken)
return nil
+5 -9
View File
@@ -11,19 +11,16 @@ import (
"github.com/containerd/log"
"github.com/distribution/reference"
"github.com/docker/buildx/util/resolver"
clitypes "github.com/docker/cli/cli/config/types"
"github.com/docker/buildx/util/resolver/auth"
"github.com/moby/buildkit/session/auth/authprovider"
"github.com/moby/buildkit/util/contentutil"
"github.com/moby/buildkit/util/tracing"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/sirupsen/logrus"
)
type Auth interface {
GetAuthConfig(registryHostname string) (clitypes.AuthConfig, error)
}
type Opt struct {
Auth Auth
Auth authprovider.AuthConfigProvider
RegistryConfig map[string]resolver.RegistryConfig
}
@@ -34,11 +31,10 @@ type Resolver struct {
}
func New(opt Opt) *Resolver {
ac := newAuthConfig(opt.Auth)
dockerAuth := docker.NewDockerAuthorizer(docker.WithAuthCreds(ac.credentials), docker.WithAuthClient(http.DefaultClient))
dockerAuth := auth.NewDockerAuthorizer(auth.WithAuthProvider(opt.Auth), auth.WithAuthClient(http.DefaultClient))
auth := &withBearerAuthorizer{
Authorizer: dockerAuth,
AuthConfig: ac,
AuthConfig: opt.Auth,
}
return &Resolver{
auth: auth,
+362
View File
@@ -0,0 +1,362 @@
package auth
// based on containerd/core/remotes/docker/authorizer.go
import (
"context"
"encoding/base64"
"net/http"
"strings"
"sync"
"time"
"github.com/containerd/containerd/v2/core/remotes/docker"
"github.com/containerd/containerd/v2/core/remotes/docker/auth"
remoteerrors "github.com/containerd/containerd/v2/core/remotes/errors"
"github.com/containerd/errdefs"
"github.com/docker/cli/cli/config/types"
"github.com/moby/buildkit/session/auth/authprovider"
"github.com/moby/buildkit/util/bklog"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type dockerAuthorizer struct {
credentials authprovider.AuthConfigProvider
client *http.Client
header http.Header
mu sync.RWMutex
// indexed by host name
handlers map[string]*authHandler
}
type authorizerConfig struct {
credentials authprovider.AuthConfigProvider
client *http.Client
header http.Header
}
// AuthorizerOpt configures an authorizer
type AuthorizerOpt func(*authorizerConfig)
// WithAuthClient provides the HTTP client for the authorizer
func WithAuthClient(client *http.Client) AuthorizerOpt {
return func(opt *authorizerConfig) {
opt.client = client
}
}
// WithAuthCreds provides a credential function to the authorizer
func WithAuthProvider(provider authprovider.AuthConfigProvider) AuthorizerOpt {
return func(opt *authorizerConfig) {
opt.credentials = provider
}
}
// WithAuthHeader provides HTTP headers for authorization
//
// We need to merge instead of replacing because header may be set by
// a per-host hosts.toml or/AND by a global header config (e.g., cri.config.headers)
func WithAuthHeader(hdr http.Header) AuthorizerOpt {
return func(opt *authorizerConfig) {
if opt.header == nil {
opt.header = hdr.Clone()
} else {
for k, v := range hdr {
opt.header[k] = append(opt.header[k], v...)
}
}
}
}
// NewDockerAuthorizer creates an authorizer using Docker's registry
// authentication spec.
// See https://distribution.github.io/distribution/spec/auth/
func NewDockerAuthorizer(opts ...AuthorizerOpt) docker.Authorizer {
var ao authorizerConfig
for _, opt := range opts {
opt(&ao)
}
if ao.client == nil {
ao.client = http.DefaultClient
}
return &dockerAuthorizer{
credentials: ao.credentials,
client: ao.client,
header: ao.header,
handlers: make(map[string]*authHandler),
}
}
// Authorize handles auth request.
func (a *dockerAuthorizer) Authorize(ctx context.Context, req *http.Request) error {
// skip if there is no auth handler
ah := a.getAuthHandler(req.URL.Host)
if ah == nil {
return nil
}
auth, err := ah.authorize(ctx)
if err != nil {
return err
}
req.Header.Set("Authorization", auth)
return nil
}
func (a *dockerAuthorizer) getAuthHandler(host string) *authHandler {
a.mu.Lock()
defer a.mu.Unlock()
return a.handlers[host]
}
func (a *dockerAuthorizer) AddResponses(ctx context.Context, responses []*http.Response) error {
last := responses[len(responses)-1]
host := last.Request.URL.Host
a.mu.Lock()
defer a.mu.Unlock()
for _, c := range auth.ParseAuthHeader(last.Header) {
if c.Scheme == auth.BearerAuth {
if retry, err := invalidAuthorization(ctx, c, responses); err != nil {
delete(a.handlers, host)
return err
} else if retry {
delete(a.handlers, host)
}
// reuse existing handler
//
// assume that one registry will return the common
// challenge information, including realm and service.
// and the resource scope is only different part
// which can be provided by each request.
if _, ok := a.handlers[host]; ok {
return nil
}
var username, secret string
if a.credentials != nil {
var err error
ac, err := a.credentials(ctx, host, strings.Split(c.Parameters["scope"], " "), nil)
if err != nil {
return err
}
username, secret = parseAuthConfig(ac)
}
common, err := auth.GenerateTokenOptions(ctx, host, username, secret, c)
if err != nil {
return err
}
a.handlers[host] = newAuthHandler(a.client, a.header, c.Scheme, common)
return nil
} else if c.Scheme == auth.BasicAuth && a.credentials != nil {
ac, err := a.credentials(ctx, host, nil, nil)
if err != nil {
return err
}
username, secret := parseAuthConfig(ac)
if username == "" || secret == "" {
return errors.Wrap(err, "no basic auth credentials")
}
a.handlers[host] = newAuthHandler(a.client, a.header, c.Scheme, auth.TokenOptions{
Username: username,
Secret: secret,
})
return nil
}
}
return errors.Wrap(errdefs.ErrNotImplemented, "failed to find supported auth scheme")
}
func parseAuthConfig(ac types.AuthConfig) (string, string) {
if ac.IdentityToken != "" {
return "", ac.IdentityToken
}
return ac.Username, ac.Password
}
// authResult is used to control limit rate.
type authResult struct {
sync.WaitGroup
token string
expirationTime *time.Time
err error
}
// authHandler is used to handle auth request per registry server.
type authHandler struct {
sync.Mutex
header http.Header
client *http.Client
// only support basic and bearer schemes
scheme auth.AuthenticationScheme
// common contains common challenge answer
common auth.TokenOptions
// scopedTokens caches token indexed by scopes, which used in
// bearer auth case
scopedTokens map[string]*authResult
}
func newAuthHandler(client *http.Client, hdr http.Header, scheme auth.AuthenticationScheme, opts auth.TokenOptions) *authHandler {
return &authHandler{
header: hdr,
client: client,
scheme: scheme,
common: opts,
scopedTokens: map[string]*authResult{},
}
}
func (ah *authHandler) authorize(ctx context.Context) (string, error) {
switch ah.scheme {
case auth.BasicAuth:
return ah.doBasicAuth(ctx)
case auth.BearerAuth:
return ah.doBearerAuth(ctx)
default:
return "", errors.Wrapf(errdefs.ErrNotImplemented, "failed to find supported auth scheme: %s", string(ah.scheme))
}
}
func (ah *authHandler) doBasicAuth(_ context.Context) (string, error) {
username, secret := ah.common.Username, ah.common.Secret
if username == "" || secret == "" {
return "", errors.Errorf("failed to handle basic auth because missing username or secret")
}
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + secret))
return "Basic " + auth, nil
}
func (ah *authHandler) doBearerAuth(ctx context.Context) (token string, err error) {
// copy common tokenOptions
to := ah.common
to.Scopes = docker.GetTokenScopes(ctx, to.Scopes)
// Docs: https://distribution.github.io/distribution/spec/auth/scope/
scoped := strings.Join(to.Scopes, " ")
// Keep track of the expiration time of cached bearer tokens so they can be
// refreshed when they expire without a server roundtrip.
var expirationTime *time.Time
ah.Lock()
if r, exist := ah.scopedTokens[scoped]; exist && (r.expirationTime == nil || r.expirationTime.After(time.Now())) {
ah.Unlock()
r.Wait()
return r.token, r.err
}
// only one fetch token job
r := new(authResult)
r.Add(1)
ah.scopedTokens[scoped] = r
ah.Unlock()
defer func() {
token = "Bearer " + token
r.token, r.err, r.expirationTime = token, err, expirationTime
r.Done()
}()
// fetch token for the resource scope
if to.Secret != "" {
defer func() {
if err != nil {
err = errors.Wrap(err, "failed to fetch oauth token")
}
}()
// credential information is provided, use oauth POST endpoint
// TODO: Allow setting client_id
resp, err := auth.FetchTokenWithOAuth(ctx, ah.client, ah.header, "containerd-client", to)
if err != nil {
var errStatus remoteerrors.ErrUnexpectedStatus
if errors.As(err, &errStatus) {
// Registries without support for POST may return 404 for POST /v2/token.
// As of September 2017, GCR is known to return 404.
// As of February 2018, JFrog Artifactory is known to return 401.
// As of January 2022, ACR is known to return 400.
if (errStatus.StatusCode == 405 && to.Username != "") || errStatus.StatusCode == 404 || errStatus.StatusCode == 401 || errStatus.StatusCode == 400 {
resp, err := auth.FetchToken(ctx, ah.client, ah.header, to)
if err != nil {
return "", err
}
expirationTime = getExpirationTime(resp.ExpiresInSeconds)
return resp.Token, nil
}
bklog.G(ctx).WithFields(logrus.Fields{
"status": errStatus.Status,
"body": string(errStatus.Body),
}).Debugf("token request failed")
}
return "", err
}
expirationTime = getExpirationTime(resp.ExpiresInSeconds)
return resp.AccessToken, nil
}
// do request anonymously
resp, err := auth.FetchToken(ctx, ah.client, ah.header, to)
if err != nil {
return "", errors.Wrap(err, "failed to fetch anonymous token")
}
expirationTime = getExpirationTime(resp.ExpiresInSeconds)
return resp.Token, nil
}
func getExpirationTime(expiresInSeconds int) *time.Time {
if expiresInSeconds <= 0 {
return nil
}
expirationTime := time.Now().Add(time.Duration(expiresInSeconds) * time.Second)
return &expirationTime
}
func invalidAuthorization(ctx context.Context, c auth.Challenge, responses []*http.Response) (retry bool, _ error) {
errStr := c.Parameters["error"]
if errStr == "" {
return retry, nil
}
n := len(responses)
if n == 1 || (n > 1 && !sameRequest(responses[n-2].Request, responses[n-1].Request)) {
limitedErr := errStr
errLenghLimit := 64
if len(limitedErr) > errLenghLimit {
limitedErr = limitedErr[:errLenghLimit] + "..."
}
bklog.G(ctx).WithField("error", limitedErr).Debug("authorization error using bearer token, retrying")
return true, nil
}
return retry, errors.Wrapf(docker.ErrInvalidAuthorization, "server message: %s", errStr)
}
func sameRequest(r1, r2 *http.Request) bool {
if r1.Method != r2.Method {
return false
}
if *r1.URL != *r2.URL {
return false
}
return true
}