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 <akihiro.suda.cz@hco.ntt.co.jp>
This commit is contained in:
parent
8094f50dd0
commit
97623ab0cd
@ -73,6 +73,15 @@ type TokenOptions struct {
|
|||||||
Scopes []string
|
Scopes []string
|
||||||
Username string
|
Username string
|
||||||
Secret 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
|
// 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("username", to.Username)
|
||||||
form.Set("password", to.Secret)
|
form.Set("password", to.Secret)
|
||||||
}
|
}
|
||||||
|
if to.FetchRefreshToken {
|
||||||
|
form.Set("access_type", "offline")
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", to.Realm, strings.NewReader(form.Encode()))
|
req, err := http.NewRequest("POST", to.Realm, strings.NewReader(form.Encode()))
|
||||||
if err != nil {
|
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)
|
req.SetBasicAuth(to.Username, to.Secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if to.FetchRefreshToken {
|
||||||
|
reqParams.Add("offline_token", "true")
|
||||||
|
}
|
||||||
|
|
||||||
req.URL.RawQuery = reqParams.Encode()
|
req.URL.RawQuery = reqParams.Encode()
|
||||||
|
|
||||||
resp, err := ctxhttp.Do(ctx, client, req)
|
resp, err := ctxhttp.Do(ctx, client, req)
|
||||||
|
@ -37,10 +37,12 @@ type dockerAuthorizer struct {
|
|||||||
|
|
||||||
client *http.Client
|
client *http.Client
|
||||||
header http.Header
|
header http.Header
|
||||||
mu sync.Mutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
// indexed by host name
|
// indexed by host name
|
||||||
handlers map[string]*authHandler
|
handlers map[string]*authHandler
|
||||||
|
|
||||||
|
onFetchRefreshToken OnFetchRefreshToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthorizer creates a Docker authorizer using the provided function to
|
// 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 {
|
type authorizerConfig struct {
|
||||||
credentials func(string) (string, string, error)
|
credentials func(string) (string, string, error)
|
||||||
client *http.Client
|
client *http.Client
|
||||||
header http.Header
|
header http.Header
|
||||||
|
onFetchRefreshToken OnFetchRefreshToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthorizerOpt configures an authorizer
|
// 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
|
// NewDockerAuthorizer creates an authorizer using Docker's registry
|
||||||
// authentication spec.
|
// authentication spec.
|
||||||
// See https://docs.docker.com/registry/spec/auth/
|
// See https://docs.docker.com/registry/spec/auth/
|
||||||
@ -94,10 +107,11 @@ func NewDockerAuthorizer(opts ...AuthorizerOpt) Authorizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &dockerAuthorizer{
|
return &dockerAuthorizer{
|
||||||
credentials: ao.credentials,
|
credentials: ao.credentials,
|
||||||
client: ao.client,
|
client: ao.client,
|
||||||
header: ao.header,
|
header: ao.header,
|
||||||
handlers: make(map[string]*authHandler),
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
auth, err := ah.authorize(ctx)
|
auth, refreshToken, err := ah.authorize(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", auth)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,6 +184,7 @@ func (a *dockerAuthorizer) AddResponses(ctx context.Context, responses []*http.R
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
common.FetchRefreshToken = a.onFetchRefreshToken != nil
|
||||||
|
|
||||||
a.handlers[host] = newAuthHandler(a.client, a.header, c.Scheme, common)
|
a.handlers[host] = newAuthHandler(a.client, a.header, c.Scheme, common)
|
||||||
return nil
|
return nil
|
||||||
@ -187,8 +211,9 @@ func (a *dockerAuthorizer) AddResponses(ctx context.Context, responses []*http.R
|
|||||||
// authResult is used to control limit rate.
|
// authResult is used to control limit rate.
|
||||||
type authResult struct {
|
type authResult struct {
|
||||||
sync.WaitGroup
|
sync.WaitGroup
|
||||||
token string
|
token string
|
||||||
err error
|
refreshToken string
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// authHandler is used to handle auth request per registry server.
|
// 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 {
|
switch ah.scheme {
|
||||||
case auth.BasicAuth:
|
case auth.BasicAuth:
|
||||||
return ah.doBasicAuth(ctx)
|
return ah.doBasicAuth(ctx)
|
||||||
case auth.BearerAuth:
|
case auth.BearerAuth:
|
||||||
return ah.doBearerAuth(ctx)
|
return ah.doBearerAuth(ctx)
|
||||||
default:
|
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
|
username, secret := ah.common.Username, ah.common.Secret
|
||||||
|
|
||||||
if username == "" || 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))
|
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
|
// copy common tokenOptions
|
||||||
to := ah.common
|
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 {
|
if r, exist := ah.scopedTokens[scoped]; exist {
|
||||||
ah.Unlock()
|
ah.Unlock()
|
||||||
r.Wait()
|
r.Wait()
|
||||||
return r.token, r.err
|
return r.token, r.refreshToken, r.err
|
||||||
}
|
}
|
||||||
|
|
||||||
// only one fetch token job
|
// only one fetch token job
|
||||||
@ -266,7 +291,7 @@ func (ah *authHandler) doBearerAuth(ctx context.Context) (token string, err erro
|
|||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
token = fmt.Sprintf("Bearer %s", token)
|
token = fmt.Sprintf("Bearer %s", token)
|
||||||
r.token, r.err = token, err
|
r.token, r.refreshToken, r.err = token, refreshToken, err
|
||||||
r.Done()
|
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 {
|
if (errStatus.StatusCode == 405 && to.Username != "") || errStatus.StatusCode == 404 || errStatus.StatusCode == 401 {
|
||||||
resp, err := auth.FetchToken(ctx, ah.client, ah.header, to)
|
resp, err := auth.FetchToken(ctx, ah.client, ah.header, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
return resp.Token, nil
|
return resp.Token, resp.RefreshToken, nil
|
||||||
}
|
}
|
||||||
log.G(ctx).WithFields(logrus.Fields{
|
log.G(ctx).WithFields(logrus.Fields{
|
||||||
"status": errStatus.Status,
|
"status": errStatus.Status,
|
||||||
"body": string(errStatus.Body),
|
"body": string(errStatus.Body),
|
||||||
}).Debugf("token request failed")
|
}).Debugf("token request failed")
|
||||||
}
|
}
|
||||||
return "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
return resp.AccessToken, nil
|
return resp.AccessToken, resp.RefreshToken, nil
|
||||||
}
|
}
|
||||||
// do request anonymously
|
// do request anonymously
|
||||||
resp, err := auth.FetchToken(ctx, ah.client, ah.header, to)
|
resp, err := auth.FetchToken(ctx, ah.client, ah.header, to)
|
||||||
if err != nil {
|
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 {
|
func invalidAuthorization(c auth.Challenge, responses []*http.Response) error {
|
||||||
|
@ -31,6 +31,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/containerd/containerd/remotes"
|
"github.com/containerd/containerd/remotes"
|
||||||
|
"github.com/containerd/containerd/remotes/docker/auth"
|
||||||
digest "github.com/opencontainers/go-digest"
|
digest "github.com/opencontainers/go-digest"
|
||||||
specs "github.com/opencontainers/image-spec/specs-go"
|
specs "github.com/opencontainers/image-spec/specs-go"
|
||||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
@ -137,6 +138,31 @@ func TestRefreshTokenResolver(t *testing.T) {
|
|||||||
runBasicTest(t, "testname", withTokenServer(th, creds))
|
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) {
|
func TestPostBasicAuthTokenResolver(t *testing.T) {
|
||||||
th := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
th := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
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)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user