OAuth support for registries
| ... | ... |
@@ -93,8 +93,8 @@ func (cli *DockerCli) CmdInfo(args ...string) error {
|
| 93 | 93 |
u := cli.configFile.AuthConfigs[info.IndexServerAddress].Username |
| 94 | 94 |
if len(u) > 0 {
|
| 95 | 95 |
fmt.Fprintf(cli.out, "Username: %v\n", u) |
| 96 |
- fmt.Fprintf(cli.out, "Registry: %v\n", info.IndexServerAddress) |
|
| 97 | 96 |
} |
| 97 |
+ fmt.Fprintf(cli.out, "Registry: %v\n", info.IndexServerAddress) |
|
| 98 | 98 |
} |
| 99 | 99 |
|
| 100 | 100 |
// Only output these warnings if the server does not support these features |
| ... | ... |
@@ -57,12 +57,16 @@ func (cli *DockerCli) CmdLogin(args ...string) error {
|
| 57 | 57 |
return err |
| 58 | 58 |
} |
| 59 | 59 |
|
| 60 |
+ if response.IdentityToken != "" {
|
|
| 61 |
+ authConfig.Password = "" |
|
| 62 |
+ authConfig.IdentityToken = response.IdentityToken |
|
| 63 |
+ } |
|
| 60 | 64 |
if err := storeCredentials(cli.configFile, authConfig); err != nil {
|
| 61 | 65 |
return fmt.Errorf("Error saving credentials: %v", err)
|
| 62 | 66 |
} |
| 63 | 67 |
|
| 64 | 68 |
if response.Status != "" {
|
| 65 |
- fmt.Fprintf(cli.out, "%s\n", response.Status) |
|
| 69 |
+ fmt.Fprintln(cli.out, response.Status) |
|
| 66 | 70 |
} |
| 67 | 71 |
return nil |
| 68 | 72 |
} |
| ... | ... |
@@ -120,6 +124,7 @@ func (cli *DockerCli) configureAuth(flUser, flPassword, serverAddress string, is |
| 120 | 120 |
authconfig.Username = flUser |
| 121 | 121 |
authconfig.Password = flPassword |
| 122 | 122 |
authconfig.ServerAddress = serverAddress |
| 123 |
+ authconfig.IdentityToken = "" |
|
| 123 | 124 |
|
| 124 | 125 |
return authconfig, nil |
| 125 | 126 |
} |
| ... | ... |
@@ -107,6 +107,13 @@ func (scs simpleCredentialStore) Basic(u *url.URL) (string, string) {
|
| 107 | 107 |
return scs.auth.Username, scs.auth.Password |
| 108 | 108 |
} |
| 109 | 109 |
|
| 110 |
+func (scs simpleCredentialStore) RefreshToken(u *url.URL, service string) string {
|
|
| 111 |
+ return scs.auth.IdentityToken |
|
| 112 |
+} |
|
| 113 |
+ |
|
| 114 |
+func (scs simpleCredentialStore) SetRefreshToken(*url.URL, string, string) {
|
|
| 115 |
+} |
|
| 116 |
+ |
|
| 110 | 117 |
// getNotaryRepository returns a NotaryRepository which stores all the |
| 111 | 118 |
// information needed to operate on a notary repository. |
| 112 | 119 |
// It creates a HTTP transport providing authentication support. |
| ... | ... |
@@ -13,5 +13,5 @@ type Backend interface {
|
| 13 | 13 |
SystemVersion() types.Version |
| 14 | 14 |
SubscribeToEvents(since, sinceNano int64, ef filters.Args) ([]events.Message, chan interface{})
|
| 15 | 15 |
UnsubscribeFromEvents(chan interface{})
|
| 16 |
- AuthenticateToRegistry(authConfig *types.AuthConfig) (string, error) |
|
| 16 |
+ AuthenticateToRegistry(authConfig *types.AuthConfig) (string, string, error) |
|
| 17 | 17 |
} |
| ... | ... |
@@ -115,11 +115,12 @@ func (s *systemRouter) postAuth(ctx context.Context, w http.ResponseWriter, r *h |
| 115 | 115 |
if err != nil {
|
| 116 | 116 |
return err |
| 117 | 117 |
} |
| 118 |
- status, err := s.backend.AuthenticateToRegistry(config) |
|
| 118 |
+ status, token, err := s.backend.AuthenticateToRegistry(config) |
|
| 119 | 119 |
if err != nil {
|
| 120 | 120 |
return err |
| 121 | 121 |
} |
| 122 | 122 |
return httputils.WriteJSON(w, http.StatusOK, &types.AuthResponse{
|
| 123 |
- Status: status, |
|
| 123 |
+ Status: status, |
|
| 124 |
+ IdentityToken: token, |
|
| 124 | 125 |
}) |
| 125 | 126 |
} |
| ... | ... |
@@ -13,7 +13,10 @@ import ( |
| 13 | 13 |
"github.com/docker/engine-api/types" |
| 14 | 14 |
) |
| 15 | 15 |
|
| 16 |
-const remoteCredentialsPrefix = "docker-credential-" |
|
| 16 |
+const ( |
|
| 17 |
+ remoteCredentialsPrefix = "docker-credential-" |
|
| 18 |
+ tokenUsername = "<token>" |
|
| 19 |
+) |
|
| 17 | 20 |
|
| 18 | 21 |
// Standarize the not found error, so every helper returns |
| 19 | 22 |
// the same message and docker can handle it properly. |
| ... | ... |
@@ -29,14 +32,14 @@ type command interface {
|
| 29 | 29 |
type credentialsRequest struct {
|
| 30 | 30 |
ServerURL string |
| 31 | 31 |
Username string |
| 32 |
- Password string |
|
| 32 |
+ Secret string |
|
| 33 | 33 |
} |
| 34 | 34 |
|
| 35 | 35 |
// credentialsGetResponse is the information serialized from a remote store |
| 36 | 36 |
// when the plugin sends requests to get the user credentials. |
| 37 | 37 |
type credentialsGetResponse struct {
|
| 38 | 38 |
Username string |
| 39 |
- Password string |
|
| 39 |
+ Secret string |
|
| 40 | 40 |
} |
| 41 | 41 |
|
| 42 | 42 |
// nativeStore implements a credentials store |
| ... | ... |
@@ -76,6 +79,7 @@ func (c *nativeStore) Get(serverAddress string) (types.AuthConfig, error) {
|
| 76 | 76 |
return auth, err |
| 77 | 77 |
} |
| 78 | 78 |
auth.Username = creds.Username |
| 79 |
+ auth.IdentityToken = creds.IdentityToken |
|
| 79 | 80 |
auth.Password = creds.Password |
| 80 | 81 |
|
| 81 | 82 |
return auth, nil |
| ... | ... |
@@ -89,6 +93,7 @@ func (c *nativeStore) GetAll() (map[string]types.AuthConfig, error) {
|
| 89 | 89 |
creds, _ := c.getCredentialsFromStore(s) |
| 90 | 90 |
ac.Username = creds.Username |
| 91 | 91 |
ac.Password = creds.Password |
| 92 |
+ ac.IdentityToken = creds.IdentityToken |
|
| 92 | 93 |
auths[s] = ac |
| 93 | 94 |
} |
| 94 | 95 |
|
| ... | ... |
@@ -102,6 +107,7 @@ func (c *nativeStore) Store(authConfig types.AuthConfig) error {
|
| 102 | 102 |
} |
| 103 | 103 |
authConfig.Username = "" |
| 104 | 104 |
authConfig.Password = "" |
| 105 |
+ authConfig.IdentityToken = "" |
|
| 105 | 106 |
|
| 106 | 107 |
// Fallback to old credential in plain text to save only the email |
| 107 | 108 |
return c.fileStore.Store(authConfig) |
| ... | ... |
@@ -113,7 +119,12 @@ func (c *nativeStore) storeCredentialsInStore(config types.AuthConfig) error {
|
| 113 | 113 |
creds := &credentialsRequest{
|
| 114 | 114 |
ServerURL: config.ServerAddress, |
| 115 | 115 |
Username: config.Username, |
| 116 |
- Password: config.Password, |
|
| 116 |
+ Secret: config.Password, |
|
| 117 |
+ } |
|
| 118 |
+ |
|
| 119 |
+ if config.IdentityToken != "" {
|
|
| 120 |
+ creds.Username = tokenUsername |
|
| 121 |
+ creds.Secret = config.IdentityToken |
|
| 117 | 122 |
} |
| 118 | 123 |
|
| 119 | 124 |
buffer := new(bytes.Buffer) |
| ... | ... |
@@ -158,13 +169,18 @@ func (c *nativeStore) getCredentialsFromStore(serverAddress string) (types.AuthC |
| 158 | 158 |
return ret, err |
| 159 | 159 |
} |
| 160 | 160 |
|
| 161 |
- ret.Username = resp.Username |
|
| 162 |
- ret.Password = resp.Password |
|
| 161 |
+ if resp.Username == tokenUsername {
|
|
| 162 |
+ ret.IdentityToken = resp.Secret |
|
| 163 |
+ } else {
|
|
| 164 |
+ ret.Password = resp.Secret |
|
| 165 |
+ ret.Username = resp.Username |
|
| 166 |
+ } |
|
| 167 |
+ |
|
| 163 | 168 |
ret.ServerAddress = serverAddress |
| 164 | 169 |
return ret, nil |
| 165 | 170 |
} |
| 166 | 171 |
|
| 167 |
-// eraseCredentialsFromStore executes the command to remove the server redentails from the native store. |
|
| 172 |
+// eraseCredentialsFromStore executes the command to remove the server credentails from the native store. |
|
| 168 | 173 |
func (c *nativeStore) eraseCredentialsFromStore(serverURL string) error {
|
| 169 | 174 |
cmd := c.commandFn("erase")
|
| 170 | 175 |
cmd.Input(strings.NewReader(serverURL)) |
| ... | ... |
@@ -47,8 +47,10 @@ func (m *mockCommand) Output() ([]byte, error) {
|
| 47 | 47 |
} |
| 48 | 48 |
case "get": |
| 49 | 49 |
switch inS {
|
| 50 |
- case validServerAddress, validServerAddress2: |
|
| 51 |
- return []byte(`{"Username": "foo", "Password": "bar"}`), nil
|
|
| 50 |
+ case validServerAddress: |
|
| 51 |
+ return []byte(`{"Username": "foo", "Secret": "bar"}`), nil
|
|
| 52 |
+ case validServerAddress2: |
|
| 53 |
+ return []byte(`{"Username": "<token>", "Secret": "abcd1234"}`), nil
|
|
| 52 | 54 |
case missingCredsAddress: |
| 53 | 55 |
return []byte(errCredentialsNotFound.Error()), errCommandExited |
| 54 | 56 |
case invalidServerAddress: |
| ... | ... |
@@ -118,6 +120,9 @@ func TestNativeStoreAddCredentials(t *testing.T) {
|
| 118 | 118 |
if a.Password != "" {
|
| 119 | 119 |
t.Fatalf("expected password to be empty, got %s", a.Password)
|
| 120 | 120 |
} |
| 121 |
+ if a.IdentityToken != "" {
|
|
| 122 |
+ t.Fatalf("expected identity token to be empty, got %s", a.IdentityToken)
|
|
| 123 |
+ } |
|
| 121 | 124 |
if a.Email != "foo@example.com" {
|
| 122 | 125 |
t.Fatalf("expected email `foo@example.com`, got %s", a.Email)
|
| 123 | 126 |
} |
| ... | ... |
@@ -174,11 +179,45 @@ func TestNativeStoreGet(t *testing.T) {
|
| 174 | 174 |
if a.Password != "bar" {
|
| 175 | 175 |
t.Fatalf("expected password `bar`, got %s", a.Password)
|
| 176 | 176 |
} |
| 177 |
+ if a.IdentityToken != "" {
|
|
| 178 |
+ t.Fatalf("expected identity token to be empty, got %s", a.IdentityToken)
|
|
| 179 |
+ } |
|
| 177 | 180 |
if a.Email != "foo@example.com" {
|
| 178 | 181 |
t.Fatalf("expected email `foo@example.com`, got %s", a.Email)
|
| 179 | 182 |
} |
| 180 | 183 |
} |
| 181 | 184 |
|
| 185 |
+func TestNativeStoreGetIdentityToken(t *testing.T) {
|
|
| 186 |
+ f := newConfigFile(map[string]types.AuthConfig{
|
|
| 187 |
+ validServerAddress2: {
|
|
| 188 |
+ Email: "foo@example2.com", |
|
| 189 |
+ }, |
|
| 190 |
+ }) |
|
| 191 |
+ f.CredentialsStore = "mock" |
|
| 192 |
+ |
|
| 193 |
+ s := &nativeStore{
|
|
| 194 |
+ commandFn: mockCommandFn, |
|
| 195 |
+ fileStore: NewFileStore(f), |
|
| 196 |
+ } |
|
| 197 |
+ a, err := s.Get(validServerAddress2) |
|
| 198 |
+ if err != nil {
|
|
| 199 |
+ t.Fatal(err) |
|
| 200 |
+ } |
|
| 201 |
+ |
|
| 202 |
+ if a.Username != "" {
|
|
| 203 |
+ t.Fatalf("expected username to be empty, got %s", a.Username)
|
|
| 204 |
+ } |
|
| 205 |
+ if a.Password != "" {
|
|
| 206 |
+ t.Fatalf("expected password to be empty, got %s", a.Password)
|
|
| 207 |
+ } |
|
| 208 |
+ if a.IdentityToken != "abcd1234" {
|
|
| 209 |
+ t.Fatalf("expected identity token `abcd1234`, got %s", a.IdentityToken)
|
|
| 210 |
+ } |
|
| 211 |
+ if a.Email != "foo@example2.com" {
|
|
| 212 |
+ t.Fatalf("expected email `foo@example2.com`, got %s", a.Email)
|
|
| 213 |
+ } |
|
| 214 |
+} |
|
| 215 |
+ |
|
| 182 | 216 |
func TestNativeStoreGetAll(t *testing.T) {
|
| 183 | 217 |
f := newConfigFile(map[string]types.AuthConfig{
|
| 184 | 218 |
validServerAddress: {
|
| ... | ... |
@@ -209,14 +248,20 @@ func TestNativeStoreGetAll(t *testing.T) {
|
| 209 | 209 |
if as[validServerAddress].Password != "bar" {
|
| 210 | 210 |
t.Fatalf("expected password `bar` for %s, got %s", validServerAddress, as[validServerAddress].Password)
|
| 211 | 211 |
} |
| 212 |
+ if as[validServerAddress].IdentityToken != "" {
|
|
| 213 |
+ t.Fatalf("expected identity to be empty for %s, got %s", validServerAddress, as[validServerAddress].IdentityToken)
|
|
| 214 |
+ } |
|
| 212 | 215 |
if as[validServerAddress].Email != "foo@example.com" {
|
| 213 | 216 |
t.Fatalf("expected email `foo@example.com` for %s, got %s", validServerAddress, as[validServerAddress].Email)
|
| 214 | 217 |
} |
| 215 |
- if as[validServerAddress2].Username != "foo" {
|
|
| 216 |
- t.Fatalf("expected username `foo` for %s, got %s", validServerAddress2, as[validServerAddress2].Username)
|
|
| 218 |
+ if as[validServerAddress2].Username != "" {
|
|
| 219 |
+ t.Fatalf("expected username to be empty for %s, got %s", validServerAddress2, as[validServerAddress2].Username)
|
|
| 220 |
+ } |
|
| 221 |
+ if as[validServerAddress2].Password != "" {
|
|
| 222 |
+ t.Fatalf("expected password to be empty for %s, got %s", validServerAddress2, as[validServerAddress2].Password)
|
|
| 217 | 223 |
} |
| 218 |
- if as[validServerAddress2].Password != "bar" {
|
|
| 219 |
- t.Fatalf("expected password `bar` for %s, got %s", validServerAddress2, as[validServerAddress2].Password)
|
|
| 224 |
+ if as[validServerAddress2].IdentityToken != "abcd1234" {
|
|
| 225 |
+ t.Fatalf("expected identity token `abcd1324` for %s, got %s", validServerAddress2, as[validServerAddress2].IdentityToken)
|
|
| 220 | 226 |
} |
| 221 | 227 |
if as[validServerAddress2].Email != "foo@example2.com" {
|
| 222 | 228 |
t.Fatalf("expected email `foo@example2.com` for %s, got %s", validServerAddress2, as[validServerAddress2].Email)
|
| ... | ... |
@@ -1519,7 +1519,7 @@ func configureVolumes(config *Config, rootUID, rootGID int) (*store.VolumeStore, |
| 1519 | 1519 |
} |
| 1520 | 1520 |
|
| 1521 | 1521 |
// AuthenticateToRegistry checks the validity of credentials in authConfig |
| 1522 |
-func (daemon *Daemon) AuthenticateToRegistry(authConfig *types.AuthConfig) (string, error) {
|
|
| 1522 |
+func (daemon *Daemon) AuthenticateToRegistry(authConfig *types.AuthConfig) (string, string, error) {
|
|
| 1523 | 1523 |
return daemon.RegistryService.Auth(authConfig, dockerversion.DockerUserAgent()) |
| 1524 | 1524 |
} |
| 1525 | 1525 |
|
| ... | ... |
@@ -26,6 +26,13 @@ func (dcs dumbCredentialStore) Basic(*url.URL) (string, string) {
|
| 26 | 26 |
return dcs.auth.Username, dcs.auth.Password |
| 27 | 27 |
} |
| 28 | 28 |
|
| 29 |
+func (dcs dumbCredentialStore) RefreshToken(*url.URL, string) string {
|
|
| 30 |
+ return dcs.auth.IdentityToken |
|
| 31 |
+} |
|
| 32 |
+ |
|
| 33 |
+func (dcs dumbCredentialStore) SetRefreshToken(*url.URL, string, string) {
|
|
| 34 |
+} |
|
| 35 |
+ |
|
| 29 | 36 |
// NewV2Repository returns a repository (v2 only). It creates a HTTP transport |
| 30 | 37 |
// providing timeout settings and authentication support, and also verifies the |
| 31 | 38 |
// remote API version. |
| ... | ... |
@@ -72,7 +79,18 @@ func NewV2Repository(ctx context.Context, repoInfo *registry.RepositoryInfo, end |
| 72 | 72 |
modifiers = append(modifiers, auth.NewAuthorizer(challengeManager, passThruTokenHandler)) |
| 73 | 73 |
} else {
|
| 74 | 74 |
creds := dumbCredentialStore{auth: authConfig}
|
| 75 |
- tokenHandler := auth.NewTokenHandler(authTransport, creds, repoName, actions...) |
|
| 75 |
+ tokenHandlerOptions := auth.TokenHandlerOptions{
|
|
| 76 |
+ Transport: authTransport, |
|
| 77 |
+ Credentials: creds, |
|
| 78 |
+ Scopes: []auth.Scope{
|
|
| 79 |
+ auth.RepositoryScope{
|
|
| 80 |
+ Repository: repoName, |
|
| 81 |
+ Actions: actions, |
|
| 82 |
+ }, |
|
| 83 |
+ }, |
|
| 84 |
+ ClientID: registry.AuthClientID, |
|
| 85 |
+ } |
|
| 86 |
+ tokenHandler := auth.NewTokenHandlerWithOptions(tokenHandlerOptions) |
|
| 76 | 87 |
basicHandler := auth.NewBasicHandler(creds) |
| 77 | 88 |
modifiers = append(modifiers, auth.NewAuthorizer(challengeManager, tokenHandler, basicHandler)) |
| 78 | 89 |
} |
| ... | ... |
@@ -125,6 +125,7 @@ This section lists each version from latest to oldest. Each listing includes a |
| 125 | 125 |
* `GET /info` now returns `KernelMemory` field, showing if "kernel memory limit" is supported. |
| 126 | 126 |
* `POST /containers/create` now takes `PidsLimit` field, if the kernel is >= 4.3 and the pids cgroup is supported. |
| 127 | 127 |
* `GET /containers/(id or name)/stats` now returns `pids_stats`, if the kernel is >= 4.3 and the pids cgroup is supported. |
| 128 |
+* `POST /auth` now returns an `IdentityToken` when supported by a registry. |
|
| 128 | 129 |
|
| 129 | 130 |
### v1.22 API changes |
| 130 | 131 |
|
| ... | ... |
@@ -1985,11 +1985,11 @@ Request Headers: |
| 1985 | 1985 |
} |
| 1986 | 1986 |
``` |
| 1987 | 1987 |
|
| 1988 |
- - Token based login: |
|
| 1988 |
+ - Identity token based login: |
|
| 1989 | 1989 |
|
| 1990 | 1990 |
``` |
| 1991 | 1991 |
{
|
| 1992 |
- "registrytoken": "9cbaf023786cd7..." |
|
| 1992 |
+ "identitytoken": "9cbaf023786cd7..." |
|
| 1993 | 1993 |
} |
| 1994 | 1994 |
``` |
| 1995 | 1995 |
|
| ... | ... |
@@ -2119,7 +2119,8 @@ Status Codes: |
| 2119 | 2119 |
|
| 2120 | 2120 |
`POST /auth` |
| 2121 | 2121 |
|
| 2122 |
-Get the default username and email |
|
| 2122 |
+Validate credentials for a registry and get identity token, |
|
| 2123 |
+if available, for accessing the registry without password. |
|
| 2123 | 2124 |
|
| 2124 | 2125 |
**Example request**: |
| 2125 | 2126 |
|
| ... | ... |
@@ -2127,9 +2128,8 @@ Get the default username and email |
| 2127 | 2127 |
Content-Type: application/json |
| 2128 | 2128 |
|
| 2129 | 2129 |
{
|
| 2130 |
- "username":" hannibal", |
|
| 2131 |
- "password: "xxxx", |
|
| 2132 |
- "email": "hannibal@a-team.com", |
|
| 2130 |
+ "username": "hannibal", |
|
| 2131 |
+ "password": "xxxx", |
|
| 2133 | 2132 |
"serveraddress": "https://index.docker.io/v1/" |
| 2134 | 2133 |
} |
| 2135 | 2134 |
|
| ... | ... |
@@ -2137,6 +2137,11 @@ Get the default username and email |
| 2137 | 2137 |
|
| 2138 | 2138 |
HTTP/1.1 200 OK |
| 2139 | 2139 |
|
| 2140 |
+ {
|
|
| 2141 |
+ "Status": "Login Succeeded", |
|
| 2142 |
+ "IdentityToken": "9cbaf023786cd7..." |
|
| 2143 |
+ } |
|
| 2144 |
+ |
|
| 2140 | 2145 |
Status Codes: |
| 2141 | 2146 |
|
| 2142 | 2147 |
- **200** – no error |
| ... | ... |
@@ -78,17 +78,20 @@ The helpers always use the first argument in the command to identify the action. |
| 78 | 78 |
There are only three possible values for that argument: `store`, `get`, and `erase`. |
| 79 | 79 |
|
| 80 | 80 |
The `store` command takes a JSON payload from the standard input. That payload carries |
| 81 |
-the server address, to identify the credential, the user name and the password. |
|
| 82 |
-This is an example of that payload: |
|
| 81 |
+the server address, to identify the credential, the user name, and either a password |
|
| 82 |
+or an identity token. |
|
| 83 | 83 |
|
| 84 | 84 |
```json |
| 85 | 85 |
{
|
| 86 | 86 |
"ServerURL": "https://index.docker.io/v1", |
| 87 | 87 |
"Username": "david", |
| 88 |
- "Password": "passw0rd1" |
|
| 88 |
+ "Secret": "passw0rd1" |
|
| 89 | 89 |
} |
| 90 | 90 |
``` |
| 91 | 91 |
|
| 92 |
+If the secret being stored is an identity token, the Username should be set to |
|
| 93 |
+`<token>`. |
|
| 94 |
+ |
|
| 92 | 95 |
The `store` command can write error messages to `STDOUT` that the docker engine |
| 93 | 96 |
will show if there was an issue. |
| 94 | 97 |
|
| ... | ... |
@@ -102,7 +105,7 @@ and password from this payload: |
| 102 | 102 |
```json |
| 103 | 103 |
{
|
| 104 | 104 |
"Username": "david", |
| 105 |
- "Password": "passw0rd1" |
|
| 105 |
+ "Secret": "passw0rd1" |
|
| 106 | 106 |
} |
| 107 | 107 |
``` |
| 108 | 108 |
|
| ... | ... |
@@ -48,7 +48,7 @@ clone git github.com/boltdb/bolt v1.1.0 |
| 48 | 48 |
clone git github.com/miekg/dns 75e6e86cc601825c5dbcd4e0c209eab180997cd7 |
| 49 | 49 |
|
| 50 | 50 |
# get graph and distribution packages |
| 51 |
-clone git github.com/docker/distribution 7b66c50bb7e0e4b3b83f8fd134a9f6ea4be08b57 |
|
| 51 |
+clone git github.com/docker/distribution db17a23b961978730892e12a0c6051d43a31aab3 |
|
| 52 | 52 |
clone git github.com/vbatts/tar-split v0.9.11 |
| 53 | 53 |
|
| 54 | 54 |
# get desired notary commit, might also need to be updated in Dockerfile |
| ... | ... |
@@ -8,8 +8,8 @@ case $1 in |
| 8 | 8 |
server=$(echo "$in" | jq --raw-output ".ServerURL" | sha1sum - | awk '{print $1}')
|
| 9 | 9 |
|
| 10 | 10 |
username=$(echo "$in" | jq --raw-output ".Username") |
| 11 |
- password=$(echo "$in" | jq --raw-output ".Password") |
|
| 12 |
- echo "{ \"Username\": \"${username}\", \"Password\": \"${password}\" }" > $TEMP/$server
|
|
| 11 |
+ password=$(echo "$in" | jq --raw-output ".Secret") |
|
| 12 |
+ echo "{ \"Username\": \"${username}\", \"Secret\": \"${password}\" }" > $TEMP/$server
|
|
| 13 | 13 |
;; |
| 14 | 14 |
"get") |
| 15 | 15 |
in=$(</dev/stdin) |
| ... | ... |
@@ -15,11 +15,16 @@ import ( |
| 15 | 15 |
registrytypes "github.com/docker/engine-api/types/registry" |
| 16 | 16 |
) |
| 17 | 17 |
|
| 18 |
+const ( |
|
| 19 |
+ // AuthClientID is used the ClientID used for the token server |
|
| 20 |
+ AuthClientID = "docker" |
|
| 21 |
+) |
|
| 22 |
+ |
|
| 18 | 23 |
// loginV1 tries to register/login to the v1 registry server. |
| 19 |
-func loginV1(authConfig *types.AuthConfig, apiEndpoint APIEndpoint, userAgent string) (string, error) {
|
|
| 24 |
+func loginV1(authConfig *types.AuthConfig, apiEndpoint APIEndpoint, userAgent string) (string, string, error) {
|
|
| 20 | 25 |
registryEndpoint, err := apiEndpoint.ToV1Endpoint(userAgent, nil) |
| 21 | 26 |
if err != nil {
|
| 22 |
- return "", err |
|
| 27 |
+ return "", "", err |
|
| 23 | 28 |
} |
| 24 | 29 |
|
| 25 | 30 |
serverAddress := registryEndpoint.String() |
| ... | ... |
@@ -27,48 +32,47 @@ func loginV1(authConfig *types.AuthConfig, apiEndpoint APIEndpoint, userAgent st |
| 27 | 27 |
logrus.Debugf("attempting v1 login to registry endpoint %s", registryEndpoint)
|
| 28 | 28 |
|
| 29 | 29 |
if serverAddress == "" {
|
| 30 |
- return "", fmt.Errorf("Server Error: Server Address not set.")
|
|
| 30 |
+ return "", "", fmt.Errorf("Server Error: Server Address not set.")
|
|
| 31 | 31 |
} |
| 32 | 32 |
|
| 33 | 33 |
loginAgainstOfficialIndex := serverAddress == IndexServer |
| 34 | 34 |
|
| 35 | 35 |
req, err := http.NewRequest("GET", serverAddress+"users/", nil)
|
| 36 | 36 |
if err != nil {
|
| 37 |
- return "", err |
|
| 37 |
+ return "", "", err |
|
| 38 | 38 |
} |
| 39 | 39 |
req.SetBasicAuth(authConfig.Username, authConfig.Password) |
| 40 | 40 |
resp, err := registryEndpoint.client.Do(req) |
| 41 | 41 |
if err != nil {
|
| 42 | 42 |
// fallback when request could not be completed |
| 43 |
- return "", fallbackError{
|
|
| 43 |
+ return "", "", fallbackError{
|
|
| 44 | 44 |
err: err, |
| 45 | 45 |
} |
| 46 | 46 |
} |
| 47 | 47 |
defer resp.Body.Close() |
| 48 | 48 |
body, err := ioutil.ReadAll(resp.Body) |
| 49 | 49 |
if err != nil {
|
| 50 |
- return "", err |
|
| 50 |
+ return "", "", err |
|
| 51 | 51 |
} |
| 52 | 52 |
if resp.StatusCode == http.StatusOK {
|
| 53 |
- return "Login Succeeded", nil |
|
| 53 |
+ return "Login Succeeded", "", nil |
|
| 54 | 54 |
} else if resp.StatusCode == http.StatusUnauthorized {
|
| 55 | 55 |
if loginAgainstOfficialIndex {
|
| 56 |
- return "", fmt.Errorf("Wrong login/password, please try again. Haven't got a Docker ID? Create one at https://hub.docker.com")
|
|
| 56 |
+ return "", "", fmt.Errorf("Wrong login/password, please try again. Haven't got a Docker ID? Create one at https://hub.docker.com")
|
|
| 57 | 57 |
} |
| 58 |
- return "", fmt.Errorf("Wrong login/password, please try again")
|
|
| 58 |
+ return "", "", fmt.Errorf("Wrong login/password, please try again")
|
|
| 59 | 59 |
} else if resp.StatusCode == http.StatusForbidden {
|
| 60 | 60 |
if loginAgainstOfficialIndex {
|
| 61 |
- return "", fmt.Errorf("Login: Account is not active. Please check your e-mail for a confirmation link.")
|
|
| 61 |
+ return "", "", fmt.Errorf("Login: Account is not active. Please check your e-mail for a confirmation link.")
|
|
| 62 | 62 |
} |
| 63 | 63 |
// *TODO: Use registry configuration to determine what this says, if anything? |
| 64 |
- return "", fmt.Errorf("Login: Account is not active. Please see the documentation of the registry %s for instructions how to activate it.", serverAddress)
|
|
| 64 |
+ return "", "", fmt.Errorf("Login: Account is not active. Please see the documentation of the registry %s for instructions how to activate it.", serverAddress)
|
|
| 65 | 65 |
} else if resp.StatusCode == http.StatusInternalServerError { // Issue #14326
|
| 66 | 66 |
logrus.Errorf("%s returned status code %d. Response Body :\n%s", req.URL.String(), resp.StatusCode, body)
|
| 67 |
- return "", fmt.Errorf("Internal Server Error")
|
|
| 68 |
- } else {
|
|
| 69 |
- return "", fmt.Errorf("Login: %s (Code: %d; Headers: %s)", body,
|
|
| 70 |
- resp.StatusCode, resp.Header) |
|
| 67 |
+ return "", "", fmt.Errorf("Internal Server Error")
|
|
| 71 | 68 |
} |
| 69 |
+ return "", "", fmt.Errorf("Login: %s (Code: %d; Headers: %s)", body,
|
|
| 70 |
+ resp.StatusCode, resp.Header) |
|
| 72 | 71 |
} |
| 73 | 72 |
|
| 74 | 73 |
type loginCredentialStore struct {
|
| ... | ... |
@@ -79,6 +83,14 @@ func (lcs loginCredentialStore) Basic(*url.URL) (string, string) {
|
| 79 | 79 |
return lcs.authConfig.Username, lcs.authConfig.Password |
| 80 | 80 |
} |
| 81 | 81 |
|
| 82 |
+func (lcs loginCredentialStore) RefreshToken(*url.URL, string) string {
|
|
| 83 |
+ return lcs.authConfig.IdentityToken |
|
| 84 |
+} |
|
| 85 |
+ |
|
| 86 |
+func (lcs loginCredentialStore) SetRefreshToken(u *url.URL, service, token string) {
|
|
| 87 |
+ lcs.authConfig.IdentityToken = token |
|
| 88 |
+} |
|
| 89 |
+ |
|
| 82 | 90 |
type fallbackError struct {
|
| 83 | 91 |
err error |
| 84 | 92 |
} |
| ... | ... |
@@ -90,7 +102,7 @@ func (err fallbackError) Error() string {
|
| 90 | 90 |
// loginV2 tries to login to the v2 registry server. The given registry |
| 91 | 91 |
// endpoint will be pinged to get authorization challenges. These challenges |
| 92 | 92 |
// will be used to authenticate against the registry to validate credentials. |
| 93 |
-func loginV2(authConfig *types.AuthConfig, endpoint APIEndpoint, userAgent string) (string, error) {
|
|
| 93 |
+func loginV2(authConfig *types.AuthConfig, endpoint APIEndpoint, userAgent string) (string, string, error) {
|
|
| 94 | 94 |
logrus.Debugf("attempting v2 login to registry endpoint %s", endpoint)
|
| 95 | 95 |
|
| 96 | 96 |
modifiers := DockerHeaders(userAgent, nil) |
| ... | ... |
@@ -101,14 +113,21 @@ func loginV2(authConfig *types.AuthConfig, endpoint APIEndpoint, userAgent strin |
| 101 | 101 |
if !foundV2 {
|
| 102 | 102 |
err = fallbackError{err: err}
|
| 103 | 103 |
} |
| 104 |
- return "", err |
|
| 104 |
+ return "", "", err |
|
| 105 | 105 |
} |
| 106 | 106 |
|
| 107 |
+ credentialAuthConfig := *authConfig |
|
| 107 | 108 |
creds := loginCredentialStore{
|
| 108 |
- authConfig: authConfig, |
|
| 109 |
+ authConfig: &credentialAuthConfig, |
|
| 109 | 110 |
} |
| 110 | 111 |
|
| 111 |
- tokenHandler := auth.NewTokenHandler(authTransport, creds, "") |
|
| 112 |
+ tokenHandlerOptions := auth.TokenHandlerOptions{
|
|
| 113 |
+ Transport: authTransport, |
|
| 114 |
+ Credentials: creds, |
|
| 115 |
+ OfflineAccess: true, |
|
| 116 |
+ ClientID: AuthClientID, |
|
| 117 |
+ } |
|
| 118 |
+ tokenHandler := auth.NewTokenHandlerWithOptions(tokenHandlerOptions) |
|
| 112 | 119 |
basicHandler := auth.NewBasicHandler(creds) |
| 113 | 120 |
modifiers = append(modifiers, auth.NewAuthorizer(challengeManager, tokenHandler, basicHandler)) |
| 114 | 121 |
tr := transport.NewTransport(authTransport, modifiers...) |
| ... | ... |
@@ -124,7 +143,7 @@ func loginV2(authConfig *types.AuthConfig, endpoint APIEndpoint, userAgent strin |
| 124 | 124 |
if !foundV2 {
|
| 125 | 125 |
err = fallbackError{err: err}
|
| 126 | 126 |
} |
| 127 |
- return "", err |
|
| 127 |
+ return "", "", err |
|
| 128 | 128 |
} |
| 129 | 129 |
|
| 130 | 130 |
resp, err := loginClient.Do(req) |
| ... | ... |
@@ -132,7 +151,7 @@ func loginV2(authConfig *types.AuthConfig, endpoint APIEndpoint, userAgent strin |
| 132 | 132 |
if !foundV2 {
|
| 133 | 133 |
err = fallbackError{err: err}
|
| 134 | 134 |
} |
| 135 |
- return "", err |
|
| 135 |
+ return "", "", err |
|
| 136 | 136 |
} |
| 137 | 137 |
defer resp.Body.Close() |
| 138 | 138 |
|
| ... | ... |
@@ -142,10 +161,10 @@ func loginV2(authConfig *types.AuthConfig, endpoint APIEndpoint, userAgent strin |
| 142 | 142 |
if !foundV2 {
|
| 143 | 143 |
err = fallbackError{err: err}
|
| 144 | 144 |
} |
| 145 |
- return "", err |
|
| 145 |
+ return "", "", err |
|
| 146 | 146 |
} |
| 147 | 147 |
|
| 148 |
- return "Login Succeeded", nil |
|
| 148 |
+ return "Login Succeeded", credentialAuthConfig.IdentityToken, nil |
|
| 149 | 149 |
|
| 150 | 150 |
} |
| 151 | 151 |
|
| ... | ... |
@@ -2,6 +2,7 @@ package registry |
| 2 | 2 |
|
| 3 | 3 |
import ( |
| 4 | 4 |
"crypto/tls" |
| 5 |
+ "fmt" |
|
| 5 | 6 |
"net/http" |
| 6 | 7 |
"net/url" |
| 7 | 8 |
"strings" |
| ... | ... |
@@ -34,10 +35,19 @@ func (s *Service) ServiceConfig() *registrytypes.ServiceConfig {
|
| 34 | 34 |
// Auth contacts the public registry with the provided credentials, |
| 35 | 35 |
// and returns OK if authentication was successful. |
| 36 | 36 |
// It can be used to verify the validity of a client's credentials. |
| 37 |
-func (s *Service) Auth(authConfig *types.AuthConfig, userAgent string) (status string, err error) {
|
|
| 38 |
- endpoints, err := s.LookupPushEndpoints(authConfig.ServerAddress) |
|
| 37 |
+func (s *Service) Auth(authConfig *types.AuthConfig, userAgent string) (status, token string, err error) {
|
|
| 38 |
+ serverAddress := authConfig.ServerAddress |
|
| 39 |
+ if !strings.HasPrefix(serverAddress, "https://") && !strings.HasPrefix(serverAddress, "http://") {
|
|
| 40 |
+ serverAddress = "https://" + serverAddress |
|
| 41 |
+ } |
|
| 42 |
+ u, err := url.Parse(serverAddress) |
|
| 43 |
+ if err != nil {
|
|
| 44 |
+ return "", "", fmt.Errorf("unable to parse server address: %v", err)
|
|
| 45 |
+ } |
|
| 46 |
+ |
|
| 47 |
+ endpoints, err := s.LookupPushEndpoints(u.Host) |
|
| 39 | 48 |
if err != nil {
|
| 40 |
- return "", err |
|
| 49 |
+ return "", "", err |
|
| 41 | 50 |
} |
| 42 | 51 |
|
| 43 | 52 |
for _, endpoint := range endpoints {
|
| ... | ... |
@@ -46,7 +56,7 @@ func (s *Service) Auth(authConfig *types.AuthConfig, userAgent string) (status s |
| 46 | 46 |
login = loginV1 |
| 47 | 47 |
} |
| 48 | 48 |
|
| 49 |
- status, err = login(authConfig, endpoint, userAgent) |
|
| 49 |
+ status, token, err = login(authConfig, endpoint, userAgent) |
|
| 50 | 50 |
if err == nil {
|
| 51 | 51 |
return |
| 52 | 52 |
} |
| ... | ... |
@@ -55,10 +65,10 @@ func (s *Service) Auth(authConfig *types.AuthConfig, userAgent string) (status s |
| 55 | 55 |
logrus.Infof("Error logging in to %s endpoint, trying next endpoint: %v", endpoint.Version, err)
|
| 56 | 56 |
continue |
| 57 | 57 |
} |
| 58 |
- return "", err |
|
| 58 |
+ return "", "", err |
|
| 59 | 59 |
} |
| 60 | 60 |
|
| 61 |
- return "", err |
|
| 61 |
+ return "", "", err |
|
| 62 | 62 |
} |
| 63 | 63 |
|
| 64 | 64 |
// splitReposSearchTerm breaks a search term into an index name and remote name |
| ... | ... |
@@ -10,7 +10,7 @@ import ( |
| 10 | 10 |
func (s *Service) lookupV2Endpoints(hostname string) (endpoints []APIEndpoint, err error) {
|
| 11 | 11 |
var cfg = tlsconfig.ServerDefault |
| 12 | 12 |
tlsConfig := &cfg |
| 13 |
- if hostname == DefaultNamespace {
|
|
| 13 |
+ if hostname == DefaultNamespace || hostname == DefaultV1Registry.Host {
|
|
| 14 | 14 |
// v2 mirrors |
| 15 | 15 |
for _, mirror := range s.config.Mirrors {
|
| 16 | 16 |
if !strings.HasPrefix(mirror, "http://") && !strings.HasPrefix(mirror, "https://") {
|
| ... | ... |
@@ -90,7 +90,7 @@ It's mandatory to: |
| 90 | 90 |
|
| 91 | 91 |
Complying to these simple rules will greatly accelerate the review process, and will ensure you have a pleasant experience in contributing code to the Registry. |
| 92 | 92 |
|
| 93 |
-Have a look at a great, succesful contribution: the [Ceph driver PR](https://github.com/docker/distribution/pull/443) |
|
| 93 |
+Have a look at a great, successful contribution: the [Ceph driver PR](https://github.com/docker/distribution/pull/443) |
|
| 94 | 94 |
|
| 95 | 95 |
## Coding Style |
| 96 | 96 |
|
| ... | ... |
@@ -14,8 +14,8 @@ endif |
| 14 | 14 |
GO_LDFLAGS=-ldflags "-X `go list ./version`.Version=$(VERSION)" |
| 15 | 15 |
|
| 16 | 16 |
.PHONY: clean all fmt vet lint build test binaries |
| 17 |
-.DEFAULT: default |
|
| 18 |
-all: AUTHORS clean fmt vet fmt lint build test binaries |
|
| 17 |
+.DEFAULT: all |
|
| 18 |
+all: fmt vet fmt lint build test binaries |
|
| 19 | 19 |
|
| 20 | 20 |
AUTHORS: .mailmap .git/HEAD |
| 21 | 21 |
git log --format='%aN <%aE>' | sort -fu > $@ |
| ... | ... |
@@ -97,6 +97,11 @@ type BlobDeleter interface {
|
| 97 | 97 |
Delete(ctx context.Context, dgst digest.Digest) error |
| 98 | 98 |
} |
| 99 | 99 |
|
| 100 |
+// BlobEnumerator enables iterating over blobs from storage |
|
| 101 |
+type BlobEnumerator interface {
|
|
| 102 |
+ Enumerate(ctx context.Context, ingester func(dgst digest.Digest) error) error |
|
| 103 |
+} |
|
| 104 |
+ |
|
| 100 | 105 |
// BlobDescriptorService manages metadata about a blob by digest. Most |
| 101 | 106 |
// implementations will not expose such an interface explicitly. Such mappings |
| 102 | 107 |
// should be maintained by interacting with the BlobIngester. Hence, this is |
| ... | ... |
@@ -8,6 +8,10 @@ import ( |
| 8 | 8 |
"github.com/docker/distribution/digest" |
| 9 | 9 |
) |
| 10 | 10 |
|
| 11 |
+// ErrAccessDenied is returned when an access to a requested resource is |
|
| 12 |
+// denied. |
|
| 13 |
+var ErrAccessDenied = errors.New("access denied")
|
|
| 14 |
+ |
|
| 11 | 15 |
// ErrManifestNotModified is returned when a conditional manifest GetByTag |
| 12 | 16 |
// returns nil due to the client indicating it has the latest version |
| 13 | 17 |
var ErrManifestNotModified = errors.New("manifest not modified")
|
| ... | ... |
@@ -53,12 +53,18 @@ type ManifestService interface {
|
| 53 | 53 |
// Delete removes the manifest specified by the given digest. Deleting |
| 54 | 54 |
// a manifest that doesn't exist will return ErrManifestNotFound |
| 55 | 55 |
Delete(ctx context.Context, dgst digest.Digest) error |
| 56 |
+} |
|
| 57 |
+ |
|
| 58 |
+// ManifestEnumerator enables iterating over manifests |
|
| 59 |
+type ManifestEnumerator interface {
|
|
| 60 |
+ // Enumerate calls ingester for each manifest. |
|
| 61 |
+ Enumerate(ctx context.Context, ingester func(digest.Digest) error) error |
|
| 62 |
+} |
|
| 56 | 63 |
|
| 57 |
- // Enumerate fills 'manifests' with the manifests in this service up |
|
| 58 |
- // to the size of 'manifests' and returns 'n' for the number of entries |
|
| 59 |
- // which were filled. 'last' contains an offset in the manifest set |
|
| 60 |
- // and can be used to resume iteration. |
|
| 61 |
- //Enumerate(ctx context.Context, manifests []Manifest, last Manifest) (n int, err error) |
|
| 64 |
+// SignaturesGetter provides an interface for getting the signatures of a schema1 manifest. If the digest |
|
| 65 |
+// referred to is not a schema1 manifest, an error should be returned. |
|
| 66 |
+type SignaturesGetter interface {
|
|
| 67 |
+ GetSignatures(ctx context.Context, manifestDigest digest.Digest) ([]digest.Digest, error) |
|
| 62 | 68 |
} |
| 63 | 69 |
|
| 64 | 70 |
// Describable is an interface for descriptors |
| ... | ... |
@@ -3,7 +3,7 @@ |
| 3 | 3 |
// |
| 4 | 4 |
// Grammar |
| 5 | 5 |
// |
| 6 |
-// reference := repository [ ":" tag ] [ "@" digest ] |
|
| 6 |
+// reference := name [ ":" tag ] [ "@" digest ] |
|
| 7 | 7 |
// name := [hostname '/'] component ['/' component]* |
| 8 | 8 |
// hostname := hostcomponent ['.' hostcomponent]* [':' port-number] |
| 9 | 9 |
// hostcomponent := /([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/ |
| ... | ... |
@@ -40,6 +40,17 @@ type Namespace interface {
|
| 40 | 40 |
// which were filled. 'last' contains an offset in the catalog, and 'err' will be |
| 41 | 41 |
// set to io.EOF if there are no more entries to obtain. |
| 42 | 42 |
Repositories(ctx context.Context, repos []string, last string) (n int, err error) |
| 43 |
+ |
|
| 44 |
+ // Blobs returns a blob enumerator to access all blobs |
|
| 45 |
+ Blobs() BlobEnumerator |
|
| 46 |
+ |
|
| 47 |
+ // BlobStatter returns a BlobStatter to control |
|
| 48 |
+ BlobStatter() BlobStatter |
|
| 49 |
+} |
|
| 50 |
+ |
|
| 51 |
+// RepositoryEnumerator describes an operation to enumerate repositories |
|
| 52 |
+type RepositoryEnumerator interface {
|
|
| 53 |
+ Enumerate(ctx context.Context, ingester func(string) error) error |
|
| 43 | 54 |
} |
| 44 | 55 |
|
| 45 | 56 |
// ManifestServiceOption is a function argument for Manifest Service methods |
| ... | ... |
@@ -514,7 +514,7 @@ var routeDescriptors = []RouteDescriptor{
|
| 514 | 514 |
digestHeader, |
| 515 | 515 |
}, |
| 516 | 516 |
Body: BodyDescriptor{
|
| 517 |
- ContentType: "application/json; charset=utf-8", |
|
| 517 |
+ ContentType: "<media type of manifest>", |
|
| 518 | 518 |
Format: manifestBody, |
| 519 | 519 |
}, |
| 520 | 520 |
}, |
| ... | ... |
@@ -553,7 +553,7 @@ var routeDescriptors = []RouteDescriptor{
|
| 553 | 553 |
referenceParameterDescriptor, |
| 554 | 554 |
}, |
| 555 | 555 |
Body: BodyDescriptor{
|
| 556 |
- ContentType: "application/json; charset=utf-8", |
|
| 556 |
+ ContentType: "<media type of manifest>", |
|
| 557 | 557 |
Format: manifestBody, |
| 558 | 558 |
}, |
| 559 | 559 |
Successes: []ResponseDescriptor{
|
| ... | ... |
@@ -19,6 +19,8 @@ import ( |
| 19 | 19 |
// basic auth due to lack of credentials. |
| 20 | 20 |
var ErrNoBasicAuthCredentials = errors.New("no basic auth credentials")
|
| 21 | 21 |
|
| 22 |
+const defaultClientID = "registry-client" |
|
| 23 |
+ |
|
| 22 | 24 |
// AuthenticationHandler is an interface for authorizing a request from |
| 23 | 25 |
// params from a "WWW-Authenicate" header for a single scheme. |
| 24 | 26 |
type AuthenticationHandler interface {
|
| ... | ... |
@@ -36,6 +38,14 @@ type AuthenticationHandler interface {
|
| 36 | 36 |
type CredentialStore interface {
|
| 37 | 37 |
// Basic returns basic auth for the given URL |
| 38 | 38 |
Basic(*url.URL) (string, string) |
| 39 |
+ |
|
| 40 |
+ // RefreshToken returns a refresh token for the |
|
| 41 |
+ // given URL and service |
|
| 42 |
+ RefreshToken(*url.URL, string) string |
|
| 43 |
+ |
|
| 44 |
+ // SetRefreshToken sets the refresh token if none |
|
| 45 |
+ // is provided for the given url and service |
|
| 46 |
+ SetRefreshToken(realm *url.URL, service, token string) |
|
| 39 | 47 |
} |
| 40 | 48 |
|
| 41 | 49 |
// NewAuthorizer creates an authorizer which can handle multiple authentication |
| ... | ... |
@@ -105,27 +115,47 @@ type clock interface {
|
| 105 | 105 |
type tokenHandler struct {
|
| 106 | 106 |
header http.Header |
| 107 | 107 |
creds CredentialStore |
| 108 |
- scope tokenScope |
|
| 109 | 108 |
transport http.RoundTripper |
| 110 | 109 |
clock clock |
| 111 | 110 |
|
| 111 |
+ offlineAccess bool |
|
| 112 |
+ forceOAuth bool |
|
| 113 |
+ clientID string |
|
| 114 |
+ scopes []Scope |
|
| 115 |
+ |
|
| 112 | 116 |
tokenLock sync.Mutex |
| 113 | 117 |
tokenCache string |
| 114 | 118 |
tokenExpiration time.Time |
| 119 |
+} |
|
| 115 | 120 |
|
| 116 |
- additionalScopes map[string]struct{}
|
|
| 121 |
+// Scope is a type which is serializable to a string |
|
| 122 |
+// using the allow scope grammar. |
|
| 123 |
+type Scope interface {
|
|
| 124 |
+ String() string |
|
| 117 | 125 |
} |
| 118 | 126 |
|
| 119 |
-// tokenScope represents the scope at which a token will be requested. |
|
| 120 |
-// This represents a specific action on a registry resource. |
|
| 121 |
-type tokenScope struct {
|
|
| 122 |
- Resource string |
|
| 123 |
- Scope string |
|
| 124 |
- Actions []string |
|
| 127 |
+// RepositoryScope represents a token scope for access |
|
| 128 |
+// to a repository. |
|
| 129 |
+type RepositoryScope struct {
|
|
| 130 |
+ Repository string |
|
| 131 |
+ Actions []string |
|
| 125 | 132 |
} |
| 126 | 133 |
|
| 127 |
-func (ts tokenScope) String() string {
|
|
| 128 |
- return fmt.Sprintf("%s:%s:%s", ts.Resource, ts.Scope, strings.Join(ts.Actions, ","))
|
|
| 134 |
+// String returns the string representation of the repository |
|
| 135 |
+// using the scope grammar |
|
| 136 |
+func (rs RepositoryScope) String() string {
|
|
| 137 |
+ return fmt.Sprintf("repository:%s:%s", rs.Repository, strings.Join(rs.Actions, ","))
|
|
| 138 |
+} |
|
| 139 |
+ |
|
| 140 |
+// TokenHandlerOptions is used to configure a new token handler |
|
| 141 |
+type TokenHandlerOptions struct {
|
|
| 142 |
+ Transport http.RoundTripper |
|
| 143 |
+ Credentials CredentialStore |
|
| 144 |
+ |
|
| 145 |
+ OfflineAccess bool |
|
| 146 |
+ ForceOAuth bool |
|
| 147 |
+ ClientID string |
|
| 148 |
+ Scopes []Scope |
|
| 129 | 149 |
} |
| 130 | 150 |
|
| 131 | 151 |
// An implementation of clock for providing real time data. |
| ... | ... |
@@ -137,22 +167,33 @@ func (realClock) Now() time.Time { return time.Now() }
|
| 137 | 137 |
// NewTokenHandler creates a new AuthenicationHandler which supports |
| 138 | 138 |
// fetching tokens from a remote token server. |
| 139 | 139 |
func NewTokenHandler(transport http.RoundTripper, creds CredentialStore, scope string, actions ...string) AuthenticationHandler {
|
| 140 |
- return newTokenHandler(transport, creds, realClock{}, scope, actions...)
|
|
| 140 |
+ // Create options... |
|
| 141 |
+ return NewTokenHandlerWithOptions(TokenHandlerOptions{
|
|
| 142 |
+ Transport: transport, |
|
| 143 |
+ Credentials: creds, |
|
| 144 |
+ Scopes: []Scope{
|
|
| 145 |
+ RepositoryScope{
|
|
| 146 |
+ Repository: scope, |
|
| 147 |
+ Actions: actions, |
|
| 148 |
+ }, |
|
| 149 |
+ }, |
|
| 150 |
+ }) |
|
| 141 | 151 |
} |
| 142 | 152 |
|
| 143 |
-// newTokenHandler exposes the option to provide a clock to manipulate time in unit testing. |
|
| 144 |
-func newTokenHandler(transport http.RoundTripper, creds CredentialStore, c clock, scope string, actions ...string) AuthenticationHandler {
|
|
| 145 |
- return &tokenHandler{
|
|
| 146 |
- transport: transport, |
|
| 147 |
- creds: creds, |
|
| 148 |
- clock: c, |
|
| 149 |
- scope: tokenScope{
|
|
| 150 |
- Resource: "repository", |
|
| 151 |
- Scope: scope, |
|
| 152 |
- Actions: actions, |
|
| 153 |
- }, |
|
| 154 |
- additionalScopes: map[string]struct{}{},
|
|
| 153 |
+// NewTokenHandlerWithOptions creates a new token handler using the provided |
|
| 154 |
+// options structure. |
|
| 155 |
+func NewTokenHandlerWithOptions(options TokenHandlerOptions) AuthenticationHandler {
|
|
| 156 |
+ handler := &tokenHandler{
|
|
| 157 |
+ transport: options.Transport, |
|
| 158 |
+ creds: options.Credentials, |
|
| 159 |
+ offlineAccess: options.OfflineAccess, |
|
| 160 |
+ forceOAuth: options.ForceOAuth, |
|
| 161 |
+ clientID: options.ClientID, |
|
| 162 |
+ scopes: options.Scopes, |
|
| 163 |
+ clock: realClock{},
|
|
| 155 | 164 |
} |
| 165 |
+ |
|
| 166 |
+ return handler |
|
| 156 | 167 |
} |
| 157 | 168 |
|
| 158 | 169 |
func (th *tokenHandler) client() *http.Client {
|
| ... | ... |
@@ -169,88 +210,162 @@ func (th *tokenHandler) Scheme() string {
|
| 169 | 169 |
func (th *tokenHandler) AuthorizeRequest(req *http.Request, params map[string]string) error {
|
| 170 | 170 |
var additionalScopes []string |
| 171 | 171 |
if fromParam := req.URL.Query().Get("from"); fromParam != "" {
|
| 172 |
- additionalScopes = append(additionalScopes, tokenScope{
|
|
| 173 |
- Resource: "repository", |
|
| 174 |
- Scope: fromParam, |
|
| 175 |
- Actions: []string{"pull"},
|
|
| 172 |
+ additionalScopes = append(additionalScopes, RepositoryScope{
|
|
| 173 |
+ Repository: fromParam, |
|
| 174 |
+ Actions: []string{"pull"},
|
|
| 176 | 175 |
}.String()) |
| 177 | 176 |
} |
| 178 |
- if err := th.refreshToken(params, additionalScopes...); err != nil {
|
|
| 177 |
+ |
|
| 178 |
+ token, err := th.getToken(params, additionalScopes...) |
|
| 179 |
+ if err != nil {
|
|
| 179 | 180 |
return err |
| 180 | 181 |
} |
| 181 | 182 |
|
| 182 |
- req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", th.tokenCache))
|
|
| 183 |
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
| 183 | 184 |
|
| 184 | 185 |
return nil |
| 185 | 186 |
} |
| 186 | 187 |
|
| 187 |
-func (th *tokenHandler) refreshToken(params map[string]string, additionalScopes ...string) error {
|
|
| 188 |
+func (th *tokenHandler) getToken(params map[string]string, additionalScopes ...string) (string, error) {
|
|
| 188 | 189 |
th.tokenLock.Lock() |
| 189 | 190 |
defer th.tokenLock.Unlock() |
| 191 |
+ scopes := make([]string, 0, len(th.scopes)+len(additionalScopes)) |
|
| 192 |
+ for _, scope := range th.scopes {
|
|
| 193 |
+ scopes = append(scopes, scope.String()) |
|
| 194 |
+ } |
|
| 190 | 195 |
var addedScopes bool |
| 191 | 196 |
for _, scope := range additionalScopes {
|
| 192 |
- if _, ok := th.additionalScopes[scope]; !ok {
|
|
| 193 |
- th.additionalScopes[scope] = struct{}{}
|
|
| 194 |
- addedScopes = true |
|
| 195 |
- } |
|
| 197 |
+ scopes = append(scopes, scope) |
|
| 198 |
+ addedScopes = true |
|
| 196 | 199 |
} |
| 200 |
+ |
|
| 197 | 201 |
now := th.clock.Now() |
| 198 | 202 |
if now.After(th.tokenExpiration) || addedScopes {
|
| 199 |
- tr, err := th.fetchToken(params) |
|
| 203 |
+ token, expiration, err := th.fetchToken(params, scopes) |
|
| 200 | 204 |
if err != nil {
|
| 201 |
- return err |
|
| 205 |
+ return "", err |
|
| 206 |
+ } |
|
| 207 |
+ |
|
| 208 |
+ // do not update cache for added scope tokens |
|
| 209 |
+ if !addedScopes {
|
|
| 210 |
+ th.tokenCache = token |
|
| 211 |
+ th.tokenExpiration = expiration |
|
| 202 | 212 |
} |
| 203 |
- th.tokenCache = tr.Token |
|
| 204 |
- th.tokenExpiration = tr.IssuedAt.Add(time.Duration(tr.ExpiresIn) * time.Second) |
|
| 213 |
+ |
|
| 214 |
+ return token, nil |
|
| 205 | 215 |
} |
| 206 | 216 |
|
| 207 |
- return nil |
|
| 217 |
+ return th.tokenCache, nil |
|
| 208 | 218 |
} |
| 209 | 219 |
|
| 210 |
-type tokenResponse struct {
|
|
| 211 |
- Token string `json:"token"` |
|
| 212 |
- AccessToken string `json:"access_token"` |
|
| 213 |
- ExpiresIn int `json:"expires_in"` |
|
| 214 |
- IssuedAt time.Time `json:"issued_at"` |
|
| 220 |
+type postTokenResponse struct {
|
|
| 221 |
+ AccessToken string `json:"access_token"` |
|
| 222 |
+ RefreshToken string `json:"refresh_token"` |
|
| 223 |
+ ExpiresIn int `json:"expires_in"` |
|
| 224 |
+ IssuedAt time.Time `json:"issued_at"` |
|
| 225 |
+ Scope string `json:"scope"` |
|
| 215 | 226 |
} |
| 216 | 227 |
|
| 217 |
-func (th *tokenHandler) fetchToken(params map[string]string) (token *tokenResponse, err error) {
|
|
| 218 |
- //log.Debugf("Getting bearer token with %s for %s", challenge.Parameters, ta.auth.Username)
|
|
| 219 |
- realm, ok := params["realm"] |
|
| 220 |
- if !ok {
|
|
| 221 |
- return nil, errors.New("no realm specified for token auth challenge")
|
|
| 222 |
- } |
|
| 228 |
+func (th *tokenHandler) fetchTokenWithOAuth(realm *url.URL, refreshToken, service string, scopes []string) (token string, expiration time.Time, err error) {
|
|
| 229 |
+ form := url.Values{}
|
|
| 230 |
+ form.Set("scope", strings.Join(scopes, " "))
|
|
| 231 |
+ form.Set("service", service)
|
|
| 223 | 232 |
|
| 224 |
- // TODO(dmcgowan): Handle empty scheme |
|
| 233 |
+ clientID := th.clientID |
|
| 234 |
+ if clientID == "" {
|
|
| 235 |
+ // Use default client, this is a required field |
|
| 236 |
+ clientID = defaultClientID |
|
| 237 |
+ } |
|
| 238 |
+ form.Set("client_id", clientID)
|
|
| 239 |
+ |
|
| 240 |
+ if refreshToken != "" {
|
|
| 241 |
+ form.Set("grant_type", "refresh_token")
|
|
| 242 |
+ form.Set("refresh_token", refreshToken)
|
|
| 243 |
+ } else if th.creds != nil {
|
|
| 244 |
+ form.Set("grant_type", "password")
|
|
| 245 |
+ username, password := th.creds.Basic(realm) |
|
| 246 |
+ form.Set("username", username)
|
|
| 247 |
+ form.Set("password", password)
|
|
| 248 |
+ |
|
| 249 |
+ // attempt to get a refresh token |
|
| 250 |
+ form.Set("access_type", "offline")
|
|
| 251 |
+ } else {
|
|
| 252 |
+ // refuse to do oauth without a grant type |
|
| 253 |
+ return "", time.Time{}, fmt.Errorf("no supported grant type")
|
|
| 254 |
+ } |
|
| 225 | 255 |
|
| 226 |
- realmURL, err := url.Parse(realm) |
|
| 256 |
+ resp, err := th.client().PostForm(realm.String(), form) |
|
| 227 | 257 |
if err != nil {
|
| 228 |
- return nil, fmt.Errorf("invalid token auth challenge realm: %s", err)
|
|
| 258 |
+ return "", time.Time{}, err
|
|
| 259 |
+ } |
|
| 260 |
+ defer resp.Body.Close() |
|
| 261 |
+ |
|
| 262 |
+ if !client.SuccessStatus(resp.StatusCode) {
|
|
| 263 |
+ err := client.HandleErrorResponse(resp) |
|
| 264 |
+ return "", time.Time{}, err
|
|
| 265 |
+ } |
|
| 266 |
+ |
|
| 267 |
+ decoder := json.NewDecoder(resp.Body) |
|
| 268 |
+ |
|
| 269 |
+ var tr postTokenResponse |
|
| 270 |
+ if err = decoder.Decode(&tr); err != nil {
|
|
| 271 |
+ return "", time.Time{}, fmt.Errorf("unable to decode token response: %s", err)
|
|
| 272 |
+ } |
|
| 273 |
+ |
|
| 274 |
+ if tr.RefreshToken != "" && tr.RefreshToken != refreshToken {
|
|
| 275 |
+ th.creds.SetRefreshToken(realm, service, tr.RefreshToken) |
|
| 276 |
+ } |
|
| 277 |
+ |
|
| 278 |
+ if tr.ExpiresIn < minimumTokenLifetimeSeconds {
|
|
| 279 |
+ // The default/minimum lifetime. |
|
| 280 |
+ tr.ExpiresIn = minimumTokenLifetimeSeconds |
|
| 281 |
+ logrus.Debugf("Increasing token expiration to: %d seconds", tr.ExpiresIn)
|
|
| 229 | 282 |
} |
| 230 | 283 |
|
| 231 |
- req, err := http.NewRequest("GET", realmURL.String(), nil)
|
|
| 284 |
+ if tr.IssuedAt.IsZero() {
|
|
| 285 |
+ // issued_at is optional in the token response. |
|
| 286 |
+ tr.IssuedAt = th.clock.Now().UTC() |
|
| 287 |
+ } |
|
| 288 |
+ |
|
| 289 |
+ return tr.AccessToken, tr.IssuedAt.Add(time.Duration(tr.ExpiresIn) * time.Second), nil |
|
| 290 |
+} |
|
| 291 |
+ |
|
| 292 |
+type getTokenResponse struct {
|
|
| 293 |
+ Token string `json:"token"` |
|
| 294 |
+ AccessToken string `json:"access_token"` |
|
| 295 |
+ ExpiresIn int `json:"expires_in"` |
|
| 296 |
+ IssuedAt time.Time `json:"issued_at"` |
|
| 297 |
+ RefreshToken string `json:"refresh_token"` |
|
| 298 |
+} |
|
| 299 |
+ |
|
| 300 |
+func (th *tokenHandler) fetchTokenWithBasicAuth(realm *url.URL, service string, scopes []string) (token string, expiration time.Time, err error) {
|
|
| 301 |
+ |
|
| 302 |
+ req, err := http.NewRequest("GET", realm.String(), nil)
|
|
| 232 | 303 |
if err != nil {
|
| 233 |
- return nil, err |
|
| 304 |
+ return "", time.Time{}, err
|
|
| 234 | 305 |
} |
| 235 | 306 |
|
| 236 | 307 |
reqParams := req.URL.Query() |
| 237 |
- service := params["service"] |
|
| 238 |
- scope := th.scope.String() |
|
| 239 | 308 |
|
| 240 | 309 |
if service != "" {
|
| 241 | 310 |
reqParams.Add("service", service)
|
| 242 | 311 |
} |
| 243 | 312 |
|
| 244 |
- for _, scopeField := range strings.Fields(scope) {
|
|
| 245 |
- reqParams.Add("scope", scopeField)
|
|
| 313 |
+ for _, scope := range scopes {
|
|
| 314 |
+ reqParams.Add("scope", scope)
|
|
| 246 | 315 |
} |
| 247 | 316 |
|
| 248 |
- for scope := range th.additionalScopes {
|
|
| 249 |
- reqParams.Add("scope", scope)
|
|
| 317 |
+ if th.offlineAccess {
|
|
| 318 |
+ reqParams.Add("offline_token", "true")
|
|
| 319 |
+ clientID := th.clientID |
|
| 320 |
+ if clientID == "" {
|
|
| 321 |
+ clientID = defaultClientID |
|
| 322 |
+ } |
|
| 323 |
+ reqParams.Add("client_id", clientID)
|
|
| 250 | 324 |
} |
| 251 | 325 |
|
| 252 | 326 |
if th.creds != nil {
|
| 253 |
- username, password := th.creds.Basic(realmURL) |
|
| 327 |
+ username, password := th.creds.Basic(realm) |
|
| 254 | 328 |
if username != "" && password != "" {
|
| 255 | 329 |
reqParams.Add("account", username)
|
| 256 | 330 |
req.SetBasicAuth(username, password) |
| ... | ... |
@@ -261,20 +376,24 @@ func (th *tokenHandler) fetchToken(params map[string]string) (token *tokenRespon |
| 261 | 261 |
|
| 262 | 262 |
resp, err := th.client().Do(req) |
| 263 | 263 |
if err != nil {
|
| 264 |
- return nil, err |
|
| 264 |
+ return "", time.Time{}, err
|
|
| 265 | 265 |
} |
| 266 | 266 |
defer resp.Body.Close() |
| 267 | 267 |
|
| 268 | 268 |
if !client.SuccessStatus(resp.StatusCode) {
|
| 269 | 269 |
err := client.HandleErrorResponse(resp) |
| 270 |
- return nil, err |
|
| 270 |
+ return "", time.Time{}, err
|
|
| 271 | 271 |
} |
| 272 | 272 |
|
| 273 | 273 |
decoder := json.NewDecoder(resp.Body) |
| 274 | 274 |
|
| 275 |
- tr := new(tokenResponse) |
|
| 276 |
- if err = decoder.Decode(tr); err != nil {
|
|
| 277 |
- return nil, fmt.Errorf("unable to decode token response: %s", err)
|
|
| 275 |
+ var tr getTokenResponse |
|
| 276 |
+ if err = decoder.Decode(&tr); err != nil {
|
|
| 277 |
+ return "", time.Time{}, fmt.Errorf("unable to decode token response: %s", err)
|
|
| 278 |
+ } |
|
| 279 |
+ |
|
| 280 |
+ if tr.RefreshToken != "" && th.creds != nil {
|
|
| 281 |
+ th.creds.SetRefreshToken(realm, service, tr.RefreshToken) |
|
| 278 | 282 |
} |
| 279 | 283 |
|
| 280 | 284 |
// `access_token` is equivalent to `token` and if both are specified |
| ... | ... |
@@ -285,7 +404,7 @@ func (th *tokenHandler) fetchToken(params map[string]string) (token *tokenRespon |
| 285 | 285 |
} |
| 286 | 286 |
|
| 287 | 287 |
if tr.Token == "" {
|
| 288 |
- return nil, errors.New("authorization server did not include a token in the response")
|
|
| 288 |
+ return "", time.Time{}, errors.New("authorization server did not include a token in the response")
|
|
| 289 | 289 |
} |
| 290 | 290 |
|
| 291 | 291 |
if tr.ExpiresIn < minimumTokenLifetimeSeconds {
|
| ... | ... |
@@ -296,10 +415,37 @@ func (th *tokenHandler) fetchToken(params map[string]string) (token *tokenRespon |
| 296 | 296 |
|
| 297 | 297 |
if tr.IssuedAt.IsZero() {
|
| 298 | 298 |
// issued_at is optional in the token response. |
| 299 |
- tr.IssuedAt = th.clock.Now() |
|
| 299 |
+ tr.IssuedAt = th.clock.Now().UTC() |
|
| 300 |
+ } |
|
| 301 |
+ |
|
| 302 |
+ return tr.Token, tr.IssuedAt.Add(time.Duration(tr.ExpiresIn) * time.Second), nil |
|
| 303 |
+} |
|
| 304 |
+ |
|
| 305 |
+func (th *tokenHandler) fetchToken(params map[string]string, scopes []string) (token string, expiration time.Time, err error) {
|
|
| 306 |
+ realm, ok := params["realm"] |
|
| 307 |
+ if !ok {
|
|
| 308 |
+ return "", time.Time{}, errors.New("no realm specified for token auth challenge")
|
|
| 309 |
+ } |
|
| 310 |
+ |
|
| 311 |
+ // TODO(dmcgowan): Handle empty scheme and relative realm |
|
| 312 |
+ realmURL, err := url.Parse(realm) |
|
| 313 |
+ if err != nil {
|
|
| 314 |
+ return "", time.Time{}, fmt.Errorf("invalid token auth challenge realm: %s", err)
|
|
| 315 |
+ } |
|
| 316 |
+ |
|
| 317 |
+ service := params["service"] |
|
| 318 |
+ |
|
| 319 |
+ var refreshToken string |
|
| 320 |
+ |
|
| 321 |
+ if th.creds != nil {
|
|
| 322 |
+ refreshToken = th.creds.RefreshToken(realmURL, service) |
|
| 323 |
+ } |
|
| 324 |
+ |
|
| 325 |
+ if refreshToken != "" || th.forceOAuth {
|
|
| 326 |
+ return th.fetchTokenWithOAuth(realmURL, refreshToken, service, scopes) |
|
| 300 | 327 |
} |
| 301 | 328 |
|
| 302 |
- return tr, nil |
|
| 329 |
+ return th.fetchTokenWithBasicAuth(realmURL, service, scopes) |
|
| 303 | 330 |
} |
| 304 | 331 |
|
| 305 | 332 |
type basicHandler struct {
|
| ... | ... |
@@ -292,9 +292,18 @@ func (t *tags) Get(ctx context.Context, tag string) (distribution.Descriptor, er |
| 292 | 292 |
if err != nil {
|
| 293 | 293 |
return distribution.Descriptor{}, err
|
| 294 | 294 |
} |
| 295 |
- var attempts int |
|
| 296 |
- resp, err := t.client.Head(u) |
|
| 297 | 295 |
|
| 296 |
+ req, err := http.NewRequest("HEAD", u, nil)
|
|
| 297 |
+ if err != nil {
|
|
| 298 |
+ return distribution.Descriptor{}, err
|
|
| 299 |
+ } |
|
| 300 |
+ |
|
| 301 |
+ for _, t := range distribution.ManifestMediaTypes() {
|
|
| 302 |
+ req.Header.Add("Accept", t)
|
|
| 303 |
+ } |
|
| 304 |
+ |
|
| 305 |
+ var attempts int |
|
| 306 |
+ resp, err := t.client.Do(req) |
|
| 298 | 307 |
check: |
| 299 | 308 |
if err != nil {
|
| 300 | 309 |
return distribution.Descriptor{}, err
|
| ... | ... |
@@ -304,7 +313,16 @@ check: |
| 304 | 304 |
case resp.StatusCode >= 200 && resp.StatusCode < 400: |
| 305 | 305 |
return descriptorFromResponse(resp) |
| 306 | 306 |
case resp.StatusCode == http.StatusMethodNotAllowed: |
| 307 |
- resp, err = t.client.Get(u) |
|
| 307 |
+ req, err = http.NewRequest("GET", u, nil)
|
|
| 308 |
+ if err != nil {
|
|
| 309 |
+ return distribution.Descriptor{}, err
|
|
| 310 |
+ } |
|
| 311 |
+ |
|
| 312 |
+ for _, t := range distribution.ManifestMediaTypes() {
|
|
| 313 |
+ req.Header.Add("Accept", t)
|
|
| 314 |
+ } |
|
| 315 |
+ |
|
| 316 |
+ resp, err = t.client.Do(req) |
|
| 308 | 317 |
attempts++ |
| 309 | 318 |
if attempts > 1 {
|
| 310 | 319 |
return distribution.Descriptor{}, err
|
| ... | ... |
@@ -66,7 +66,7 @@ func (hrs *httpReadSeeker) Read(p []byte) (n int, err error) {
|
| 66 | 66 |
return 0, hrs.err |
| 67 | 67 |
} |
| 68 | 68 |
|
| 69 |
- // If we seeked to a different position, we need to reset the |
|
| 69 |
+ // If we sought to a different position, we need to reset the |
|
| 70 | 70 |
// connection. This logic is here instead of Seek so that if |
| 71 | 71 |
// a seek is undone before the next read, the connection doesn't |
| 72 | 72 |
// need to be closed and reopened. A common example of this is |