diff --git a/driver/driver.go b/driver/driver.go index c82852cd8..2e3d9c4d0 100644 --- a/driver/driver.go +++ b/driver/driver.go @@ -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 diff --git a/driver/manager.go b/driver/manager.go index 54bf860ec..f09d22d57 100644 --- a/driver/manager.go +++ b/driver/manager.go @@ -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 diff --git a/store/storeutil/storeutil.go b/store/storeutil/storeutil.go index e9b2a14fa..1ad4da6a1 100644 --- a/store/storeutil/storeutil.go +++ b/store/storeutil/storeutil.go @@ -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 diff --git a/util/dockerutil/dockerconfig/configprovider.go b/util/dockerutil/dockerconfig/configprovider.go index 7c465dc95..1aa257460 100644 --- a/util/dockerutil/dockerconfig/configprovider.go +++ b/util/dockerutil/dockerconfig/configprovider.go @@ -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 diff --git a/util/imagetools/auth.go b/util/imagetools/auth.go index 0ddeac8bc..f917fbdc8 100644 --- a/util/imagetools/auth.go +++ b/util/imagetools/auth.go @@ -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 diff --git a/util/imagetools/inspect.go b/util/imagetools/inspect.go index c6e9c1b32..656654dc0 100644 --- a/util/imagetools/inspect.go +++ b/util/imagetools/inspect.go @@ -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, diff --git a/util/resolver/auth/authorizer.go b/util/resolver/auth/authorizer.go new file mode 100644 index 000000000..77c3bffca --- /dev/null +++ b/util/resolver/auth/authorizer.go @@ -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 +}