From e254cf198518bb117b3f8781f8ffd61710d96ffc Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Fri, 23 Jan 2026 23:29:56 -0800 Subject: [PATCH] auth: create new authorizer for imagetools Based on containerd Authorizer. Removed unused RefreshToken, fixed error handling. Signed-off-by: Tonis Tiigi --- util/imagetools/inspect.go | 3 +- util/resolver/auth/authorizer.go | 350 +++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 util/resolver/auth/authorizer.go diff --git a/util/imagetools/inspect.go b/util/imagetools/inspect.go index c6e9c1b32..e49e2418d 100644 --- a/util/imagetools/inspect.go +++ b/util/imagetools/inspect.go @@ -11,6 +11,7 @@ import ( "github.com/containerd/log" "github.com/distribution/reference" "github.com/docker/buildx/util/resolver" + "github.com/docker/buildx/util/resolver/auth" clitypes "github.com/docker/cli/cli/config/types" "github.com/moby/buildkit/util/contentutil" "github.com/moby/buildkit/util/tracing" @@ -35,7 +36,7 @@ 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.WithAuthCreds(ac.credentials), auth.WithAuthClient(http.DefaultClient)) auth := &withBearerAuthorizer{ Authorizer: dockerAuth, AuthConfig: ac, diff --git a/util/resolver/auth/authorizer.go b/util/resolver/auth/authorizer.go new file mode 100644 index 000000000..128dda560 --- /dev/null +++ b/util/resolver/auth/authorizer.go @@ -0,0 +1,350 @@ +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/moby/buildkit/util/bklog" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +type dockerAuthorizer struct { + credentials func(string) (string, string, error) + + client *http.Client + header http.Header + mu sync.RWMutex + + // indexed by host name + handlers map[string]*authHandler +} + +type authorizerConfig struct { + credentials func(string) (string, string, error) + 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 WithAuthCreds(creds func(string) (string, string, error)) AuthorizerOpt { + return func(opt *authorizerConfig) { + opt.credentials = creds + } +} + +// 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 + username, secret, err = a.credentials(host) + if err != nil { + return err + } + } + + 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 { + username, secret, err := a.credentials(host) + if err != nil { + return err + } + + 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") +} + +// 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 +}