diff --git a/docker/docker_client.go b/docker/docker_client.go index ebd89a6df..c316bdeec 100644 --- a/docker/docker_client.go +++ b/docker/docker_client.go @@ -1,11 +1,13 @@ package docker import ( + "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" + "io/ioutil" "net/http" "net/url" "os" @@ -97,8 +99,7 @@ type dockerClient struct { // by detectProperties(). Callers can edit tlsClientConfig.InsecureSkipVerify in the meantime. tlsClientConfig *tls.Config // The following members are not set by newDockerClient and must be set by callers if needed. - username string - password string + auth types.DockerAuthConfig registryToken string signatureBase signatureStorageBase scope authScope @@ -210,10 +211,11 @@ func dockerCertDir(sys *types.SystemContext, hostPort string) (string, error) { // “write” specifies whether the client will be used for "write" access (in particular passed to lookaside.go:toplevelFromSection) func newDockerClientFromRef(sys *types.SystemContext, ref dockerReference, write bool, actions string) (*dockerClient, error) { registry := reference.Domain(ref.ref) - username, password, err := config.GetAuthentication(sys, registry) + auth, err := config.GetCredentials(sys, registry) if err != nil { return nil, errors.Wrapf(err, "error getting username and password") } + sigBase, err := configuredSignatureStorageBase(sys, ref, write) if err != nil { return nil, err @@ -223,8 +225,7 @@ func newDockerClientFromRef(sys *types.SystemContext, ref dockerReference, write if err != nil { return nil, err } - client.username = username - client.password = password + client.auth = auth if sys != nil { client.registryToken = sys.DockerBearerRegistryToken } @@ -289,8 +290,10 @@ func CheckAuth(ctx context.Context, sys *types.SystemContext, username, password if err != nil { return errors.Wrapf(err, "error creating new docker client") } - client.username = username - client.password = password + client.auth = types.DockerAuthConfig{ + Username: username, + Password: password, + } resp, err := client.makeRequest(ctx, "GET", "/v2/", nil, nil, v2Auth, nil) if err != nil { @@ -332,7 +335,7 @@ func SearchRegistry(ctx context.Context, sys *types.SystemContext, registry, ima v1Res := &V1Results{} // Get credentials from authfile for the underlying hostname - username, password, err := config.GetAuthentication(sys, registry) + auth, err := config.GetCredentials(sys, registry) if err != nil { return nil, errors.Wrapf(err, "error getting username and password") } @@ -350,8 +353,7 @@ func SearchRegistry(ctx context.Context, sys *types.SystemContext, registry, ima if err != nil { return nil, errors.Wrapf(err, "error creating new docker client") } - client.username = username - client.password = password + client.auth = auth if sys != nil { client.registryToken = sys.DockerBearerRegistryToken } @@ -535,7 +537,7 @@ func (c *dockerClient) setupRequestAuth(req *http.Request, extraScope *authScope schemeNames = append(schemeNames, challenge.Scheme) switch challenge.Scheme { case "basic": - req.SetBasicAuth(c.username, c.password) + req.SetBasicAuth(c.auth.Username, c.auth.Password) return nil case "bearer": registryToken := c.registryToken @@ -553,10 +555,19 @@ func (c *dockerClient) setupRequestAuth(req *http.Request, extraScope *authScope token = t.(bearerToken) } if !inCache || time.Now().After(token.expirationTime) { - t, err := c.getBearerToken(req.Context(), challenge, scopes) + var ( + t *bearerToken + err error + ) + if c.auth.IdentityToken != "" { + t, err = c.getBearerTokenOAuth2(req.Context(), challenge, scopes) + } else { + t, err = c.getBearerToken(req.Context(), challenge, scopes) + } if err != nil { return err } + token = *t c.tokenCache.Store(cacheKey, token) } @@ -572,48 +583,96 @@ func (c *dockerClient) setupRequestAuth(req *http.Request, extraScope *authScope return nil } -func (c *dockerClient) getBearerToken(ctx context.Context, challenge challenge, scopes []authScope) (*bearerToken, error) { +func (c *dockerClient) getBearerTokenOAuth2(ctx context.Context, challenge challenge, + scopes []authScope) (*bearerToken, error) { + realm, ok := challenge.Parameters["realm"] + if !ok { + return nil, errors.Errorf("missing realm in bearer auth challenge") + } + + authReq, err := http.NewRequest(http.MethodPost, realm, nil) + if err != nil { + return nil, err + } + + authReq = authReq.WithContext(ctx) + + // Make the form data required against the oauth2 authentication + // More details here: https://docs.docker.com/registry/spec/auth/oauth/ + params := authReq.URL.Query() + if service, ok := challenge.Parameters["service"]; ok && service != "" { + params.Add("service", service) + } + for _, scope := range scopes { + if scope.remoteName != "" && scope.actions != "" { + params.Add("scope", fmt.Sprintf("repository:%s:%s", scope.remoteName, scope.actions)) + } + } + params.Add("grant_type", "refresh_token") + params.Add("refresh_token", c.auth.IdentityToken) + + authReq.Body = ioutil.NopCloser(bytes.NewBufferString(params.Encode())) + authReq.Header.Add("Content-Type", "application/x-www-form-urlencoded") + logrus.Debugf("%s %s", authReq.Method, authReq.URL.String()) + res, err := c.client.Do(authReq) + if err != nil { + return nil, err + } + defer res.Body.Close() + if err := httpResponseToError(res, "Trying to obtain access token"); err != nil { + return nil, err + } + + tokenBlob, err := iolimits.ReadAtMost(res.Body, iolimits.MaxAuthTokenBodySize) + if err != nil { + return nil, err + } + + return newBearerTokenFromJSONBlob(tokenBlob) +} + +func (c *dockerClient) getBearerToken(ctx context.Context, challenge challenge, + scopes []authScope) (*bearerToken, error) { realm, ok := challenge.Parameters["realm"] if !ok { return nil, errors.Errorf("missing realm in bearer auth challenge") } - authReq, err := http.NewRequest("GET", realm, nil) + authReq, err := http.NewRequest(http.MethodGet, realm, nil) if err != nil { return nil, err } + authReq = authReq.WithContext(ctx) - getParams := authReq.URL.Query() - if c.username != "" { - getParams.Add("account", c.username) + params := authReq.URL.Query() + if c.auth.Username != "" { + params.Add("account", c.auth.Username) } + if service, ok := challenge.Parameters["service"]; ok && service != "" { - getParams.Add("service", service) + params.Add("service", service) } + for _, scope := range scopes { if scope.remoteName != "" && scope.actions != "" { - getParams.Add("scope", fmt.Sprintf("repository:%s:%s", scope.remoteName, scope.actions)) + params.Add("scope", fmt.Sprintf("repository:%s:%s", scope.remoteName, scope.actions)) } } - authReq.URL.RawQuery = getParams.Encode() - if c.username != "" && c.password != "" { - authReq.SetBasicAuth(c.username, c.password) + + authReq.URL.RawQuery = params.Encode() + + if c.auth.Username != "" && c.auth.Password != "" { + authReq.SetBasicAuth(c.auth.Username, c.auth.Password) } + logrus.Debugf("%s %s", authReq.Method, authReq.URL.String()) res, err := c.client.Do(authReq) if err != nil { return nil, err } defer res.Body.Close() - switch res.StatusCode { - case http.StatusUnauthorized: - err := clientLib.HandleErrorResponse(res) - logrus.Debugf("Server response when trying to obtain an access token: \n%q", err.Error()) - return nil, ErrUnauthorizedForCredentials{Err: err} - case http.StatusOK: - break - default: - return nil, errors.Errorf("unexpected http code: %d (%s), URL: %s", res.StatusCode, http.StatusText(res.StatusCode), authReq.URL) + if err := httpResponseToError(res, "Requesting bear token"); err != nil { + return nil, err } tokenBlob, err := iolimits.ReadAtMost(res.Body, iolimits.MaxAuthTokenBodySize) if err != nil { diff --git a/pkg/docker/config/config.go b/pkg/docker/config/config.go index b7dddd0d6..dae3eb586 100644 --- a/pkg/docker/config/config.go +++ b/pkg/docker/config/config.go @@ -18,7 +18,8 @@ import ( ) type dockerAuthConfig struct { - Auth string `json:"auth,omitempty"` + Auth string `json:"auth,omitempty"` + IdentityToken string `json:"identitytoken,omitempty"` } type dockerConfigFile struct { @@ -72,20 +73,23 @@ func SetAuthentication(sys *types.SystemContext, registry, username, password st }) } -// GetAuthentication returns the registry credentials stored in -// either auth.json file or .docker/config.json -// If an entry is not found empty strings are returned for the username and password -func GetAuthentication(sys *types.SystemContext, registry string) (string, string, error) { +// GetCredentials returns the registry credentials stored in either auth.json +// file or .docker/config.json, including support for OAuth2 and IdentityToken. +// If an entry is not found, an empty struct is returned. +func GetCredentials(sys *types.SystemContext, registry string) (types.DockerAuthConfig, error) { if sys != nil && sys.DockerAuthConfig != nil { logrus.Debug("Returning credentials from DockerAuthConfig") - return sys.DockerAuthConfig.Username, sys.DockerAuthConfig.Password, nil + return *sys.DockerAuthConfig, nil } if enableKeyring { username, password, err := getAuthFromKernelKeyring(registry) if err == nil { logrus.Debug("returning credentials from kernel keyring") - return username, password, nil + return types.DockerAuthConfig{ + Username: username, + Password: password, + }, nil } } @@ -104,18 +108,39 @@ func GetAuthentication(sys *types.SystemContext, registry string) (string, strin authPath{path: filepath.Join(homedir.Get(), dockerLegacyHomePath), legacyFormat: true}) for _, path := range paths { - username, password, err := findAuthentication(registry, path.path, path.legacyFormat) + authConfig, err := findAuthentication(registry, path.path, path.legacyFormat) if err != nil { logrus.Debugf("Credentials not found") - return "", "", err + return types.DockerAuthConfig{}, err } - if username != "" && password != "" { + + if (authConfig.Username != "" && authConfig.Password != "") || authConfig.IdentityToken != "" { logrus.Debugf("Returning credentials from %s", path.path) - return username, password, nil + return authConfig, nil } } + logrus.Debugf("Credentials not found") - return "", "", nil + return types.DockerAuthConfig{}, nil +} + +// GetAuthentication returns the registry credentials stored in +// either auth.json file or .docker/config.json +// If an entry is not found empty strings are returned for the username and password +// +// Deprecated: This API only has support for username and password. To get the +// support for oauth2 in docker registry authentication, we added the new +// GetCredentials API. The new API should be used and this API is kept to +// maintain backward compatibility. +func GetAuthentication(sys *types.SystemContext, registry string) (string, string, error) { + auth, err := GetCredentials(sys, registry) + if err != nil { + return "", "", err + } + if auth.IdentityToken != "" { + return "", "", errors.Wrap(ErrNotSupported, "non-empty identity token found and this API doesn't support it") + } + return auth.Username, auth.Password, nil } // RemoveAuthentication deletes the credentials stored in auth.json @@ -294,20 +319,28 @@ func deleteAuthFromCredHelper(credHelper, registry string) error { } // findAuthentication looks for auth of registry in path -func findAuthentication(registry, path string, legacyFormat bool) (string, string, error) { +func findAuthentication(registry, path string, legacyFormat bool) (types.DockerAuthConfig, error) { auths, err := readJSONFile(path, legacyFormat) if err != nil { - return "", "", errors.Wrapf(err, "error reading JSON file %q", path) + return types.DockerAuthConfig{}, errors.Wrapf(err, "error reading JSON file %q", path) } // First try cred helpers. They should always be normalized. if ch, exists := auths.CredHelpers[registry]; exists { - return getAuthFromCredHelper(ch, registry) + username, password, err := getAuthFromCredHelper(ch, registry) + if err != nil { + return types.DockerAuthConfig{}, err + } + + return types.DockerAuthConfig{ + Username: username, + Password: password, + }, nil } // I'm feeling lucky if val, exists := auths.AuthConfigs[registry]; exists { - return decodeDockerAuth(val.Auth) + return decodeDockerAuth(val) } // bad luck; let's normalize the entries first @@ -316,25 +349,35 @@ func findAuthentication(registry, path string, legacyFormat bool) (string, strin for k, v := range auths.AuthConfigs { normalizedAuths[normalizeRegistry(k)] = v } + if val, exists := normalizedAuths[registry]; exists { - return decodeDockerAuth(val.Auth) + return decodeDockerAuth(val) } - return "", "", nil + + return types.DockerAuthConfig{}, nil } -func decodeDockerAuth(s string) (string, string, error) { - decoded, err := base64.StdEncoding.DecodeString(s) +// decodeDockerAuth decodes the username and password, which is +// encoded in base64. +func decodeDockerAuth(conf dockerAuthConfig) (types.DockerAuthConfig, error) { + decoded, err := base64.StdEncoding.DecodeString(conf.Auth) if err != nil { - return "", "", err + return types.DockerAuthConfig{}, err } + parts := strings.SplitN(string(decoded), ":", 2) if len(parts) != 2 { // if it's invalid just skip, as docker does - return "", "", nil + return types.DockerAuthConfig{}, nil } + user := parts[0] password := strings.Trim(parts[1], "\x00") - return user, password, nil + return types.DockerAuthConfig{ + Username: user, + Password: password, + IdentityToken: conf.IdentityToken, + }, nil } // convertToHostname converts a registry url which has http|https prepended diff --git a/pkg/docker/config/config_test.go b/pkg/docker/config/config_test.go index aebe1cb76..905396a8a 100644 --- a/pkg/docker/config/config_test.go +++ b/pkg/docker/config/config_test.go @@ -110,13 +110,12 @@ func TestGetAuth(t *testing.T) { for _, configPath := range configPaths { for _, tc := range []struct { - name string - hostname string - path string - expectedUsername string - expectedPassword string - expectedError error - sys *types.SystemContext + name string + hostname string + path string + expected types.DockerAuthConfig + expectedError error + sys *types.SystemContext }{ { name: "no auth config", @@ -127,11 +126,13 @@ func TestGetAuth(t *testing.T) { path: filepath.Join("testdata", "example.json"), }, { - name: "match one", - hostname: "example.org", - path: filepath.Join("testdata", "example.json"), - expectedUsername: "example", - expectedPassword: "org", + name: "match one", + hostname: "example.org", + path: filepath.Join("testdata", "example.json"), + expected: types.DockerAuthConfig{ + Username: "example", + Password: "org", + }, }, { name: "match none", @@ -139,53 +140,67 @@ func TestGetAuth(t *testing.T) { path: filepath.Join("testdata", "example.json"), }, { - name: "match docker.io", - hostname: "docker.io", - path: filepath.Join("testdata", "full.json"), - expectedUsername: "docker", - expectedPassword: "io", + name: "match docker.io", + hostname: "docker.io", + path: filepath.Join("testdata", "full.json"), + expected: types.DockerAuthConfig{ + Username: "docker", + Password: "io", + }, }, { - name: "match docker.io normalized", - hostname: "docker.io", - path: filepath.Join("testdata", "abnormal.json"), - expectedUsername: "index", - expectedPassword: "docker.io", + name: "match docker.io normalized", + hostname: "docker.io", + path: filepath.Join("testdata", "abnormal.json"), + expected: types.DockerAuthConfig{ + Username: "index", + Password: "docker.io", + }, }, { - name: "normalize registry", - hostname: "https://example.org/v1", - path: filepath.Join("testdata", "full.json"), - expectedUsername: "example", - expectedPassword: "org", + name: "normalize registry", + hostname: "https://example.org/v1", + path: filepath.Join("testdata", "full.json"), + expected: types.DockerAuthConfig{ + Username: "example", + Password: "org", + }, }, { - name: "match localhost", - hostname: "http://localhost", - path: filepath.Join("testdata", "full.json"), - expectedUsername: "local", - expectedPassword: "host", + name: "match localhost", + hostname: "http://localhost", + path: filepath.Join("testdata", "full.json"), + expected: types.DockerAuthConfig{ + Username: "local", + Password: "host", + }, }, { - name: "match ip", - hostname: "10.10.30.45:5000", - path: filepath.Join("testdata", "full.json"), - expectedUsername: "10.10", - expectedPassword: "30.45-5000", + name: "match ip", + hostname: "10.10.30.45:5000", + path: filepath.Join("testdata", "full.json"), + expected: types.DockerAuthConfig{ + Username: "10.10", + Password: "30.45-5000", + }, }, { - name: "match port", - hostname: "https://localhost:5000", - path: filepath.Join("testdata", "abnormal.json"), - expectedUsername: "local", - expectedPassword: "host-5000", + name: "match port", + hostname: "https://localhost:5000", + path: filepath.Join("testdata", "abnormal.json"), + expected: types.DockerAuthConfig{ + Username: "local", + Password: "host-5000", + }, }, { - name: "use system context", - hostname: "example.org", - path: filepath.Join("testdata", "example.json"), - expectedUsername: "foo", - expectedPassword: "bar", + name: "use system context", + hostname: "example.org", + path: filepath.Join("testdata", "example.json"), + expected: types.DockerAuthConfig{ + Username: "foo", + Password: "bar", + }, sys: &types.SystemContext{ DockerAuthConfig: &types.DockerAuthConfig{ Username: "foo", @@ -193,14 +208,22 @@ func TestGetAuth(t *testing.T) { }, }, }, + { + name: "identity token", + hostname: "example.org", + path: filepath.Join("testdata", "example_identitytoken.json"), + expected: types.DockerAuthConfig{ + Username: "00000000-0000-0000-0000-000000000000", + Password: "", + IdentityToken: "some very long identity token", + }, + }, } { - if tc.path == "" { + t.Run(tc.name, func(t *testing.T) { if err := os.RemoveAll(configPath); err != nil { t.Fatal(err) } - } - t.Run(tc.name, func(t *testing.T) { if tc.path != "" { contents, err := ioutil.ReadFile(tc.path) if err != nil { @@ -216,10 +239,23 @@ func TestGetAuth(t *testing.T) { if tc.sys != nil { sys = tc.sys } - username, password, err := GetAuthentication(sys, tc.hostname) + auth, err := GetCredentials(sys, tc.hostname) assert.Equal(t, tc.expectedError, err) - assert.Equal(t, tc.expectedUsername, username) - assert.Equal(t, tc.expectedPassword, password) + assert.Equal(t, tc.expected, auth) + + // Test for the previous APIs. + username, password, err := GetAuthentication(sys, tc.hostname) + if tc.expected.IdentityToken != "" { + assert.Equal(t, "", username) + assert.Equal(t, "", password) + assert.Error(t, err) + } else { + assert.Equal(t, tc.expected.Username, username) + assert.Equal(t, tc.expected.Password, password) + assert.Equal(t, tc.expectedError, err) + } + + require.NoError(t, os.RemoveAll(configPath)) }) } } @@ -249,23 +285,26 @@ func TestGetAuthFromLegacyFile(t *testing.T) { } for _, tc := range []struct { - name string - hostname string - expectedUsername string - expectedPassword string - expectedError error + name string + hostname string + expected types.DockerAuthConfig + expectedError error }{ { - name: "normalize registry", - hostname: "https://docker.io/v1", - expectedUsername: "docker", - expectedPassword: "io-legacy", + name: "normalize registry", + hostname: "https://docker.io/v1", + expected: types.DockerAuthConfig{ + Username: "docker", + Password: "io-legacy", + }, }, { - name: "ignore schema and path", - hostname: "http://index.docker.io/v1", - expectedUsername: "docker", - expectedPassword: "io-legacy", + name: "ignore schema and path", + hostname: "http://index.docker.io/v1", + expected: types.DockerAuthConfig{ + Username: "docker", + Password: "io-legacy", + }, }, } { t.Run(tc.name, func(t *testing.T) { @@ -273,10 +312,15 @@ func TestGetAuthFromLegacyFile(t *testing.T) { t.Fatal(err) } + auth, err := GetCredentials(nil, tc.hostname) + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expected, auth) + + // Testing for previous APIs username, password, err := GetAuthentication(nil, tc.hostname) assert.Equal(t, tc.expectedError, err) - assert.Equal(t, tc.expectedUsername, username) - assert.Equal(t, tc.expectedPassword, password) + assert.Equal(t, tc.expected.Username, username) + assert.Equal(t, tc.expected.Password, password) }) } } @@ -326,10 +370,10 @@ func TestGetAuthPreferNewConfig(t *testing.T) { } } - username, password, err := GetAuthentication(nil, "docker.io") - assert.Equal(t, nil, err) - assert.Equal(t, "docker", username) - assert.Equal(t, "io", password) + auth, err := GetCredentials(nil, "docker.io") + assert.NoError(t, err) + assert.Equal(t, "docker", auth.Username) + assert.Equal(t, "io", auth.Password) } func TestGetAuthFailsOnBadInput(t *testing.T) { @@ -372,20 +416,18 @@ func TestGetAuthFailsOnBadInput(t *testing.T) { configPath := filepath.Join(configDir, "auth.json") // no config file present - username, password, err := GetAuthentication(nil, "index.docker.io") + auth, err := GetCredentials(nil, "index.docker.io") if err != nil { t.Fatalf("got unexpected error: %#+v", err) } - if len(username) > 0 || len(password) > 0 { - t.Fatalf("got unexpected not empty username/password: %q/%q", username, password) - } + assert.Equal(t, types.DockerAuthConfig{}, auth) if err := ioutil.WriteFile(configPath, []byte("Json rocks! Unless it doesn't."), 0640); err != nil { t.Fatalf("failed to write file %q: %v", configPath, err) } - username, password, err = GetAuthentication(nil, "index.docker.io") + auth, err = GetCredentials(nil, "index.docker.io") if err == nil { - t.Fatalf("got unexpected non-error: username=%q, password=%q", username, password) + t.Fatalf("got unexpected non-error: username=%q, password=%q", auth.Username, auth.Password) } if _, ok := errors.Cause(err).(*json.SyntaxError); !ok { t.Fatalf("expected JSON syntax error, not: %#+v", err) @@ -394,21 +436,19 @@ func TestGetAuthFailsOnBadInput(t *testing.T) { // remove the invalid config file os.RemoveAll(configPath) // no config file present - username, password, err = GetAuthentication(nil, "index.docker.io") + auth, err = GetCredentials(nil, "index.docker.io") if err != nil { t.Fatalf("got unexpected error: %#+v", err) } - if len(username) > 0 || len(password) > 0 { - t.Fatalf("got unexpected not empty username/password: %q/%q", username, password) - } + assert.Equal(t, types.DockerAuthConfig{}, auth) configPath = filepath.Join(tmpDir2, ".dockercfg") if err := ioutil.WriteFile(configPath, []byte("I'm certainly not a json string."), 0640); err != nil { t.Fatalf("failed to write file %q: %v", configPath, err) } - username, password, err = GetAuthentication(nil, "index.docker.io") + auth, err = GetCredentials(nil, "index.docker.io") if err == nil { - t.Fatalf("got unexpected non-error: username=%q, password=%q", username, password) + t.Fatalf("got unexpected non-error: username=%q, password=%q", auth.Username, auth.Password) } if _, ok := errors.Cause(err).(*json.SyntaxError); !ok { t.Fatalf("expected JSON syntax error, not: %#+v", err) diff --git a/pkg/docker/config/testdata/example_identitytoken.json b/pkg/docker/config/testdata/example_identitytoken.json new file mode 100644 index 000000000..bfcf48907 --- /dev/null +++ b/pkg/docker/config/testdata/example_identitytoken.json @@ -0,0 +1,8 @@ +{ + "auths": { + "example.org": { + "auth": "MDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAwMDAwMDAwOg==", + "identitytoken": "some very long identity token" + } + } +} \ No newline at end of file diff --git a/types/types.go b/types/types.go index a6d862415..cb26341c3 100644 --- a/types/types.go +++ b/types/types.go @@ -450,6 +450,11 @@ type ImageInspectInfo struct { type DockerAuthConfig struct { Username string Password string + // IdentityToken can be used as an refresh_token in place of username and + // password to obtain the bearer/access token in oauth2 flow. If identity + // token is set, password should not be set. + // Ref: https://docs.docker.com/registry/spec/auth/oauth/ + IdentityToken string } // OptionalBool is a boolean with an additional undefined value, which is meant