From 8094f50dd0fafa9695457ee9bf3dfd8f3a19bf26 Mon Sep 17 00:00:00 2001 From: Akihiro Suda Date: Fri, 24 Dec 2021 14:25:59 +0900 Subject: [PATCH 1/2] remotes/docker/config: allow setting custom AuthorizerOpts Signed-off-by: Akihiro Suda --- remotes/docker/config/hosts.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/remotes/docker/config/hosts.go b/remotes/docker/config/hosts.go index 61cdea45f..f47e4ec06 100644 --- a/remotes/docker/config/hosts.go +++ b/remotes/docker/config/hosts.go @@ -63,7 +63,8 @@ type HostOptions struct { DefaultTLS *tls.Config DefaultScheme string // UpdateClient will be called after creating http.Client object, so clients can provide extra configuration - UpdateClient UpdateClientFunc + UpdateClient UpdateClientFunc + AuthorizerOpts []docker.AuthorizerOpt } // ConfigureHosts creates a registry hosts function from the provided @@ -143,6 +144,7 @@ func ConfigureHosts(ctx context.Context, options HostOptions) docker.RegistryHos if options.Credentials != nil { authOpts = append(authOpts, docker.WithAuthCreds(options.Credentials)) } + authOpts = append(authOpts, options.AuthorizerOpts...) authorizer := docker.NewDockerAuthorizer(authOpts...) rhosts := make([]docker.RegistryHost, len(hosts)) From 97623ab0cdf6e570a12b0bd0189454b10431465c Mon Sep 17 00:00:00 2001 From: Akihiro Suda Date: Fri, 24 Dec 2021 14:20:04 +0900 Subject: [PATCH 2/2] remotes/docker: allow fetching "refresh token" (aka "identity token") The new AuthorizerOpt `WithFetchRefreshToken` allows fetching "refresh token" (aka "identity token", "offline token"). For HTTP GET mode (`FetchToken`), `offline_token=true` is set in the request. https://docs.docker.com/registry/spec/auth/token/#requesting-a-token For HTTP POST mode (`FetchTokenWithOAuth`), `access_type=offline` is set in the request. https://docs.docker.com/registry/spec/auth/oauth/#getting-a-token Signed-off-by: Akihiro Suda --- remotes/docker/auth/fetch.go | 16 ++++ remotes/docker/authorizer.go | 75 ++++++++++------ remotes/docker/resolver_test.go | 154 ++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 25 deletions(-) diff --git a/remotes/docker/auth/fetch.go b/remotes/docker/auth/fetch.go index 02b995224..3a9c26184 100644 --- a/remotes/docker/auth/fetch.go +++ b/remotes/docker/auth/fetch.go @@ -73,6 +73,15 @@ type TokenOptions struct { Scopes []string Username string Secret string + + // FetchRefreshToken enables fetching a refresh token (aka "identity token", "offline token") along with the bearer token. + // + // For HTTP GET mode (FetchToken), FetchRefreshToken sets `offline_token=true` in the request. + // https://docs.docker.com/registry/spec/auth/token/#requesting-a-token + // + // For HTTP POST mode (FetchTokenWithOAuth), FetchRefreshToken sets `access_type=offline` in the request. + // https://docs.docker.com/registry/spec/auth/oauth/#getting-a-token + FetchRefreshToken bool } // OAuthTokenResponse is response from fetching token with a OAuth POST request @@ -101,6 +110,9 @@ func FetchTokenWithOAuth(ctx context.Context, client *http.Client, headers http. form.Set("username", to.Username) form.Set("password", to.Secret) } + if to.FetchRefreshToken { + form.Set("access_type", "offline") + } req, err := http.NewRequest("POST", to.Realm, strings.NewReader(form.Encode())) if err != nil { @@ -175,6 +187,10 @@ func FetchToken(ctx context.Context, client *http.Client, headers http.Header, t req.SetBasicAuth(to.Username, to.Secret) } + if to.FetchRefreshToken { + reqParams.Add("offline_token", "true") + } + req.URL.RawQuery = reqParams.Encode() resp, err := ctxhttp.Do(ctx, client, req) diff --git a/remotes/docker/authorizer.go b/remotes/docker/authorizer.go index 67e4aea8d..bcb6296f3 100644 --- a/remotes/docker/authorizer.go +++ b/remotes/docker/authorizer.go @@ -37,10 +37,12 @@ type dockerAuthorizer struct { client *http.Client header http.Header - mu sync.Mutex + mu sync.RWMutex // indexed by host name handlers map[string]*authHandler + + onFetchRefreshToken OnFetchRefreshToken } // NewAuthorizer creates a Docker authorizer using the provided function to @@ -51,9 +53,10 @@ func NewAuthorizer(client *http.Client, f func(string) (string, string, error)) } type authorizerConfig struct { - credentials func(string) (string, string, error) - client *http.Client - header http.Header + credentials func(string) (string, string, error) + client *http.Client + header http.Header + onFetchRefreshToken OnFetchRefreshToken } // AuthorizerOpt configures an authorizer @@ -80,6 +83,16 @@ func WithAuthHeader(hdr http.Header) AuthorizerOpt { } } +// OnFetchRefreshToken is called on fetching request token. +type OnFetchRefreshToken func(ctx context.Context, refreshToken string, req *http.Request) + +// WithFetchRefreshToken enables fetching "refresh token" (aka "identity token", "offline token"). +func WithFetchRefreshToken(f OnFetchRefreshToken) AuthorizerOpt { + return func(opt *authorizerConfig) { + opt.onFetchRefreshToken = f + } +} + // NewDockerAuthorizer creates an authorizer using Docker's registry // authentication spec. // See https://docs.docker.com/registry/spec/auth/ @@ -94,10 +107,11 @@ func NewDockerAuthorizer(opts ...AuthorizerOpt) Authorizer { } return &dockerAuthorizer{ - credentials: ao.credentials, - client: ao.client, - header: ao.header, - handlers: make(map[string]*authHandler), + credentials: ao.credentials, + client: ao.client, + header: ao.header, + handlers: make(map[string]*authHandler), + onFetchRefreshToken: ao.onFetchRefreshToken, } } @@ -109,12 +123,21 @@ func (a *dockerAuthorizer) Authorize(ctx context.Context, req *http.Request) err return nil } - auth, err := ah.authorize(ctx) + auth, refreshToken, err := ah.authorize(ctx) if err != nil { return err } req.Header.Set("Authorization", auth) + + if refreshToken != "" { + a.mu.RLock() + onFetchRefreshToken := a.onFetchRefreshToken + a.mu.RUnlock() + if onFetchRefreshToken != nil { + onFetchRefreshToken(ctx, refreshToken, req) + } + } return nil } @@ -161,6 +184,7 @@ func (a *dockerAuthorizer) AddResponses(ctx context.Context, responses []*http.R if err != nil { return err } + common.FetchRefreshToken = a.onFetchRefreshToken != nil a.handlers[host] = newAuthHandler(a.client, a.header, c.Scheme, common) return nil @@ -187,8 +211,9 @@ func (a *dockerAuthorizer) AddResponses(ctx context.Context, responses []*http.R // authResult is used to control limit rate. type authResult struct { sync.WaitGroup - token string - err error + token string + refreshToken string + err error } // authHandler is used to handle auth request per registry server. @@ -220,29 +245,29 @@ func newAuthHandler(client *http.Client, hdr http.Header, scheme auth.Authentica } } -func (ah *authHandler) authorize(ctx context.Context) (string, error) { +func (ah *authHandler) authorize(ctx context.Context) (string, 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)) + return "", "", errors.Wrapf(errdefs.ErrNotImplemented, "failed to find supported auth scheme: %s", string(ah.scheme)) } } -func (ah *authHandler) doBasicAuth(ctx context.Context) (string, error) { +func (ah *authHandler) doBasicAuth(ctx context.Context) (string, string, error) { username, secret := ah.common.Username, ah.common.Secret if username == "" || secret == "" { - return "", fmt.Errorf("failed to handle basic auth because missing username or secret") + return "", "", fmt.Errorf("failed to handle basic auth because missing username or secret") } auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + secret)) - return fmt.Sprintf("Basic %s", auth), nil + return fmt.Sprintf("Basic %s", auth), "", nil } -func (ah *authHandler) doBearerAuth(ctx context.Context) (token string, err error) { +func (ah *authHandler) doBearerAuth(ctx context.Context) (token, refreshToken string, err error) { // copy common tokenOptions to := ah.common @@ -255,7 +280,7 @@ func (ah *authHandler) doBearerAuth(ctx context.Context) (token string, err erro if r, exist := ah.scopedTokens[scoped]; exist { ah.Unlock() r.Wait() - return r.token, r.err + return r.token, r.refreshToken, r.err } // only one fetch token job @@ -266,7 +291,7 @@ func (ah *authHandler) doBearerAuth(ctx context.Context) (token string, err erro defer func() { token = fmt.Sprintf("Bearer %s", token) - r.token, r.err = token, err + r.token, r.refreshToken, r.err = token, refreshToken, err r.Done() }() @@ -287,25 +312,25 @@ func (ah *authHandler) doBearerAuth(ctx context.Context) (token string, err erro if (errStatus.StatusCode == 405 && to.Username != "") || errStatus.StatusCode == 404 || errStatus.StatusCode == 401 { resp, err := auth.FetchToken(ctx, ah.client, ah.header, to) if err != nil { - return "", err + return "", "", err } - return resp.Token, nil + return resp.Token, resp.RefreshToken, nil } log.G(ctx).WithFields(logrus.Fields{ "status": errStatus.Status, "body": string(errStatus.Body), }).Debugf("token request failed") } - return "", err + return "", "", err } - return resp.AccessToken, nil + return resp.AccessToken, resp.RefreshToken, 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") + return "", "", errors.Wrap(err, "failed to fetch anonymous token") } - return resp.Token, nil + return resp.Token, resp.RefreshToken, nil } func invalidAuthorization(c auth.Challenge, responses []*http.Response) error { diff --git a/remotes/docker/resolver_test.go b/remotes/docker/resolver_test.go index ee8c311e7..e73ba73b1 100644 --- a/remotes/docker/resolver_test.go +++ b/remotes/docker/resolver_test.go @@ -31,6 +31,7 @@ import ( "time" "github.com/containerd/containerd/remotes" + "github.com/containerd/containerd/remotes/docker/auth" digest "github.com/opencontainers/go-digest" specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -137,6 +138,31 @@ func TestRefreshTokenResolver(t *testing.T) { runBasicTest(t, "testname", withTokenServer(th, creds)) } +func TestFetchRefreshToken(t *testing.T) { + f := func(t *testing.T, disablePOST bool) { + name := "testname" + if disablePOST { + name += "-disable-post" + } + var fetchedRefreshToken string + onFetchRefreshToken := func(ctx context.Context, refreshToken string, req *http.Request) { + fetchedRefreshToken = refreshToken + } + srv := newRefreshTokenServer(t, name, disablePOST, onFetchRefreshToken) + runBasicTest(t, name, srv.BasicTestFunc()) + if fetchedRefreshToken != srv.RefreshToken { + t.Errorf("unexpected refresh token: got %q", fetchedRefreshToken) + } + } + + t.Run("POST", func(t *testing.T) { + f(t, false) + }) + t.Run("GET", func(t *testing.T) { + f(t, true) + }) +} + func TestPostBasicAuthTokenResolver(t *testing.T) { th := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -658,3 +684,131 @@ func (m testManifest) RegisterHandler(r *http.ServeMux, name string) { r.Handle(fmt.Sprintf("/v2/%s/blobs/%s", name, c.Digest()), c) } } + +func newRefreshTokenServer(t testing.TB, name string, disablePOST bool, onFetchRefreshToken OnFetchRefreshToken) *refreshTokenServer { + return &refreshTokenServer{ + T: t, + Name: name, + DisablePOST: disablePOST, + OnFetchRefreshToken: onFetchRefreshToken, + AccessToken: "testAccessToken-" + name, + RefreshToken: "testRefreshToken-" + name, + Username: "testUser-" + name, + Password: "testPassword-" + name, + } +} + +type refreshTokenServer struct { + T testing.TB + Name string + DisablePOST bool + OnFetchRefreshToken OnFetchRefreshToken + AccessToken string + RefreshToken string + Username string + Password string +} + +func (srv *refreshTokenServer) isValidAuthorizationHeader(s string) bool { + fields := strings.Fields(s) + return len(fields) == 2 && strings.ToLower(fields[0]) == "bearer" && (fields[1] == srv.RefreshToken || fields[1] == srv.AccessToken) +} + +func (srv *refreshTokenServer) BasicTestFunc() func(h http.Handler) (string, ResolverOptions, func()) { + t := srv.T + return func(h http.Handler) (string, ResolverOptions, func()) { + wrapped := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/token" { + if !srv.isValidAuthorizationHeader(r.Header.Get("Authorization")) { + realm := fmt.Sprintf("https://%s/token", r.Host) + wwwAuthenticateHeader := fmt.Sprintf("Bearer realm=%q,service=registry,scope=\"repository:%s:pull\"", realm, srv.Name) + rw.Header().Set("WWW-Authenticate", wwwAuthenticateHeader) + rw.WriteHeader(http.StatusUnauthorized) + return + } + h.ServeHTTP(rw, r) + return + } + switch r.Method { + case http.MethodGet: // https://docs.docker.com/registry/spec/auth/token/#requesting-a-token + u, p, ok := r.BasicAuth() + if !ok || u != srv.Username || p != srv.Password { + rw.WriteHeader(http.StatusForbidden) + return + } + var resp auth.FetchTokenResponse + resp.Token = srv.AccessToken + resp.AccessToken = srv.AccessToken // alias of Token + query := r.URL.Query() + switch query.Get("offline_token") { + case "true": + resp.RefreshToken = srv.RefreshToken + case "false", "": + default: + rw.WriteHeader(http.StatusBadRequest) + return + } + b, err := json.Marshal(resp) + if err != nil { + rw.WriteHeader(http.StatusInternalServerError) + return + } + rw.WriteHeader(http.StatusOK) + rw.Header().Set("Content-Type", "application/json") + t.Logf("GET mode: returning JSON %q, for query %+v", string(b), query) + rw.Write(b) + case http.MethodPost: // https://docs.docker.com/registry/spec/auth/oauth/#getting-a-token + if srv.DisablePOST { + rw.WriteHeader(http.StatusMethodNotAllowed) + return + } + r.ParseForm() + pf := r.PostForm + if pf.Get("grant_type") != "password" { + rw.WriteHeader(http.StatusBadRequest) + return + } + if pf.Get("username") != srv.Username || pf.Get("password") != srv.Password { + rw.WriteHeader(http.StatusForbidden) + return + } + var resp auth.OAuthTokenResponse + resp.AccessToken = srv.AccessToken + switch pf.Get("access_type") { + case "offline": + resp.RefreshToken = srv.RefreshToken + case "online", "": + default: + rw.WriteHeader(http.StatusBadRequest) + return + } + b, err := json.Marshal(resp) + if err != nil { + rw.WriteHeader(http.StatusInternalServerError) + return + } + rw.WriteHeader(http.StatusOK) + rw.Header().Set("Content-Type", "application/json") + t.Logf("POST mode: returning JSON %q, for form %+v", string(b), pf) + rw.Write(b) + default: + rw.WriteHeader(http.StatusMethodNotAllowed) + return + } + }) + + base, options, close := tlsServer(wrapped) + authorizer := NewDockerAuthorizer( + WithAuthClient(options.Client), + WithAuthCreds(func(string) (string, string, error) { + return srv.Username, srv.Password, nil + }), + WithFetchRefreshToken(srv.OnFetchRefreshToken), + ) + options.Hosts = ConfigureDefaultRegistries( + WithClient(options.Client), + WithAuthorizer(authorizer), + ) + return base, options, close + } +}