diff --git a/api/client/info.go b/api/client/info.go index 2d02af3a58..2959424378 100644 --- a/api/client/info.go +++ b/api/client/info.go @@ -93,8 +93,8 @@ func (cli *DockerCli) CmdInfo(args ...string) error { u := cli.configFile.AuthConfigs[info.IndexServerAddress].Username if len(u) > 0 { fmt.Fprintf(cli.out, "Username: %v\n", u) - fmt.Fprintf(cli.out, "Registry: %v\n", info.IndexServerAddress) } + fmt.Fprintf(cli.out, "Registry: %v\n", info.IndexServerAddress) } // Only output these warnings if the server does not support these features diff --git a/api/client/login.go b/api/client/login.go index 6696feb8cf..3fc14743de 100644 --- a/api/client/login.go +++ b/api/client/login.go @@ -57,12 +57,16 @@ func (cli *DockerCli) CmdLogin(args ...string) error { return err } + if response.IdentityToken != "" { + authConfig.Password = "" + authConfig.IdentityToken = response.IdentityToken + } if err := storeCredentials(cli.configFile, authConfig); err != nil { return fmt.Errorf("Error saving credentials: %v", err) } if response.Status != "" { - fmt.Fprintf(cli.out, "%s\n", response.Status) + fmt.Fprintln(cli.out, response.Status) } return nil } @@ -120,6 +124,7 @@ func (cli *DockerCli) configureAuth(flUser, flPassword, serverAddress string, is authconfig.Username = flUser authconfig.Password = flPassword authconfig.ServerAddress = serverAddress + authconfig.IdentityToken = "" return authconfig, nil } diff --git a/api/client/trust.go b/api/client/trust.go index 88cfb3244b..e61c8225b6 100644 --- a/api/client/trust.go +++ b/api/client/trust.go @@ -107,6 +107,13 @@ func (scs simpleCredentialStore) Basic(u *url.URL) (string, string) { return scs.auth.Username, scs.auth.Password } +func (scs simpleCredentialStore) RefreshToken(u *url.URL, service string) string { + return scs.auth.IdentityToken +} + +func (scs simpleCredentialStore) SetRefreshToken(*url.URL, string, string) { +} + // getNotaryRepository returns a NotaryRepository which stores all the // information needed to operate on a notary repository. // It creates a HTTP transport providing authentication support. diff --git a/api/server/router/system/backend.go b/api/server/router/system/backend.go index 8a270027ff..c842ce68cc 100644 --- a/api/server/router/system/backend.go +++ b/api/server/router/system/backend.go @@ -13,5 +13,5 @@ type Backend interface { SystemVersion() types.Version SubscribeToEvents(since, sinceNano int64, ef filters.Args) ([]events.Message, chan interface{}) UnsubscribeFromEvents(chan interface{}) - AuthenticateToRegistry(authConfig *types.AuthConfig) (string, error) + AuthenticateToRegistry(authConfig *types.AuthConfig) (string, string, error) } diff --git a/api/server/router/system/system_routes.go b/api/server/router/system/system_routes.go index 41b531129d..1819747f7d 100644 --- a/api/server/router/system/system_routes.go +++ b/api/server/router/system/system_routes.go @@ -115,11 +115,12 @@ func (s *systemRouter) postAuth(ctx context.Context, w http.ResponseWriter, r *h if err != nil { return err } - status, err := s.backend.AuthenticateToRegistry(config) + status, token, err := s.backend.AuthenticateToRegistry(config) if err != nil { return err } return httputils.WriteJSON(w, http.StatusOK, &types.AuthResponse{ - Status: status, + Status: status, + IdentityToken: token, }) } diff --git a/cliconfig/credentials/native_store.go b/cliconfig/credentials/native_store.go index 2da041d4b2..9b8997dd64 100644 --- a/cliconfig/credentials/native_store.go +++ b/cliconfig/credentials/native_store.go @@ -13,7 +13,10 @@ import ( "github.com/docker/engine-api/types" ) -const remoteCredentialsPrefix = "docker-credential-" +const ( + remoteCredentialsPrefix = "docker-credential-" + tokenUsername = "" +) // Standarize the not found error, so every helper returns // the same message and docker can handle it properly. @@ -29,14 +32,14 @@ type command interface { type credentialsRequest struct { ServerURL string Username string - Password string + Secret string } // credentialsGetResponse is the information serialized from a remote store // when the plugin sends requests to get the user credentials. type credentialsGetResponse struct { Username string - Password string + Secret string } // nativeStore implements a credentials store @@ -76,6 +79,7 @@ func (c *nativeStore) Get(serverAddress string) (types.AuthConfig, error) { return auth, err } auth.Username = creds.Username + auth.IdentityToken = creds.IdentityToken auth.Password = creds.Password return auth, nil @@ -89,6 +93,7 @@ func (c *nativeStore) GetAll() (map[string]types.AuthConfig, error) { creds, _ := c.getCredentialsFromStore(s) ac.Username = creds.Username ac.Password = creds.Password + ac.IdentityToken = creds.IdentityToken auths[s] = ac } @@ -102,6 +107,7 @@ func (c *nativeStore) Store(authConfig types.AuthConfig) error { } authConfig.Username = "" authConfig.Password = "" + authConfig.IdentityToken = "" // Fallback to old credential in plain text to save only the email return c.fileStore.Store(authConfig) @@ -113,7 +119,12 @@ func (c *nativeStore) storeCredentialsInStore(config types.AuthConfig) error { creds := &credentialsRequest{ ServerURL: config.ServerAddress, Username: config.Username, - Password: config.Password, + Secret: config.Password, + } + + if config.IdentityToken != "" { + creds.Username = tokenUsername + creds.Secret = config.IdentityToken } buffer := new(bytes.Buffer) @@ -158,13 +169,18 @@ func (c *nativeStore) getCredentialsFromStore(serverAddress string) (types.AuthC return ret, err } - ret.Username = resp.Username - ret.Password = resp.Password + if resp.Username == tokenUsername { + ret.IdentityToken = resp.Secret + } else { + ret.Password = resp.Secret + ret.Username = resp.Username + } + ret.ServerAddress = serverAddress return ret, nil } -// eraseCredentialsFromStore executes the command to remove the server redentails from the native store. +// eraseCredentialsFromStore executes the command to remove the server credentails from the native store. func (c *nativeStore) eraseCredentialsFromStore(serverURL string) error { cmd := c.commandFn("erase") cmd.Input(strings.NewReader(serverURL)) diff --git a/cliconfig/credentials/native_store_test.go b/cliconfig/credentials/native_store_test.go index 454fd0bd91..354221027e 100644 --- a/cliconfig/credentials/native_store_test.go +++ b/cliconfig/credentials/native_store_test.go @@ -47,8 +47,10 @@ func (m *mockCommand) Output() ([]byte, error) { } case "get": switch inS { - case validServerAddress, validServerAddress2: - return []byte(`{"Username": "foo", "Password": "bar"}`), nil + case validServerAddress: + return []byte(`{"Username": "foo", "Secret": "bar"}`), nil + case validServerAddress2: + return []byte(`{"Username": "", "Secret": "abcd1234"}`), nil case missingCredsAddress: return []byte(errCredentialsNotFound.Error()), errCommandExited case invalidServerAddress: @@ -118,6 +120,9 @@ func TestNativeStoreAddCredentials(t *testing.T) { if a.Password != "" { t.Fatalf("expected password to be empty, got %s", a.Password) } + if a.IdentityToken != "" { + t.Fatalf("expected identity token to be empty, got %s", a.IdentityToken) + } if a.Email != "foo@example.com" { t.Fatalf("expected email `foo@example.com`, got %s", a.Email) } @@ -174,11 +179,45 @@ func TestNativeStoreGet(t *testing.T) { if a.Password != "bar" { t.Fatalf("expected password `bar`, got %s", a.Password) } + if a.IdentityToken != "" { + t.Fatalf("expected identity token to be empty, got %s", a.IdentityToken) + } if a.Email != "foo@example.com" { t.Fatalf("expected email `foo@example.com`, got %s", a.Email) } } +func TestNativeStoreGetIdentityToken(t *testing.T) { + f := newConfigFile(map[string]types.AuthConfig{ + validServerAddress2: { + Email: "foo@example2.com", + }, + }) + f.CredentialsStore = "mock" + + s := &nativeStore{ + commandFn: mockCommandFn, + fileStore: NewFileStore(f), + } + a, err := s.Get(validServerAddress2) + if err != nil { + t.Fatal(err) + } + + if a.Username != "" { + t.Fatalf("expected username to be empty, got %s", a.Username) + } + if a.Password != "" { + t.Fatalf("expected password to be empty, got %s", a.Password) + } + if a.IdentityToken != "abcd1234" { + t.Fatalf("expected identity token `abcd1234`, got %s", a.IdentityToken) + } + if a.Email != "foo@example2.com" { + t.Fatalf("expected email `foo@example2.com`, got %s", a.Email) + } +} + func TestNativeStoreGetAll(t *testing.T) { f := newConfigFile(map[string]types.AuthConfig{ validServerAddress: { @@ -209,14 +248,20 @@ func TestNativeStoreGetAll(t *testing.T) { if as[validServerAddress].Password != "bar" { t.Fatalf("expected password `bar` for %s, got %s", validServerAddress, as[validServerAddress].Password) } + if as[validServerAddress].IdentityToken != "" { + t.Fatalf("expected identity to be empty for %s, got %s", validServerAddress, as[validServerAddress].IdentityToken) + } if as[validServerAddress].Email != "foo@example.com" { t.Fatalf("expected email `foo@example.com` for %s, got %s", validServerAddress, as[validServerAddress].Email) } - if as[validServerAddress2].Username != "foo" { - t.Fatalf("expected username `foo` for %s, got %s", validServerAddress2, as[validServerAddress2].Username) + if as[validServerAddress2].Username != "" { + t.Fatalf("expected username to be empty for %s, got %s", validServerAddress2, as[validServerAddress2].Username) } - if as[validServerAddress2].Password != "bar" { - t.Fatalf("expected password `bar` for %s, got %s", validServerAddress2, as[validServerAddress2].Password) + if as[validServerAddress2].Password != "" { + t.Fatalf("expected password to be empty for %s, got %s", validServerAddress2, as[validServerAddress2].Password) + } + if as[validServerAddress2].IdentityToken != "abcd1234" { + t.Fatalf("expected identity token `abcd1324` for %s, got %s", validServerAddress2, as[validServerAddress2].IdentityToken) } if as[validServerAddress2].Email != "foo@example2.com" { t.Fatalf("expected email `foo@example2.com` for %s, got %s", validServerAddress2, as[validServerAddress2].Email) diff --git a/daemon/daemon.go b/daemon/daemon.go index 34ac30d014..a7efedbe9c 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -1519,7 +1519,7 @@ func configureVolumes(config *Config, rootUID, rootGID int) (*store.VolumeStore, } // AuthenticateToRegistry checks the validity of credentials in authConfig -func (daemon *Daemon) AuthenticateToRegistry(authConfig *types.AuthConfig) (string, error) { +func (daemon *Daemon) AuthenticateToRegistry(authConfig *types.AuthConfig) (string, string, error) { return daemon.RegistryService.Auth(authConfig, dockerversion.DockerUserAgent()) } diff --git a/distribution/registry.go b/distribution/registry.go index 4946c0b573..a4de299530 100644 --- a/distribution/registry.go +++ b/distribution/registry.go @@ -26,6 +26,13 @@ func (dcs dumbCredentialStore) Basic(*url.URL) (string, string) { return dcs.auth.Username, dcs.auth.Password } +func (dcs dumbCredentialStore) RefreshToken(*url.URL, string) string { + return dcs.auth.IdentityToken +} + +func (dcs dumbCredentialStore) SetRefreshToken(*url.URL, string, string) { +} + // NewV2Repository returns a repository (v2 only). It creates a HTTP transport // providing timeout settings and authentication support, and also verifies the // remote API version. @@ -72,7 +79,18 @@ func NewV2Repository(ctx context.Context, repoInfo *registry.RepositoryInfo, end modifiers = append(modifiers, auth.NewAuthorizer(challengeManager, passThruTokenHandler)) } else { creds := dumbCredentialStore{auth: authConfig} - tokenHandler := auth.NewTokenHandler(authTransport, creds, repoName, actions...) + tokenHandlerOptions := auth.TokenHandlerOptions{ + Transport: authTransport, + Credentials: creds, + Scopes: []auth.Scope{ + auth.RepositoryScope{ + Repository: repoName, + Actions: actions, + }, + }, + ClientID: registry.AuthClientID, + } + tokenHandler := auth.NewTokenHandlerWithOptions(tokenHandlerOptions) basicHandler := auth.NewBasicHandler(creds) modifiers = append(modifiers, auth.NewAuthorizer(challengeManager, tokenHandler, basicHandler)) } diff --git a/docs/reference/api/docker_remote_api.md b/docs/reference/api/docker_remote_api.md index 75edd928bf..5aee96c53a 100644 --- a/docs/reference/api/docker_remote_api.md +++ b/docs/reference/api/docker_remote_api.md @@ -125,6 +125,7 @@ This section lists each version from latest to oldest. Each listing includes a * `GET /info` now returns `KernelMemory` field, showing if "kernel memory limit" is supported. * `POST /containers/create` now takes `PidsLimit` field, if the kernel is >= 4.3 and the pids cgroup is supported. * `GET /containers/(id or name)/stats` now returns `pids_stats`, if the kernel is >= 4.3 and the pids cgroup is supported. +* `POST /auth` now returns an `IdentityToken` when supported by a registry. ### v1.22 API changes diff --git a/docs/reference/api/docker_remote_api_v1.23.md b/docs/reference/api/docker_remote_api_v1.23.md index 97f0ddee0d..fc456d088a 100644 --- a/docs/reference/api/docker_remote_api_v1.23.md +++ b/docs/reference/api/docker_remote_api_v1.23.md @@ -1985,11 +1985,11 @@ Request Headers: } ``` - - Token based login: + - Identity token based login: ``` { - "registrytoken": "9cbaf023786cd7..." + "identitytoken": "9cbaf023786cd7..." } ``` @@ -2119,7 +2119,8 @@ Status Codes: `POST /auth` -Get the default username and email +Validate credentials for a registry and get identity token, +if available, for accessing the registry without password. **Example request**: @@ -2127,9 +2128,8 @@ Get the default username and email Content-Type: application/json { - "username":" hannibal", - "password: "xxxx", - "email": "hannibal@a-team.com", + "username": "hannibal", + "password": "xxxx", "serveraddress": "https://index.docker.io/v1/" } @@ -2137,6 +2137,11 @@ Get the default username and email HTTP/1.1 200 OK + { + "Status": "Login Succeeded", + "IdentityToken": "9cbaf023786cd7..." + } + Status Codes: - **200** – no error diff --git a/docs/reference/commandline/login.md b/docs/reference/commandline/login.md index 410a8ae781..baff45f8c7 100644 --- a/docs/reference/commandline/login.md +++ b/docs/reference/commandline/login.md @@ -78,17 +78,20 @@ The helpers always use the first argument in the command to identify the action. There are only three possible values for that argument: `store`, `get`, and `erase`. The `store` command takes a JSON payload from the standard input. That payload carries -the server address, to identify the credential, the user name and the password. -This is an example of that payload: +the server address, to identify the credential, the user name, and either a password +or an identity token. ```json { "ServerURL": "https://index.docker.io/v1", "Username": "david", - "Password": "passw0rd1" + "Secret": "passw0rd1" } ``` +If the secret being stored is an identity token, the Username should be set to +``. + The `store` command can write error messages to `STDOUT` that the docker engine will show if there was an issue. @@ -102,7 +105,7 @@ and password from this payload: ```json { "Username": "david", - "Password": "passw0rd1" + "Secret": "passw0rd1" } ``` diff --git a/hack/vendor.sh b/hack/vendor.sh index 22c83458df..655effbae9 100755 --- a/hack/vendor.sh +++ b/hack/vendor.sh @@ -48,7 +48,7 @@ clone git github.com/boltdb/bolt v1.1.0 clone git github.com/miekg/dns 75e6e86cc601825c5dbcd4e0c209eab180997cd7 # get graph and distribution packages -clone git github.com/docker/distribution 7b66c50bb7e0e4b3b83f8fd134a9f6ea4be08b57 +clone git github.com/docker/distribution db17a23b961978730892e12a0c6051d43a31aab3 clone git github.com/vbatts/tar-split v0.9.11 # get desired notary commit, might also need to be updated in Dockerfile diff --git a/integration-cli/fixtures/auth/docker-credential-shell-test b/integration-cli/fixtures/auth/docker-credential-shell-test index 0c94bcd216..1980bb1803 100755 --- a/integration-cli/fixtures/auth/docker-credential-shell-test +++ b/integration-cli/fixtures/auth/docker-credential-shell-test @@ -8,8 +8,8 @@ case $1 in server=$(echo "$in" | jq --raw-output ".ServerURL" | sha1sum - | awk '{print $1}') username=$(echo "$in" | jq --raw-output ".Username") - password=$(echo "$in" | jq --raw-output ".Password") - echo "{ \"Username\": \"${username}\", \"Password\": \"${password}\" }" > $TEMP/$server + password=$(echo "$in" | jq --raw-output ".Secret") + echo "{ \"Username\": \"${username}\", \"Secret\": \"${password}\" }" > $TEMP/$server ;; "get") in=$( $@ diff --git a/vendor/src/github.com/docker/distribution/README.md b/vendor/src/github.com/docker/distribution/README.md index e8262133a1..cc55884e61 100644 --- a/vendor/src/github.com/docker/distribution/README.md +++ b/vendor/src/github.com/docker/distribution/README.md @@ -128,4 +128,4 @@ avenues are available for support: ## License -This project is distributed under [Apache License, Version 2.0](LICENSE.md). +This project is distributed under [Apache License, Version 2.0](LICENSE). diff --git a/vendor/src/github.com/docker/distribution/blobs.go b/vendor/src/github.com/docker/distribution/blobs.go index ce43ea2ef6..e80800f853 100644 --- a/vendor/src/github.com/docker/distribution/blobs.go +++ b/vendor/src/github.com/docker/distribution/blobs.go @@ -97,6 +97,11 @@ type BlobDeleter interface { Delete(ctx context.Context, dgst digest.Digest) error } +// BlobEnumerator enables iterating over blobs from storage +type BlobEnumerator interface { + Enumerate(ctx context.Context, ingester func(dgst digest.Digest) error) error +} + // BlobDescriptorService manages metadata about a blob by digest. Most // implementations will not expose such an interface explicitly. Such mappings // should be maintained by interacting with the BlobIngester. Hence, this is diff --git a/vendor/src/github.com/docker/distribution/errors.go b/vendor/src/github.com/docker/distribution/errors.go index 77bd096ec8..c20f28113c 100644 --- a/vendor/src/github.com/docker/distribution/errors.go +++ b/vendor/src/github.com/docker/distribution/errors.go @@ -8,6 +8,10 @@ import ( "github.com/docker/distribution/digest" ) +// ErrAccessDenied is returned when an access to a requested resource is +// denied. +var ErrAccessDenied = errors.New("access denied") + // ErrManifestNotModified is returned when a conditional manifest GetByTag // returns nil due to the client indicating it has the latest version var ErrManifestNotModified = errors.New("manifest not modified") diff --git a/vendor/src/github.com/docker/distribution/manifests.go b/vendor/src/github.com/docker/distribution/manifests.go index 40c5622f08..3bf912a659 100644 --- a/vendor/src/github.com/docker/distribution/manifests.go +++ b/vendor/src/github.com/docker/distribution/manifests.go @@ -53,12 +53,18 @@ type ManifestService interface { // Delete removes the manifest specified by the given digest. Deleting // a manifest that doesn't exist will return ErrManifestNotFound Delete(ctx context.Context, dgst digest.Digest) error +} - // Enumerate fills 'manifests' with the manifests in this service up - // to the size of 'manifests' and returns 'n' for the number of entries - // which were filled. 'last' contains an offset in the manifest set - // and can be used to resume iteration. - //Enumerate(ctx context.Context, manifests []Manifest, last Manifest) (n int, err error) +// ManifestEnumerator enables iterating over manifests +type ManifestEnumerator interface { + // Enumerate calls ingester for each manifest. + Enumerate(ctx context.Context, ingester func(digest.Digest) error) error +} + +// SignaturesGetter provides an interface for getting the signatures of a schema1 manifest. If the digest +// referred to is not a schema1 manifest, an error should be returned. +type SignaturesGetter interface { + GetSignatures(ctx context.Context, manifestDigest digest.Digest) ([]digest.Digest, error) } // Describable is an interface for descriptors diff --git a/vendor/src/github.com/docker/distribution/reference/reference.go b/vendor/src/github.com/docker/distribution/reference/reference.go index 6f079cbb1a..bb09fa25da 100644 --- a/vendor/src/github.com/docker/distribution/reference/reference.go +++ b/vendor/src/github.com/docker/distribution/reference/reference.go @@ -3,7 +3,7 @@ // // Grammar // -// reference := repository [ ":" tag ] [ "@" digest ] +// reference := name [ ":" tag ] [ "@" digest ] // name := [hostname '/'] component ['/' component]* // hostname := hostcomponent ['.' hostcomponent]* [':' port-number] // hostcomponent := /([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/ diff --git a/vendor/src/github.com/docker/distribution/registry.go b/vendor/src/github.com/docker/distribution/registry.go index 37dbb49be9..fbe605e0a5 100644 --- a/vendor/src/github.com/docker/distribution/registry.go +++ b/vendor/src/github.com/docker/distribution/registry.go @@ -40,6 +40,17 @@ type Namespace interface { // which were filled. 'last' contains an offset in the catalog, and 'err' will be // set to io.EOF if there are no more entries to obtain. Repositories(ctx context.Context, repos []string, last string) (n int, err error) + + // Blobs returns a blob enumerator to access all blobs + Blobs() BlobEnumerator + + // BlobStatter returns a BlobStatter to control + BlobStatter() BlobStatter +} + +// RepositoryEnumerator describes an operation to enumerate repositories +type RepositoryEnumerator interface { + Enumerate(ctx context.Context, ingester func(string) error) error } // ManifestServiceOption is a function argument for Manifest Service methods diff --git a/vendor/src/github.com/docker/distribution/registry/api/v2/descriptors.go b/vendor/src/github.com/docker/distribution/registry/api/v2/descriptors.go index 7549ccc322..582799948a 100644 --- a/vendor/src/github.com/docker/distribution/registry/api/v2/descriptors.go +++ b/vendor/src/github.com/docker/distribution/registry/api/v2/descriptors.go @@ -514,7 +514,7 @@ var routeDescriptors = []RouteDescriptor{ digestHeader, }, Body: BodyDescriptor{ - ContentType: "application/json; charset=utf-8", + ContentType: "", Format: manifestBody, }, }, @@ -553,7 +553,7 @@ var routeDescriptors = []RouteDescriptor{ referenceParameterDescriptor, }, Body: BodyDescriptor{ - ContentType: "application/json; charset=utf-8", + ContentType: "", Format: manifestBody, }, Successes: []ResponseDescriptor{ diff --git a/vendor/src/github.com/docker/distribution/registry/client/auth/session.go b/vendor/src/github.com/docker/distribution/registry/client/auth/session.go index f4c7ade41f..058a87b9c9 100644 --- a/vendor/src/github.com/docker/distribution/registry/client/auth/session.go +++ b/vendor/src/github.com/docker/distribution/registry/client/auth/session.go @@ -19,6 +19,8 @@ import ( // basic auth due to lack of credentials. var ErrNoBasicAuthCredentials = errors.New("no basic auth credentials") +const defaultClientID = "registry-client" + // AuthenticationHandler is an interface for authorizing a request from // params from a "WWW-Authenicate" header for a single scheme. type AuthenticationHandler interface { @@ -36,6 +38,14 @@ type AuthenticationHandler interface { type CredentialStore interface { // Basic returns basic auth for the given URL Basic(*url.URL) (string, string) + + // RefreshToken returns a refresh token for the + // given URL and service + RefreshToken(*url.URL, string) string + + // SetRefreshToken sets the refresh token if none + // is provided for the given url and service + SetRefreshToken(realm *url.URL, service, token string) } // NewAuthorizer creates an authorizer which can handle multiple authentication @@ -105,27 +115,47 @@ type clock interface { type tokenHandler struct { header http.Header creds CredentialStore - scope tokenScope transport http.RoundTripper clock clock + offlineAccess bool + forceOAuth bool + clientID string + scopes []Scope + tokenLock sync.Mutex tokenCache string tokenExpiration time.Time - - additionalScopes map[string]struct{} } -// tokenScope represents the scope at which a token will be requested. -// This represents a specific action on a registry resource. -type tokenScope struct { - Resource string - Scope string - Actions []string +// Scope is a type which is serializable to a string +// using the allow scope grammar. +type Scope interface { + String() string } -func (ts tokenScope) String() string { - return fmt.Sprintf("%s:%s:%s", ts.Resource, ts.Scope, strings.Join(ts.Actions, ",")) +// RepositoryScope represents a token scope for access +// to a repository. +type RepositoryScope struct { + Repository string + Actions []string +} + +// String returns the string representation of the repository +// using the scope grammar +func (rs RepositoryScope) String() string { + return fmt.Sprintf("repository:%s:%s", rs.Repository, strings.Join(rs.Actions, ",")) +} + +// TokenHandlerOptions is used to configure a new token handler +type TokenHandlerOptions struct { + Transport http.RoundTripper + Credentials CredentialStore + + OfflineAccess bool + ForceOAuth bool + ClientID string + Scopes []Scope } // An implementation of clock for providing real time data. @@ -137,22 +167,33 @@ func (realClock) Now() time.Time { return time.Now() } // NewTokenHandler creates a new AuthenicationHandler which supports // fetching tokens from a remote token server. func NewTokenHandler(transport http.RoundTripper, creds CredentialStore, scope string, actions ...string) AuthenticationHandler { - return newTokenHandler(transport, creds, realClock{}, scope, actions...) + // Create options... + return NewTokenHandlerWithOptions(TokenHandlerOptions{ + Transport: transport, + Credentials: creds, + Scopes: []Scope{ + RepositoryScope{ + Repository: scope, + Actions: actions, + }, + }, + }) } -// newTokenHandler exposes the option to provide a clock to manipulate time in unit testing. -func newTokenHandler(transport http.RoundTripper, creds CredentialStore, c clock, scope string, actions ...string) AuthenticationHandler { - return &tokenHandler{ - transport: transport, - creds: creds, - clock: c, - scope: tokenScope{ - Resource: "repository", - Scope: scope, - Actions: actions, - }, - additionalScopes: map[string]struct{}{}, +// NewTokenHandlerWithOptions creates a new token handler using the provided +// options structure. +func NewTokenHandlerWithOptions(options TokenHandlerOptions) AuthenticationHandler { + handler := &tokenHandler{ + transport: options.Transport, + creds: options.Credentials, + offlineAccess: options.OfflineAccess, + forceOAuth: options.ForceOAuth, + clientID: options.ClientID, + scopes: options.Scopes, + clock: realClock{}, } + + return handler } func (th *tokenHandler) client() *http.Client { @@ -169,123 +210,110 @@ func (th *tokenHandler) Scheme() string { func (th *tokenHandler) AuthorizeRequest(req *http.Request, params map[string]string) error { var additionalScopes []string if fromParam := req.URL.Query().Get("from"); fromParam != "" { - additionalScopes = append(additionalScopes, tokenScope{ - Resource: "repository", - Scope: fromParam, - Actions: []string{"pull"}, + additionalScopes = append(additionalScopes, RepositoryScope{ + Repository: fromParam, + Actions: []string{"pull"}, }.String()) } - if err := th.refreshToken(params, additionalScopes...); err != nil { + + token, err := th.getToken(params, additionalScopes...) + if err != nil { return err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", th.tokenCache)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) return nil } -func (th *tokenHandler) refreshToken(params map[string]string, additionalScopes ...string) error { +func (th *tokenHandler) getToken(params map[string]string, additionalScopes ...string) (string, error) { th.tokenLock.Lock() defer th.tokenLock.Unlock() + scopes := make([]string, 0, len(th.scopes)+len(additionalScopes)) + for _, scope := range th.scopes { + scopes = append(scopes, scope.String()) + } var addedScopes bool for _, scope := range additionalScopes { - if _, ok := th.additionalScopes[scope]; !ok { - th.additionalScopes[scope] = struct{}{} - addedScopes = true - } + scopes = append(scopes, scope) + addedScopes = true } + now := th.clock.Now() if now.After(th.tokenExpiration) || addedScopes { - tr, err := th.fetchToken(params) + token, expiration, err := th.fetchToken(params, scopes) if err != nil { - return err + return "", err } - th.tokenCache = tr.Token - th.tokenExpiration = tr.IssuedAt.Add(time.Duration(tr.ExpiresIn) * time.Second) + + // do not update cache for added scope tokens + if !addedScopes { + th.tokenCache = token + th.tokenExpiration = expiration + } + + return token, nil } - return nil + return th.tokenCache, nil } -type tokenResponse struct { - Token string `json:"token"` - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - IssuedAt time.Time `json:"issued_at"` +type postTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + IssuedAt time.Time `json:"issued_at"` + Scope string `json:"scope"` } -func (th *tokenHandler) fetchToken(params map[string]string) (token *tokenResponse, err error) { - //log.Debugf("Getting bearer token with %s for %s", challenge.Parameters, ta.auth.Username) - realm, ok := params["realm"] - if !ok { - return nil, errors.New("no realm specified for token auth challenge") +func (th *tokenHandler) fetchTokenWithOAuth(realm *url.URL, refreshToken, service string, scopes []string) (token string, expiration time.Time, err error) { + form := url.Values{} + form.Set("scope", strings.Join(scopes, " ")) + form.Set("service", service) + + clientID := th.clientID + if clientID == "" { + // Use default client, this is a required field + clientID = defaultClientID + } + form.Set("client_id", clientID) + + if refreshToken != "" { + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", refreshToken) + } else if th.creds != nil { + form.Set("grant_type", "password") + username, password := th.creds.Basic(realm) + form.Set("username", username) + form.Set("password", password) + + // attempt to get a refresh token + form.Set("access_type", "offline") + } else { + // refuse to do oauth without a grant type + return "", time.Time{}, fmt.Errorf("no supported grant type") } - // TODO(dmcgowan): Handle empty scheme - - realmURL, err := url.Parse(realm) + resp, err := th.client().PostForm(realm.String(), form) if err != nil { - return nil, fmt.Errorf("invalid token auth challenge realm: %s", err) - } - - req, err := http.NewRequest("GET", realmURL.String(), nil) - if err != nil { - return nil, err - } - - reqParams := req.URL.Query() - service := params["service"] - scope := th.scope.String() - - if service != "" { - reqParams.Add("service", service) - } - - for _, scopeField := range strings.Fields(scope) { - reqParams.Add("scope", scopeField) - } - - for scope := range th.additionalScopes { - reqParams.Add("scope", scope) - } - - if th.creds != nil { - username, password := th.creds.Basic(realmURL) - if username != "" && password != "" { - reqParams.Add("account", username) - req.SetBasicAuth(username, password) - } - } - - req.URL.RawQuery = reqParams.Encode() - - resp, err := th.client().Do(req) - if err != nil { - return nil, err + return "", time.Time{}, err } defer resp.Body.Close() if !client.SuccessStatus(resp.StatusCode) { err := client.HandleErrorResponse(resp) - return nil, err + return "", time.Time{}, err } decoder := json.NewDecoder(resp.Body) - tr := new(tokenResponse) - if err = decoder.Decode(tr); err != nil { - return nil, fmt.Errorf("unable to decode token response: %s", err) + var tr postTokenResponse + if err = decoder.Decode(&tr); err != nil { + return "", time.Time{}, fmt.Errorf("unable to decode token response: %s", err) } - // `access_token` is equivalent to `token` and if both are specified - // the choice is undefined. Canonicalize `access_token` by sticking - // things in `token`. - if tr.AccessToken != "" { - tr.Token = tr.AccessToken - } - - if tr.Token == "" { - return nil, errors.New("authorization server did not include a token in the response") + if tr.RefreshToken != "" && tr.RefreshToken != refreshToken { + th.creds.SetRefreshToken(realm, service, tr.RefreshToken) } if tr.ExpiresIn < minimumTokenLifetimeSeconds { @@ -296,10 +324,128 @@ func (th *tokenHandler) fetchToken(params map[string]string) (token *tokenRespon if tr.IssuedAt.IsZero() { // issued_at is optional in the token response. - tr.IssuedAt = th.clock.Now() + tr.IssuedAt = th.clock.Now().UTC() } - return tr, nil + return tr.AccessToken, tr.IssuedAt.Add(time.Duration(tr.ExpiresIn) * time.Second), nil +} + +type getTokenResponse struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + IssuedAt time.Time `json:"issued_at"` + RefreshToken string `json:"refresh_token"` +} + +func (th *tokenHandler) fetchTokenWithBasicAuth(realm *url.URL, service string, scopes []string) (token string, expiration time.Time, err error) { + + req, err := http.NewRequest("GET", realm.String(), nil) + if err != nil { + return "", time.Time{}, err + } + + reqParams := req.URL.Query() + + if service != "" { + reqParams.Add("service", service) + } + + for _, scope := range scopes { + reqParams.Add("scope", scope) + } + + if th.offlineAccess { + reqParams.Add("offline_token", "true") + clientID := th.clientID + if clientID == "" { + clientID = defaultClientID + } + reqParams.Add("client_id", clientID) + } + + if th.creds != nil { + username, password := th.creds.Basic(realm) + if username != "" && password != "" { + reqParams.Add("account", username) + req.SetBasicAuth(username, password) + } + } + + req.URL.RawQuery = reqParams.Encode() + + resp, err := th.client().Do(req) + if err != nil { + return "", time.Time{}, err + } + defer resp.Body.Close() + + if !client.SuccessStatus(resp.StatusCode) { + err := client.HandleErrorResponse(resp) + return "", time.Time{}, err + } + + decoder := json.NewDecoder(resp.Body) + + var tr getTokenResponse + if err = decoder.Decode(&tr); err != nil { + return "", time.Time{}, fmt.Errorf("unable to decode token response: %s", err) + } + + if tr.RefreshToken != "" && th.creds != nil { + th.creds.SetRefreshToken(realm, service, tr.RefreshToken) + } + + // `access_token` is equivalent to `token` and if both are specified + // the choice is undefined. Canonicalize `access_token` by sticking + // things in `token`. + if tr.AccessToken != "" { + tr.Token = tr.AccessToken + } + + if tr.Token == "" { + return "", time.Time{}, errors.New("authorization server did not include a token in the response") + } + + if tr.ExpiresIn < minimumTokenLifetimeSeconds { + // The default/minimum lifetime. + tr.ExpiresIn = minimumTokenLifetimeSeconds + logrus.Debugf("Increasing token expiration to: %d seconds", tr.ExpiresIn) + } + + if tr.IssuedAt.IsZero() { + // issued_at is optional in the token response. + tr.IssuedAt = th.clock.Now().UTC() + } + + return tr.Token, tr.IssuedAt.Add(time.Duration(tr.ExpiresIn) * time.Second), nil +} + +func (th *tokenHandler) fetchToken(params map[string]string, scopes []string) (token string, expiration time.Time, err error) { + realm, ok := params["realm"] + if !ok { + return "", time.Time{}, errors.New("no realm specified for token auth challenge") + } + + // TODO(dmcgowan): Handle empty scheme and relative realm + realmURL, err := url.Parse(realm) + if err != nil { + return "", time.Time{}, fmt.Errorf("invalid token auth challenge realm: %s", err) + } + + service := params["service"] + + var refreshToken string + + if th.creds != nil { + refreshToken = th.creds.RefreshToken(realmURL, service) + } + + if refreshToken != "" || th.forceOAuth { + return th.fetchTokenWithOAuth(realmURL, refreshToken, service, scopes) + } + + return th.fetchTokenWithBasicAuth(realmURL, service, scopes) } type basicHandler struct { diff --git a/vendor/src/github.com/docker/distribution/registry/client/repository.go b/vendor/src/github.com/docker/distribution/registry/client/repository.go index ebf44d4733..830749f1b3 100644 --- a/vendor/src/github.com/docker/distribution/registry/client/repository.go +++ b/vendor/src/github.com/docker/distribution/registry/client/repository.go @@ -292,9 +292,18 @@ func (t *tags) Get(ctx context.Context, tag string) (distribution.Descriptor, er if err != nil { return distribution.Descriptor{}, err } - var attempts int - resp, err := t.client.Head(u) + req, err := http.NewRequest("HEAD", u, nil) + if err != nil { + return distribution.Descriptor{}, err + } + + for _, t := range distribution.ManifestMediaTypes() { + req.Header.Add("Accept", t) + } + + var attempts int + resp, err := t.client.Do(req) check: if err != nil { return distribution.Descriptor{}, err @@ -304,7 +313,16 @@ check: case resp.StatusCode >= 200 && resp.StatusCode < 400: return descriptorFromResponse(resp) case resp.StatusCode == http.StatusMethodNotAllowed: - resp, err = t.client.Get(u) + req, err = http.NewRequest("GET", u, nil) + if err != nil { + return distribution.Descriptor{}, err + } + + for _, t := range distribution.ManifestMediaTypes() { + req.Header.Add("Accept", t) + } + + resp, err = t.client.Do(req) attempts++ if attempts > 1 { return distribution.Descriptor{}, err diff --git a/vendor/src/github.com/docker/distribution/registry/client/transport/http_reader.go b/vendor/src/github.com/docker/distribution/registry/client/transport/http_reader.go index 22b0b9d69a..e1b17a03a0 100644 --- a/vendor/src/github.com/docker/distribution/registry/client/transport/http_reader.go +++ b/vendor/src/github.com/docker/distribution/registry/client/transport/http_reader.go @@ -66,7 +66,7 @@ func (hrs *httpReadSeeker) Read(p []byte) (n int, err error) { return 0, hrs.err } - // If we seeked to a different position, we need to reset the + // If we sought to a different position, we need to reset the // connection. This logic is here instead of Seek so that if // a seek is undone before the next read, the connection doesn't // need to be closed and reopened. A common example of this is