diff --git a/README.md b/README.md index 854a0ce9f6..d75b159b97 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Dex implements the following connectors: | [SAML 2.0](https://dexidp.io/docs/connectors/saml/) | no | yes | no | stable | WARNING: Unmaintained and likely vulnerable to auth bypasses ([#1884](https://github.com/dexidp/dex/discussions/1884)) | | [GitLab](https://dexidp.io/docs/connectors/gitlab/) | yes | yes | yes | beta | | | [OpenID Connect](https://dexidp.io/docs/connectors/oidc/) | yes | yes | yes | beta | Includes Salesforce, Azure, etc. | +| [OAuth 2.0](https://dexidp.io/docs/connectors/oauth/) | no | yes | yes | alpha | | | [Google](https://dexidp.io/docs/connectors/google/) | yes | yes | yes | alpha | | | [LinkedIn](https://dexidp.io/docs/connectors/linkedin/) | yes | no | no | beta | | | [Microsoft](https://dexidp.io/docs/connectors/microsoft/) | yes | yes | no | beta | | diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 88dc98e720..60c9d8ba26 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -10,13 +10,13 @@ import ( "golang.org/x/crypto/bcrypt" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/server" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/etcd" - "github.com/dexidp/dex/storage/kubernetes" - "github.com/dexidp/dex/storage/memory" - "github.com/dexidp/dex/storage/sql" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/server" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/etcd" + "github.com/concourse/dex/storage/kubernetes" + "github.com/concourse/dex/storage/memory" + "github.com/concourse/dex/storage/sql" ) // Config is the config format for the main application. diff --git a/cmd/dex/config_test.go b/cmd/dex/config_test.go index 8ee02d5aa2..2f7bc8447e 100644 --- a/cmd/dex/config_test.go +++ b/cmd/dex/config_test.go @@ -7,11 +7,11 @@ import ( "github.com/ghodss/yaml" "github.com/kylelemons/godebug/pretty" - "github.com/dexidp/dex/connector/mock" - "github.com/dexidp/dex/connector/oidc" - "github.com/dexidp/dex/server" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/sql" + "github.com/concourse/dex/connector/mock" + "github.com/concourse/dex/connector/oidc" + "github.com/concourse/dex/server" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/sql" ) var _ = yaml.YAMLToJSON diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index bd7869c427..6e8d0081dc 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -29,9 +29,9 @@ import ( "google.golang.org/grpc/reflection" "github.com/dexidp/dex/api/v2" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/server" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/server" + "github.com/concourse/dex/storage" ) type serveOptions struct { diff --git a/cmd/dex/version.go b/cmd/dex/version.go index de206e16d8..85ff30e583 100644 --- a/cmd/dex/version.go +++ b/cmd/dex/version.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/dexidp/dex/version" + "github.com/concourse/dex/version" ) func commandVersion() *cobra.Command { diff --git a/connector/atlassiancrowd/atlassiancrowd.go b/connector/atlassiancrowd/atlassiancrowd.go index 6f03406086..f37b7db574 100644 --- a/connector/atlassiancrowd/atlassiancrowd.go +++ b/connector/atlassiancrowd/atlassiancrowd.go @@ -13,9 +13,9 @@ import ( "strings" "time" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/groups" + "github.com/concourse/dex/pkg/log" ) // Config holds configuration options for Atlassian Crowd connector. diff --git a/connector/authproxy/authproxy.go b/connector/authproxy/authproxy.go index 853e5ee29f..db71149e64 100644 --- a/connector/authproxy/authproxy.go +++ b/connector/authproxy/authproxy.go @@ -8,8 +8,8 @@ import ( "net/http" "net/url" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/log" ) // Config holds the configuration parameters for a connector which returns an diff --git a/connector/bitbucketcloud/bitbucketcloud.go b/connector/bitbucketcloud/bitbucketcloud.go index e81893da07..0f9adb916d 100644 --- a/connector/bitbucketcloud/bitbucketcloud.go +++ b/connector/bitbucketcloud/bitbucketcloud.go @@ -14,9 +14,9 @@ import ( "golang.org/x/oauth2" "golang.org/x/oauth2/bitbucket" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/groups" + "github.com/concourse/dex/pkg/log" ) const ( diff --git a/connector/bitbucketcloud/bitbucketcloud_test.go b/connector/bitbucketcloud/bitbucketcloud_test.go index 3d984a8fcb..8c7b3858c6 100644 --- a/connector/bitbucketcloud/bitbucketcloud_test.go +++ b/connector/bitbucketcloud/bitbucketcloud_test.go @@ -10,7 +10,7 @@ import ( "reflect" "testing" - "github.com/dexidp/dex/connector" + "github.com/concourse/dex/connector" ) func TestUserGroups(t *testing.T) { diff --git a/connector/cf/cf.go b/connector/cf/cf.go new file mode 100644 index 0000000000..998315af4e --- /dev/null +++ b/connector/cf/cf.go @@ -0,0 +1,394 @@ +package cf + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net" + "net/http" + "sort" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/log" +) + +type cfConnector struct { + clientID string + clientSecret string + redirectURI string + apiURL string + tokenURL string + authorizationURL string + userInfoURL string + httpClient *http.Client + logger log.Logger +} + +type connectorData struct { + AccessToken string +} + +type Config struct { + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectURI"` + APIURL string `json:"apiURL"` + RootCAs []string `json:"rootCAs"` + InsecureSkipVerify bool `json:"insecureSkipVerify"` +} + +type CCResponse struct { + NextURL string `json:"next_url"` + Resources []Resource `json:"resources"` + TotalResults int `json:"total_results"` +} + +type Resource struct { + Metadata Metadata `json:"metadata"` + Entity Entity `json:"entity"` +} + +type Metadata struct { + GUID string `json:"guid"` +} + +type Entity struct { + Name string `json:"name"` + OrganizationGUID string `json:"organization_guid"` +} + +type Space struct { + Name string + GUID string + OrgGUID string + Role string +} + +type Org struct { + Name string + GUID string +} + +func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { + var err error + + cfConn := &cfConnector{ + clientID: c.ClientID, + clientSecret: c.ClientSecret, + apiURL: c.APIURL, + redirectURI: c.RedirectURI, + logger: logger, + } + + cfConn.httpClient, err = newHTTPClient(c.RootCAs, c.InsecureSkipVerify) + if err != nil { + return nil, err + } + + apiURL := strings.TrimRight(c.APIURL, "/") + apiResp, err := cfConn.httpClient.Get(fmt.Sprintf("%s/v2/info", apiURL)) + if err != nil { + logger.Errorf("failed-to-send-request-to-cloud-controller-api", err) + return nil, err + } + + defer apiResp.Body.Close() + + if apiResp.StatusCode != http.StatusOK { + err = fmt.Errorf("request failed with status %d", apiResp.StatusCode) + logger.Errorf("failed-get-info-response-from-api", err) + return nil, err + } + + var apiResult map[string]interface{} + json.NewDecoder(apiResp.Body).Decode(&apiResult) + + uaaURL := strings.TrimRight(apiResult["authorization_endpoint"].(string), "/") + uaaResp, err := cfConn.httpClient.Get(fmt.Sprintf("%s/.well-known/openid-configuration", uaaURL)) + if err != nil { + logger.Errorf("failed-to-send-request-to-uaa-api", err) + return nil, err + } + + if apiResp.StatusCode != http.StatusOK { + err = fmt.Errorf("request failed with status %d", apiResp.StatusCode) + logger.Errorf("failed-to-get-well-known-config-response-from-api", err) + return nil, err + } + + defer uaaResp.Body.Close() + + var uaaResult map[string]interface{} + err = json.NewDecoder(uaaResp.Body).Decode(&uaaResult) + + if err != nil { + logger.Errorf("failed-to-decode-response-from-uaa-api", err) + return nil, err + } + + cfConn.tokenURL, _ = uaaResult["token_endpoint"].(string) + cfConn.authorizationURL, _ = uaaResult["authorization_endpoint"].(string) + cfConn.userInfoURL, _ = uaaResult["userinfo_endpoint"].(string) + + return cfConn, err +} + +func newHTTPClient(rootCAs []string, insecureSkipVerify bool) (*http.Client, error) { + pool, err := x509.SystemCertPool() + if err != nil { + return nil, err + } + + tlsConfig := tls.Config{RootCAs: pool, InsecureSkipVerify: insecureSkipVerify} + for _, rootCA := range rootCAs { + rootCABytes, err := ioutil.ReadFile(rootCA) + if err != nil { + return nil, fmt.Errorf("failed to read root-ca: %v", err) + } + if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) { + return nil, fmt.Errorf("no certs found in root CA file %q", rootCA) + } + } + + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tlsConfig, + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + }, nil +} + +func (c *cfConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { + if c.redirectURI != callbackURL { + return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) + } + + oauth2Config := &oauth2.Config{ + ClientID: c.clientID, + ClientSecret: c.clientSecret, + Endpoint: oauth2.Endpoint{TokenURL: c.tokenURL, AuthURL: c.authorizationURL}, + RedirectURL: c.redirectURI, + Scopes: []string{"openid", "cloud_controller.read"}, + } + + return oauth2Config.AuthCodeURL(state), nil +} + +func fetchRoleSpaces(baseURL, path, role string, client *http.Client) ([]Space, error) { + resources, err := fetchResources(baseURL, path, client) + if err != nil { + return nil, fmt.Errorf("failed to fetch resources: %v", err) + } + + spaces := make([]Space, len(resources)) + for i, resource := range resources { + spaces[i] = Space{ + Name: resource.Entity.Name, + GUID: resource.Metadata.GUID, + OrgGUID: resource.Entity.OrganizationGUID, + Role: role, + } + } + + return spaces, nil +} + +func fetchOrgs(baseURL, path string, client *http.Client) ([]Org, error) { + resources, err := fetchResources(baseURL, path, client) + if err != nil { + return nil, fmt.Errorf("failed to fetch resources: %v", err) + } + + orgs := make([]Org, len(resources)) + for i, resource := range resources { + orgs[i] = Org{ + Name: resource.Entity.Name, + GUID: resource.Metadata.GUID, + } + } + + return orgs, nil +} + +func fetchResources(baseURL, path string, client *http.Client) ([]Resource, error) { + var ( + resources []Resource + url string + ) + + for { + url = fmt.Sprintf("%s%s", baseURL, path) + + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unsuccessful status code %d", resp.StatusCode) + } + + response := CCResponse{} + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, fmt.Errorf("failed to parse spaces: %v", err) + } + + resources = append(resources, response.Resources...) + + path = response.NextURL + if path == "" { + break + } + } + + return resources, nil +} + +func getGroupsClaims(orgs []Org, spaces []Space) []string { + var ( + orgMap = map[string]string{} + orgSpaces = map[string][]Space{} + groupsClaims = map[string]bool{} + ) + + for _, org := range orgs { + orgMap[org.GUID] = org.Name + orgSpaces[org.Name] = []Space{} + groupsClaims[org.GUID] = true + groupsClaims[org.Name] = true + } + + for _, space := range spaces { + orgName := orgMap[space.OrgGUID] + orgSpaces[orgName] = append(orgSpaces[orgName], space) + groupsClaims[space.GUID] = true + groupsClaims[fmt.Sprintf("%s:%s", space.GUID, space.Role)] = true + } + + for orgName, spaces := range orgSpaces { + for _, space := range spaces { + groupsClaims[fmt.Sprintf("%s:%s", orgName, space.Name)] = true + groupsClaims[fmt.Sprintf("%s:%s:%s", orgName, space.Name, space.Role)] = true + } + } + + groups := make([]string, 0, len(groupsClaims)) + for group := range groupsClaims { + groups = append(groups, group) + } + + sort.Strings(groups) + + return groups +} + +func (c *cfConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { + q := r.URL.Query() + if errType := q.Get("error"); errType != "" { + return identity, errors.New(q.Get("error_description")) + } + + oauth2Config := &oauth2.Config{ + ClientID: c.clientID, + ClientSecret: c.clientSecret, + Endpoint: oauth2.Endpoint{TokenURL: c.tokenURL, AuthURL: c.authorizationURL}, + RedirectURL: c.redirectURI, + Scopes: []string{"openid", "cloud_controller.read"}, + } + + ctx := context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient) + + token, err := oauth2Config.Exchange(ctx, q.Get("code")) + if err != nil { + return identity, fmt.Errorf("CF connector: failed to get token: %v", err) + } + + client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) + + userInfoResp, err := client.Get(c.userInfoURL) + if err != nil { + return identity, fmt.Errorf("CF Connector: failed to execute request to userinfo: %v", err) + } + + if userInfoResp.StatusCode != http.StatusOK { + return identity, fmt.Errorf("CF Connector: failed to execute request to userinfo: status %d", userInfoResp.StatusCode) + } + + defer userInfoResp.Body.Close() + + var userInfoResult map[string]interface{} + err = json.NewDecoder(userInfoResp.Body).Decode(&userInfoResult) + + if err != nil { + return identity, fmt.Errorf("CF Connector: failed to parse userinfo: %v", err) + } + + identity.UserID, _ = userInfoResult["user_id"].(string) + identity.Username, _ = userInfoResult["user_name"].(string) + identity.PreferredUsername, _ = userInfoResult["user_name"].(string) + identity.Email, _ = userInfoResult["email"].(string) + identity.EmailVerified, _ = userInfoResult["email_verified"].(bool) + + var ( + devPath = fmt.Sprintf("/v2/users/%s/spaces", identity.UserID) + auditorPath = fmt.Sprintf("/v2/users/%s/audited_spaces", identity.UserID) + managerPath = fmt.Sprintf("/v2/users/%s/managed_spaces", identity.UserID) + orgsPath = fmt.Sprintf("/v2/users/%s/organizations", identity.UserID) + ) + + if s.Groups { + orgs, err := fetchOrgs(c.apiURL, orgsPath, client) + if err != nil { + return identity, fmt.Errorf("failed to fetch organizaitons: %v", err) + } + + developerSpaces, err := fetchRoleSpaces(c.apiURL, devPath, "developer", client) + if err != nil { + return identity, fmt.Errorf("failed to fetch spaces for developer roles: %v", err) + } + + auditorSpaces, err := fetchRoleSpaces(c.apiURL, auditorPath, "auditor", client) + if err != nil { + return identity, fmt.Errorf("failed to fetch spaces for developer roles: %v", err) + } + + managerSpaces, err := fetchRoleSpaces(c.apiURL, managerPath, "manager", client) + if err != nil { + return identity, fmt.Errorf("failed to fetch spaces for developer roles: %v", err) + } + + spaces := append(developerSpaces, append(auditorSpaces, managerSpaces...)...) + + identity.Groups = getGroupsClaims(orgs, spaces) + } + + if s.OfflineAccess { + data := connectorData{AccessToken: token.AccessToken} + connData, err := json.Marshal(data) + if err != nil { + return identity, fmt.Errorf("CF Connector: failed to parse connector data for offline access: %v", err) + } + identity.ConnectorData = connData + } + + return identity, nil +} diff --git a/connector/cf/cf_test.go b/connector/cf/cf_test.go new file mode 100644 index 0000000000..a1acb756e6 --- /dev/null +++ b/connector/cf/cf_test.go @@ -0,0 +1,259 @@ +package cf + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/sirupsen/logrus" + + "github.com/concourse/dex/connector" +) + +func TestOpen(t *testing.T) { + testServer := testSetup() + defer testServer.Close() + + conn := newConnector(t, testServer.URL) + + expectEqual(t, conn.clientID, "test-client") + expectEqual(t, conn.clientSecret, "secret") + expectEqual(t, conn.redirectURI, testServer.URL+"/callback") +} + +func TestHandleCallback(t *testing.T) { + testServer := testSetup() + defer testServer.Close() + + cfConn := &cfConnector{ + tokenURL: fmt.Sprintf("%s/token", testServer.URL), + authorizationURL: fmt.Sprintf("%s/authorize", testServer.URL), + userInfoURL: fmt.Sprintf("%s/userinfo", testServer.URL), + apiURL: testServer.URL, + clientSecret: "secret", + clientID: "test-client", + redirectURI: "localhost:8080/sky/dex/callback", + httpClient: http.DefaultClient, + } + + req, err := http.NewRequest("GET", testServer.URL, nil) + expectEqual(t, err, nil) + + t.Run("CallbackWithGroupsScope", func(t *testing.T) { + identity, err := cfConn.HandleCallback(connector.Scopes{Groups: true}, req) + expectEqual(t, err, nil) + + expectEqual(t, len(identity.Groups), 24) + expectEqual(t, identity.Groups[0], "some-org-guid-1") + expectEqual(t, identity.Groups[1], "some-org-guid-2") + expectEqual(t, identity.Groups[2], "some-org-guid-3") + expectEqual(t, identity.Groups[3], "some-org-guid-4") + expectEqual(t, identity.Groups[4], "some-org-name-1") + expectEqual(t, identity.Groups[5], "some-org-name-1:some-space-name-1") + expectEqual(t, identity.Groups[6], "some-org-name-1:some-space-name-1:auditor") + expectEqual(t, identity.Groups[7], "some-org-name-1:some-space-name-1:developer") + expectEqual(t, identity.Groups[8], "some-org-name-1:some-space-name-1:manager") + expectEqual(t, identity.Groups[9], "some-org-name-2") + expectEqual(t, identity.Groups[10], "some-org-name-2:some-space-name-2") + expectEqual(t, identity.Groups[11], "some-org-name-2:some-space-name-2:auditor") + expectEqual(t, identity.Groups[12], "some-org-name-2:some-space-name-2:developer") + expectEqual(t, identity.Groups[13], "some-org-name-2:some-space-name-2:manager") + expectEqual(t, identity.Groups[14], "some-org-name-3") + expectEqual(t, identity.Groups[15], "some-org-name-4") + expectEqual(t, identity.Groups[16], "some-space-guid-1") + expectEqual(t, identity.Groups[17], "some-space-guid-1:auditor") + expectEqual(t, identity.Groups[18], "some-space-guid-1:developer") + expectEqual(t, identity.Groups[19], "some-space-guid-1:manager") + expectEqual(t, identity.Groups[20], "some-space-guid-2") + expectEqual(t, identity.Groups[21], "some-space-guid-2:auditor") + expectEqual(t, identity.Groups[22], "some-space-guid-2:developer") + expectEqual(t, identity.Groups[23], "some-space-guid-2:manager") + }) + + t.Run("CallbackWithoutGroupsScope", func(t *testing.T) { + identity, err := cfConn.HandleCallback(connector.Scopes{}, req) + + expectEqual(t, err, nil) + expectEqual(t, identity.UserID, "12345") + expectEqual(t, identity.Username, "test-user") + }) + + t.Run("CallbackWithOfflineAccessScope", func(t *testing.T) { + identity, err := cfConn.HandleCallback(connector.Scopes{OfflineAccess: true}, req) + + expectEqual(t, err, nil) + expectNotEqual(t, len(identity.ConnectorData), 0) + + cData := connectorData{} + err = json.Unmarshal(identity.ConnectorData, &cData) + + expectEqual(t, err, nil) + expectNotEqual(t, cData.AccessToken, "") + }) +} + +func testSpaceHandler(reqURL, spaceAPIEndpoint string) (result map[string]interface{}) { + fullURL := fmt.Sprintf("%s?order-direction=asc&page=2&results-per-page=50", spaceAPIEndpoint) + if strings.Contains(reqURL, fullURL) { + result = map[string]interface{}{ + "resources": []map[string]interface{}{ + { + "metadata": map[string]string{"guid": "some-space-guid-2"}, + "entity": map[string]string{"name": "some-space-name-2", "organization_guid": "some-org-guid-2"}, + }, + }, + } + } else { + nextURL := fmt.Sprintf("/v2/users/12345/%s?order-direction=asc&page=2&results-per-page=50", spaceAPIEndpoint) + result = map[string]interface{}{ + "next_url": nextURL, + "resources": []map[string]interface{}{ + { + "metadata": map[string]string{"guid": "some-space-guid-1"}, + "entity": map[string]string{"name": "some-space-name-1", "organization_guid": "some-org-guid-1"}, + }, + }, + } + } + return result +} + +func testOrgHandler(reqURL string) (result map[string]interface{}) { + if strings.Contains(reqURL, "organizations?order-direction=asc&page=2&results-per-page=50") { + result = map[string]interface{}{ + "resources": []map[string]interface{}{ + { + "metadata": map[string]string{"guid": "some-org-guid-3"}, + "entity": map[string]string{"name": "some-org-name-3"}, + }, + { + "metadata": map[string]string{"guid": "some-org-guid-4"}, + "entity": map[string]string{"name": "some-org-name-4"}, + }, + }, + } + } else { + result = map[string]interface{}{ + "next_url": "/v2/users/12345/organizations?order-direction=asc&page=2&results-per-page=50", + "resources": []map[string]interface{}{ + { + "metadata": map[string]string{"guid": "some-org-guid-1"}, + "entity": map[string]string{"name": "some-org-name-1"}, + }, + { + "metadata": map[string]string{"guid": "some-org-guid-2"}, + "entity": map[string]string{"name": "some-org-name-2"}, + }, + }, + } + } + return result +} + +func testSetup() *httptest.Server { + mux := http.NewServeMux() + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + token := "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0xIiwidHlwIjoiSldUIn0.eyJqdGkiOiIxMjk4MTNhZjJiNGM0ZDNhYmYyNjljMzM4OTFkZjNiZCIsInN1YiI6ImNmMWFlODk4LWQ1ODctNDBhYS1hNWRiLTE5ZTY3MjI0N2I1NyIsInNjb3BlIjpbImNsb3VkX2NvbnRyb2xsZXIucmVhZCIsIm9wZW5pZCJdLCJjbGllbnRfaWQiOiJjb25jb3Vyc2UiLCJjaWQiOiJjb25jb3Vyc2UiLCJhenAiOiJjb25jb3Vyc2UiLCJncmFudF90eXBlIjoiYXV0aG9yaXphdGlvbl9jb2RlIiwidXNlcl9pZCI6ImNmMWFlODk4LWQ1ODctNDBhYS1hNWRiLTE5ZTY3MjI0N2I1NyIsIm9yaWdpbiI6InVhYSIsInVzZXJfbmFtZSI6ImFkbWluIiwiZW1haWwiOiJhZG1pbiIsImF1dGhfdGltZSI6MTUyMzM3NDIwNCwicmV2X3NpZyI6IjYxNWJjMTk0IiwiaWF0IjoxNTIzMzc3MTUyLCJleHAiOjE1MjM0MjAzNTIsImlzcyI6Imh0dHBzOi8vdWFhLnN0eXgucHVzaC5nY3AuY2YtYXBwLmNvbS9vYXV0aC90b2tlbiIsInppZCI6InVhYSIsImF1ZCI6WyJjbG91ZF9jb250cm9sbGVyIiwiY29uY291cnNlIiwib3BlbmlkIl19.FslbnwvW0WScVRNK8IWghRX0buXfl6qaI1K7z_dzjPUVrdEyMtaYa3kJI8srA-2G1PjSSEWa_3Vzs_BEnTc3iG0JQWU0XlcjdCdAFTvnmKiHSzffy1O_oGYyH47KXtnZOxHf3rdV_Xgw4XTqPrfKXQxnPemUAJyKf2tjgs3XToGaqqBw-D_2BQVY79kF0_GgksQsViqq1GW0Dur6m2CgBhtc2h1AQGO16izXl3uNbpW6ClhaW43NQXlE4wqtr7kfmxyOigHJb2MSQ3wwPc6pqYdUT6ka_TMqavqbxEJ4QcS6SoEcVsDTmEQ4c8dmWUgXM0AZjd0CaEGTB6FDHxH5sw" + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "access_token": token, + }) + }) + + mux.HandleFunc("/v2/info", func(w http.ResponseWriter, r *http.Request) { + url := fmt.Sprintf("http://%s", r.Host) + + json.NewEncoder(w).Encode(map[string]string{ + "authorization_endpoint": url, + }) + }) + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + url := fmt.Sprintf("http://%s", r.Host) + + json.NewEncoder(w).Encode(map[string]string{ + "token_endpoint": url, + "authorization_endpoint": url, + "userinfo_endpoint": url, + }) + }) + + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + }) + + mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{ + "user_id": "12345", + "user_name": "test-user", + "email": "blah-email", + }) + }) + + mux.HandleFunc("/v2/users/", func(w http.ResponseWriter, r *http.Request) { + var result map[string]interface{} + + reqURL := r.URL.String() + if strings.Contains(reqURL, "/spaces") { + result = testSpaceHandler(reqURL, "spaces") + } + + if strings.Contains(reqURL, "/audited_spaces") { + result = testSpaceHandler(reqURL, "audited_spaces") + } + + if strings.Contains(reqURL, "/managed_spaces") { + result = testSpaceHandler(reqURL, "managed_spaces") + } + + if strings.Contains(reqURL, "organizations") { + result = testOrgHandler(reqURL) + } + + json.NewEncoder(w).Encode(result) + }) + + return httptest.NewServer(mux) +} + +func newConnector(t *testing.T, serverURL string) *cfConnector { + callBackURL := fmt.Sprintf("%s/callback", serverURL) + + testConfig := Config{ + APIURL: serverURL, + ClientID: "test-client", + ClientSecret: "secret", + RedirectURI: callBackURL, + InsecureSkipVerify: true, + } + + log := logrus.New() + + conn, err := testConfig.Open("id", log) + if err != nil { + t.Fatal(err) + } + + cfConn, ok := conn.(*cfConnector) + if !ok { + t.Fatal(errors.New("it is not a cf conn")) + } + + return cfConn +} + +func expectEqual(t *testing.T, a interface{}, b interface{}) { + if !reflect.DeepEqual(a, b) { + t.Fatalf("Expected %+v to equal %+v", a, b) + } +} + +func expectNotEqual(t *testing.T, a interface{}, b interface{}) { + if reflect.DeepEqual(a, b) { + t.Fatalf("Expected %+v to NOT equal %+v", a, b) + } +} diff --git a/connector/gitea/gitea.go b/connector/gitea/gitea.go index 33cc3126e6..308550adee 100644 --- a/connector/gitea/gitea.go +++ b/connector/gitea/gitea.go @@ -14,8 +14,8 @@ import ( "golang.org/x/oauth2" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/log" ) // Config holds configuration options for gitea logins. diff --git a/connector/gitea/gitea_test.go b/connector/gitea/gitea_test.go index a71d79956e..c4576d0fd2 100644 --- a/connector/gitea/gitea_test.go +++ b/connector/gitea/gitea_test.go @@ -9,7 +9,7 @@ import ( "reflect" "testing" - "github.com/dexidp/dex/connector" + "github.com/concourse/dex/connector" ) // tests that the email is used as their username when they have no username set diff --git a/connector/github/github.go b/connector/github/github.go index 02f2cae804..a1291af5de 100644 --- a/connector/github/github.go +++ b/connector/github/github.go @@ -19,9 +19,9 @@ import ( "golang.org/x/oauth2" "golang.org/x/oauth2/github" - "github.com/dexidp/dex/connector" - groups_pkg "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + groups_pkg "github.com/concourse/dex/pkg/groups" + "github.com/concourse/dex/pkg/log" ) const ( diff --git a/connector/github/github_test.go b/connector/github/github_test.go index 76d7463cf6..b9d10d7d27 100644 --- a/connector/github/github_test.go +++ b/connector/github/github_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "github.com/dexidp/dex/connector" + "github.com/concourse/dex/connector" ) type testResponse struct { diff --git a/connector/gitlab/gitlab.go b/connector/gitlab/gitlab.go index e40601402d..e70f1eaded 100644 --- a/connector/gitlab/gitlab.go +++ b/connector/gitlab/gitlab.go @@ -12,9 +12,9 @@ import ( "golang.org/x/oauth2" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/groups" + "github.com/concourse/dex/pkg/log" ) const ( diff --git a/connector/gitlab/gitlab_test.go b/connector/gitlab/gitlab_test.go index 23cf9aac2d..266e6dffce 100644 --- a/connector/gitlab/gitlab_test.go +++ b/connector/gitlab/gitlab_test.go @@ -10,7 +10,7 @@ import ( "reflect" "testing" - "github.com/dexidp/dex/connector" + "github.com/concourse/dex/connector" ) func TestUserGroups(t *testing.T) { diff --git a/connector/google/google.go b/connector/google/google.go index eccb1fc7d7..747274dfdb 100644 --- a/connector/google/google.go +++ b/connector/google/google.go @@ -15,9 +15,9 @@ import ( admin "google.golang.org/api/admin/directory/v1" "google.golang.org/api/option" - "github.com/dexidp/dex/connector" - pkg_groups "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + pkg_groups "github.com/concourse/dex/pkg/groups" + "github.com/concourse/dex/pkg/log" ) const ( diff --git a/connector/keystone/keystone.go b/connector/keystone/keystone.go index d817bd9454..7b87de2e47 100644 --- a/connector/keystone/keystone.go +++ b/connector/keystone/keystone.go @@ -9,8 +9,8 @@ import ( "io/ioutil" "net/http" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/log" ) type conn struct { diff --git a/connector/keystone/keystone_test.go b/connector/keystone/keystone_test.go index b138012426..a14728bbb4 100644 --- a/connector/keystone/keystone_test.go +++ b/connector/keystone/keystone_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "github.com/dexidp/dex/connector" + "github.com/concourse/dex/connector" ) const ( diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go index 2325b25ace..1733e1c88b 100644 --- a/connector/ldap/ldap.go +++ b/connector/ldap/ldap.go @@ -12,8 +12,8 @@ import ( "gopkg.in/ldap.v2" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/log" ) // Config holds the configuration parameters for the LDAP connector. The LDAP diff --git a/connector/ldap/ldap_test.go b/connector/ldap/ldap_test.go index 9ae4567431..9bbe1ac3a5 100644 --- a/connector/ldap/ldap_test.go +++ b/connector/ldap/ldap_test.go @@ -10,7 +10,7 @@ import ( "github.com/kylelemons/godebug/pretty" "github.com/sirupsen/logrus" - "github.com/dexidp/dex/connector" + "github.com/concourse/dex/connector" ) // connectionMethod indicates how the test should connect to the LDAP server. diff --git a/connector/linkedin/linkedin.go b/connector/linkedin/linkedin.go index 1c8312c11e..6c2821af00 100644 --- a/connector/linkedin/linkedin.go +++ b/connector/linkedin/linkedin.go @@ -11,8 +11,8 @@ import ( "golang.org/x/oauth2" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/log" ) const ( diff --git a/connector/microsoft/microsoft.go b/connector/microsoft/microsoft.go index 3a3cf3b5cc..87b6cfdc4a 100644 --- a/connector/microsoft/microsoft.go +++ b/connector/microsoft/microsoft.go @@ -15,9 +15,9 @@ import ( "golang.org/x/oauth2" - "github.com/dexidp/dex/connector" - groups_pkg "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + groups_pkg "github.com/concourse/dex/pkg/groups" + "github.com/concourse/dex/pkg/log" ) // GroupNameFormat represents the format of the group identifier diff --git a/connector/microsoft/microsoft_test.go b/connector/microsoft/microsoft_test.go index 3fba9c2ae2..0cd6fa5028 100644 --- a/connector/microsoft/microsoft_test.go +++ b/connector/microsoft/microsoft_test.go @@ -9,7 +9,7 @@ import ( "reflect" "testing" - "github.com/dexidp/dex/connector" + "github.com/concourse/dex/connector" ) type testResponse struct { diff --git a/connector/mock/connectortest.go b/connector/mock/connectortest.go index 5089914ca1..98dc513063 100644 --- a/connector/mock/connectortest.go +++ b/connector/mock/connectortest.go @@ -8,8 +8,8 @@ import ( "net/http" "net/url" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/log" ) // NewCallbackConnector returns a mock connector which requires no user interaction. It always returns diff --git a/connector/oauth/oauth.go b/connector/oauth/oauth.go new file mode 100644 index 0000000000..ea91a8469f --- /dev/null +++ b/connector/oauth/oauth.go @@ -0,0 +1,283 @@ +package oauth + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net" + "net/http" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/log" +) + +type oauthConnector struct { + clientID string + clientSecret string + redirectURI string + tokenURL string + authorizationURL string + userInfoURL string + scopes []string + userIDKey string + userNameKey string + preferredUsernameKey string + emailKey string + emailVerifiedKey string + groupsKey string + httpClient *http.Client + logger log.Logger +} + +type connectorData struct { + AccessToken string +} + +type Config struct { + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectURI"` + TokenURL string `json:"tokenURL"` + AuthorizationURL string `json:"authorizationURL"` + UserInfoURL string `json:"userInfoURL"` + Scopes []string `json:"scopes"` + RootCAs []string `json:"rootCAs"` + InsecureSkipVerify bool `json:"insecureSkipVerify"` + UserIDKey string `json:"userIDKey"` // defaults to "id" + ClaimMapping struct { + UserNameKey string `json:"userNameKey"` // defaults to "user_name" + PreferredUsernameKey string `json:"preferredUsernameKey"` // defaults to "preferred_username" + GroupsKey string `json:"groupsKey"` // defaults to "groups" + EmailKey string `json:"emailKey"` // defaults to "email" + EmailVerifiedKey string `json:"emailVerifiedKey"` // defaults to "email_verified" + } `json:"claimMapping"` +} + +func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { + var err error + + if c.UserIDKey == "" { + c.UserIDKey = "id" + } + + if c.ClaimMapping.UserNameKey == "" { + c.ClaimMapping.UserNameKey = "user_name" + } + + if c.ClaimMapping.PreferredUsernameKey == "" { + c.ClaimMapping.PreferredUsernameKey = "preferred_username" + } + + if c.ClaimMapping.GroupsKey == "" { + c.ClaimMapping.GroupsKey = "groups" + } + + if c.ClaimMapping.EmailKey == "" { + c.ClaimMapping.EmailKey = "email" + } + + if c.ClaimMapping.EmailVerifiedKey == "" { + c.ClaimMapping.EmailVerifiedKey = "email_verified" + } + + oauthConn := &oauthConnector{ + clientID: c.ClientID, + clientSecret: c.ClientSecret, + tokenURL: c.TokenURL, + authorizationURL: c.AuthorizationURL, + userInfoURL: c.UserInfoURL, + scopes: c.Scopes, + redirectURI: c.RedirectURI, + logger: logger, + userIDKey: c.UserIDKey, + userNameKey: c.ClaimMapping.UserNameKey, + preferredUsernameKey: c.ClaimMapping.PreferredUsernameKey, + groupsKey: c.ClaimMapping.GroupsKey, + emailKey: c.ClaimMapping.EmailKey, + emailVerifiedKey: c.ClaimMapping.EmailVerifiedKey, + } + + oauthConn.httpClient, err = newHTTPClient(c.RootCAs, c.InsecureSkipVerify) + if err != nil { + return nil, err + } + + return oauthConn, err +} + +func newHTTPClient(rootCAs []string, insecureSkipVerify bool) (*http.Client, error) { + pool, err := x509.SystemCertPool() + if err != nil { + return nil, err + } + + tlsConfig := tls.Config{RootCAs: pool, InsecureSkipVerify: insecureSkipVerify} + for _, rootCA := range rootCAs { + rootCABytes, err := ioutil.ReadFile(rootCA) + if err != nil { + return nil, fmt.Errorf("failed to read root-ca: %v", err) + } + if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) { + return nil, fmt.Errorf("no certs found in root CA file %q", rootCA) + } + } + + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tlsConfig, + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + }, nil +} + +func (c *oauthConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { + if c.redirectURI != callbackURL { + return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) + } + + oauth2Config := &oauth2.Config{ + ClientID: c.clientID, + ClientSecret: c.clientSecret, + Endpoint: oauth2.Endpoint{TokenURL: c.tokenURL, AuthURL: c.authorizationURL}, + RedirectURL: c.redirectURI, + Scopes: c.scopes, + } + + return oauth2Config.AuthCodeURL(state), nil +} + +func (c *oauthConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { + q := r.URL.Query() + if errType := q.Get("error"); errType != "" { + return identity, errors.New(q.Get("error_description")) + } + + oauth2Config := &oauth2.Config{ + ClientID: c.clientID, + ClientSecret: c.clientSecret, + Endpoint: oauth2.Endpoint{TokenURL: c.tokenURL, AuthURL: c.authorizationURL}, + RedirectURL: c.redirectURI, + Scopes: c.scopes, + } + + ctx := context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient) + + token, err := oauth2Config.Exchange(ctx, q.Get("code")) + if err != nil { + return identity, fmt.Errorf("OAuth connector: failed to get token: %v", err) + } + + client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) + + userInfoResp, err := client.Get(c.userInfoURL) + if err != nil { + return identity, fmt.Errorf("OAuth Connector: failed to execute request to userinfo: %v", err) + } + + if userInfoResp.StatusCode != http.StatusOK { + return identity, fmt.Errorf("OAuth Connector: failed to execute request to userinfo: status %d", userInfoResp.StatusCode) + } + + defer userInfoResp.Body.Close() + + var userInfoResult map[string]interface{} + err = json.NewDecoder(userInfoResp.Body).Decode(&userInfoResult) + + if err != nil { + return identity, fmt.Errorf("OAuth Connector: failed to parse userinfo: %v", err) + } + + userID, found := userInfoResult[c.userIDKey].(string) + if !found { + return identity, fmt.Errorf("OAuth Connector: not found %v claim", c.userIDKey) + } + + identity.UserID = userID + identity.Username, _ = userInfoResult[c.userNameKey].(string) + identity.PreferredUsername, _ = userInfoResult[c.preferredUsernameKey].(string) + identity.Email, _ = userInfoResult[c.emailKey].(string) + identity.EmailVerified, _ = userInfoResult[c.emailVerifiedKey].(bool) + + if s.Groups { + groups := map[string]bool{} + + c.addGroupsFromMap(groups, userInfoResult) + c.addGroupsFromToken(groups, token.AccessToken) + + for groupName := range groups { + identity.Groups = append(identity.Groups, groupName) + } + } + + if s.OfflineAccess { + data := connectorData{AccessToken: token.AccessToken} + connData, err := json.Marshal(data) + if err != nil { + return identity, fmt.Errorf("OAuth Connector: failed to parse connector data for offline access: %v", err) + } + identity.ConnectorData = connData + } + + return identity, nil +} + +func (c *oauthConnector) addGroupsFromMap(groups map[string]bool, result map[string]interface{}) error { + groupsClaim, ok := result[c.groupsKey].([]interface{}) + if !ok { + return errors.New("cannot convert to slice") + } + + for _, group := range groupsClaim { + if groupString, ok := group.(string); ok { + groups[groupString] = true + } + } + + return nil +} + +func (c *oauthConnector) addGroupsFromToken(groups map[string]bool, token string) error { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return errors.New("invalid token") + } + + decoded, err := decode(parts[1]) + if err != nil { + return err + } + + var claimsMap map[string]interface{} + err = json.Unmarshal(decoded, &claimsMap) + if err != nil { + return err + } + + return c.addGroupsFromMap(groups, claimsMap) +} + +func decode(seg string) ([]byte, error) { + if l := len(seg) % 4; l > 0 { + seg += strings.Repeat("=", 4-l) + } + + return base64.URLEncoding.DecodeString(seg) +} diff --git a/connector/oauth/oauth_test.go b/connector/oauth/oauth_test.go new file mode 100644 index 0000000000..077dcc9987 --- /dev/null +++ b/connector/oauth/oauth_test.go @@ -0,0 +1,235 @@ +package oauth + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + jose "gopkg.in/square/go-jose.v2" + + "github.com/concourse/dex/connector" +) + +func TestOpen(t *testing.T) { + tokenClaims := map[string]interface{}{} + userInfoClaims := map[string]interface{}{} + + testServer := testSetup(t, tokenClaims, userInfoClaims) + defer testServer.Close() + + conn := newConnector(t, testServer.URL) + + sort.Strings(conn.scopes) + + assert.Equal(t, conn.clientID, "testClient") + assert.Equal(t, conn.clientSecret, "testSecret") + assert.Equal(t, conn.redirectURI, testServer.URL+"/callback") + assert.Equal(t, conn.tokenURL, testServer.URL+"/token") + assert.Equal(t, conn.authorizationURL, testServer.URL+"/authorize") + assert.Equal(t, conn.userInfoURL, testServer.URL+"/userinfo") + assert.Equal(t, len(conn.scopes), 2) + assert.Equal(t, conn.scopes[0], "groups") + assert.Equal(t, conn.scopes[1], "openid") +} + +func TestLoginURL(t *testing.T) { + tokenClaims := map[string]interface{}{} + userInfoClaims := map[string]interface{}{} + + testServer := testSetup(t, tokenClaims, userInfoClaims) + defer testServer.Close() + + conn := newConnector(t, testServer.URL) + + loginURL, err := conn.LoginURL(connector.Scopes{}, conn.redirectURI, "some-state") + assert.Equal(t, err, nil) + + expectedURL, err := url.Parse(testServer.URL + "/authorize") + assert.Equal(t, err, nil) + + values := url.Values{} + values.Add("client_id", "testClient") + values.Add("redirect_uri", conn.redirectURI) + values.Add("response_type", "code") + values.Add("scope", "openid groups") + values.Add("state", "some-state") + expectedURL.RawQuery = values.Encode() + + assert.Equal(t, loginURL, expectedURL.String()) +} + +func TestHandleCallBackForGroupsInUserInfo(t *testing.T) { + tokenClaims := map[string]interface{}{} + + userInfoClaims := map[string]interface{}{ + "name": "test-name", + "user_id_key": "test-user-id", + "user_name_key": "test-username", + "preferred_username": "test-preferred-username", + "mail": "mod_mail", + "has_verified_email": false, + "groups_key": []string{"admin-group", "user-group"}, + } + + testServer := testSetup(t, tokenClaims, userInfoClaims) + defer testServer.Close() + + conn := newConnector(t, testServer.URL) + req := newRequestWithAuthCode(t, testServer.URL, "some-code") + + identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req) + assert.Equal(t, err, nil) + + sort.Strings(identity.Groups) + assert.Equal(t, len(identity.Groups), 2) + assert.Equal(t, identity.Groups[0], "admin-group") + assert.Equal(t, identity.Groups[1], "user-group") + assert.Equal(t, identity.UserID, "test-user-id") + assert.Equal(t, identity.Username, "test-username") + assert.Equal(t, identity.PreferredUsername, "test-preferred-username") + assert.Equal(t, identity.Email, "mod_mail") + assert.Equal(t, identity.EmailVerified, false) +} + +func TestHandleCallBackForGroupsInToken(t *testing.T) { + tokenClaims := map[string]interface{}{ + "groups_key": []string{"test-group"}, + } + + userInfoClaims := map[string]interface{}{ + "name": "test-name", + "user_id_key": "test-user-id", + "user_name_key": "test-username", + "preferred_username": "test-preferred-username", + "email": "test-email", + "email_verified": true, + } + + testServer := testSetup(t, tokenClaims, userInfoClaims) + defer testServer.Close() + + conn := newConnector(t, testServer.URL) + req := newRequestWithAuthCode(t, testServer.URL, "some-code") + + identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req) + assert.Equal(t, err, nil) + + assert.Equal(t, len(identity.Groups), 1) + assert.Equal(t, identity.Groups[0], "test-group") + assert.Equal(t, identity.PreferredUsername, "test-preferred-username") + assert.Equal(t, identity.UserID, "test-user-id") + assert.Equal(t, identity.Username, "test-username") + assert.Equal(t, identity.Email, "") + assert.Equal(t, identity.EmailVerified, false) +} + +func testSetup(t *testing.T, tokenClaims map[string]interface{}, userInfoClaims map[string]interface{}) *httptest.Server { + key, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatal("Failed to generate rsa key", err) + } + + jwk := jose.JSONWebKey{ + Key: key, + KeyID: "some-key", + Algorithm: "RSA", + } + + mux := http.NewServeMux() + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + token, err := newToken(&jwk, tokenClaims) + if err != nil { + t.Fatal("unable to generate token", err) + } + + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(&map[string]string{ + "access_token": token, + "id_token": token, + "token_type": "Bearer", + }) + }) + + mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(userInfoClaims) + }) + + return httptest.NewServer(mux) +} + +func newToken(key *jose.JSONWebKey, claims map[string]interface{}) (string, error) { + signingKey := jose.SigningKey{Key: key, Algorithm: jose.RS256} + + signer, err := jose.NewSigner(signingKey, &jose.SignerOptions{}) + if err != nil { + return "", fmt.Errorf("new signer: %v", err) + } + + payload, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("marshaling claims: %v", err) + } + + signature, err := signer.Sign(payload) + if err != nil { + return "", fmt.Errorf("signing payload: %v", err) + } + + return signature.CompactSerialize() +} + +func newConnector(t *testing.T, serverURL string) *oauthConnector { + testConfig := Config{ + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: serverURL + "/callback", + TokenURL: serverURL + "/token", + AuthorizationURL: serverURL + "/authorize", + UserInfoURL: serverURL + "/userinfo", + Scopes: []string{"openid", "groups"}, + UserIDKey: "user_id_key", + } + + testConfig.ClaimMapping.UserNameKey = "user_name_key" + testConfig.ClaimMapping.GroupsKey = "groups_key" + testConfig.ClaimMapping.EmailKey = "mail" + testConfig.ClaimMapping.EmailVerifiedKey = "has_verified_email" + + log := logrus.New() + + conn, err := testConfig.Open("id", log) + if err != nil { + t.Fatal(err) + } + + oauthConn, ok := conn.(*oauthConnector) + if !ok { + t.Fatal(errors.New("failed to convert to oauthConnector")) + } + + return oauthConn +} + +func newRequestWithAuthCode(t *testing.T, serverURL string, code string) *http.Request { + req, err := http.NewRequest("GET", serverURL, nil) + if err != nil { + t.Fatal("failed to create request", err) + } + + values := req.URL.Query() + values.Add("code", code) + req.URL.RawQuery = values.Encode() + + return req +} diff --git a/connector/oidc/oidc.go b/connector/oidc/oidc.go index b752f9dac5..0145a537f5 100644 --- a/connector/oidc/oidc.go +++ b/connector/oidc/oidc.go @@ -3,9 +3,13 @@ package oidc import ( "context" + "crypto/tls" + "crypto/x509" "encoding/json" "errors" "fmt" + "io/ioutil" + "net" "net/http" "net/url" "strings" @@ -14,8 +18,8 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/log" ) // Config holds configuration options for OpenID Connect logins. @@ -38,6 +42,12 @@ type Config struct { // If this field is nonempty, only users from a listed domain will be allowed to log in HostedDomains []string `json:"hostedDomains"` + // Certificates for SSL validation + RootCAs []string `json:"rootCAs"` + + // Disable certificate verification + InsecureSkipVerify bool `json:"insecureSkipVerify"` + // Override the value of email_verified to true in the returned claims InsecureSkipEmailVerified bool `json:"insecureSkipEmailVerified"` @@ -99,7 +109,13 @@ func knownBrokenAuthHeaderProvider(issuerURL string) bool { // Open returns a connector which can be used to login users through an upstream // OpenID Connect provider. func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) { + httpClient, err := newHTTPClient(c.RootCAs, c.InsecureSkipVerify) + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(context.Background()) + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) provider, err := oidc.NewProvider(ctx, c.Issuer) if err != nil { @@ -146,6 +162,7 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e ), logger: logger, cancel: cancel, + httpClient: httpClient, hostedDomains: c.HostedDomains, insecureSkipEmailVerified: c.InsecureSkipEmailVerified, insecureEnableGroups: c.InsecureEnableGroups, @@ -159,6 +176,40 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e }, nil } +func newHTTPClient(rootCAs []string, insecureSkipVerify bool) (*http.Client, error) { + pool, err := x509.SystemCertPool() + if err != nil { + return nil, err + } + + tlsConfig := tls.Config{RootCAs: pool, InsecureSkipVerify: insecureSkipVerify} + for _, rootCA := range rootCAs { + rootCABytes, err := ioutil.ReadFile(rootCA) + if err != nil { + return nil, fmt.Errorf("failed to read root-ca: %v", err) + } + if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) { + return nil, fmt.Errorf("no certs found in root CA file %q", rootCA) + } + } + + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tlsConfig, + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + }, nil +} + var ( _ connector.CallbackConnector = (*oidcConnector)(nil) _ connector.RefreshConnector = (*oidcConnector)(nil) @@ -171,6 +222,7 @@ type oidcConnector struct { verifier *oidc.IDTokenVerifier cancel context.CancelFunc logger log.Logger + httpClient *http.Client hostedDomains []string insecureSkipEmailVerified bool insecureEnableGroups bool @@ -225,7 +277,10 @@ func (c *oidcConnector) HandleCallback(s connector.Scopes, r *http.Request) (ide if errType := q.Get("error"); errType != "" { return identity, &oauth2Error{errType, q.Get("error_description")} } - token, err := c.oauth2Config.Exchange(r.Context(), q.Get("code")) + + ctx := context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient) + + token, err := c.oauth2Config.Exchange(ctx, q.Get("code")) if err != nil { return identity, fmt.Errorf("oidc: failed to get token: %v", err) } @@ -258,6 +313,7 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I if !ok { return identity, errors.New("oidc: no id_token in token response") } + idToken, err := c.verifier.Verify(ctx, rawIDToken) if err != nil { return identity, fmt.Errorf("oidc: failed to verify ID Token: %v", err) diff --git a/connector/oidc/oidc_test.go b/connector/oidc/oidc_test.go index ae92f70caa..342a46418c 100644 --- a/connector/oidc/oidc_test.go +++ b/connector/oidc/oidc_test.go @@ -19,7 +19,7 @@ import ( "github.com/sirupsen/logrus" "gopkg.in/square/go-jose.v2" - "github.com/dexidp/dex/connector" + "github.com/concourse/dex/connector" ) func TestKnownBrokenAuthHeaderProvider(t *testing.T) { diff --git a/connector/openshift/openshift.go b/connector/openshift/openshift.go index f06e8f8045..cfd43481d3 100644 --- a/connector/openshift/openshift.go +++ b/connector/openshift/openshift.go @@ -14,10 +14,10 @@ import ( "golang.org/x/oauth2" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage/kubernetes/k8sapi" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/groups" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/storage/kubernetes/k8sapi" ) // Config holds configuration options for OpenShift login diff --git a/connector/openshift/openshift_test.go b/connector/openshift/openshift_test.go index 90f1686c40..a430f0835c 100644 --- a/connector/openshift/openshift_test.go +++ b/connector/openshift/openshift_test.go @@ -13,8 +13,8 @@ import ( "github.com/sirupsen/logrus" "golang.org/x/oauth2" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/storage/kubernetes/k8sapi" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/storage/kubernetes/k8sapi" ) func TestOpen(t *testing.T) { diff --git a/connector/saml/saml.go b/connector/saml/saml.go index 0d52b13116..a809bc2779 100644 --- a/connector/saml/saml.go +++ b/connector/saml/saml.go @@ -19,9 +19,9 @@ import ( dsig "github.com/russellhaering/goxmldsig" "github.com/russellhaering/goxmldsig/etreeutils" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/pkg/groups" + "github.com/concourse/dex/pkg/log" ) // nolint diff --git a/connector/saml/saml_test.go b/connector/saml/saml_test.go index 67d7efb140..4a721fd42f 100644 --- a/connector/saml/saml_test.go +++ b/connector/saml/saml_test.go @@ -14,7 +14,7 @@ import ( dsig "github.com/russellhaering/goxmldsig" "github.com/sirupsen/logrus" - "github.com/dexidp/dex/connector" + "github.com/concourse/dex/connector" ) // responseTest maps a SAML 2.0 response object to a set of expected values. diff --git a/examples/go.mod b/examples/go.mod index fc1405d615..f2748f1b3b 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -1,4 +1,4 @@ -module github.com/dexidp/dex/examples +module github.com/concourse/dex/examples go 1.14 diff --git a/go.mod b/go.mod index 6291284cca..d3bf607d57 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/dexidp/dex +module github.com/concourse/dex go 1.16 @@ -11,7 +11,7 @@ require ( github.com/ghodss/yaml v1.0.0 github.com/go-sql-driver/mysql v1.5.0 github.com/gogo/protobuf v1.3.1 // indirect - github.com/golang/protobuf v1.3.2 + github.com/golang/protobuf v1.5.1 github.com/gorilla/handlers v1.5.1 github.com/gorilla/mux v1.8.0 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 diff --git a/go.sum b/go.sum index 69b3bcf4d8..f57d0cf658 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,5 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= @@ -15,7 +14,6 @@ cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiy dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/AppsFlyer/go-sundheit v0.3.1 h1:Zqnr3wV3WQmXonc234k9XZAoV2KHUHw3osR5k2iHQZE= github.com/AppsFlyer/go-sundheit v0.3.1/go.mod h1:iZ8zWMS7idcvmqewf5mEymWWgoOiG/0WD4+aeh+heX4= -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= @@ -42,9 +40,7 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa h1:OaNxuTZr7kxeODyLWsRMC+OD03aFUH+mW6r2d+MWa5Y= github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= -github.com/coreos/bbolt v1.3.2 h1:wZwiHHUieZCquLkDL0B8UhzreNWsPHooDAG3q34zk0s= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.13+incompatible h1:8F3hqu9fGYLBifCmRCJsicFqDx/D68Rt3q1JMazcgBQ= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-oidc/v3 v3.0.0 h1:/mAA0XMgYJw2Uqm7WKGCsKnjitE/+A0FFbOmiRJm7LQ= github.com/coreos/go-oidc/v3 v3.0.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo= @@ -74,7 +70,6 @@ github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8S github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= -github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -87,7 +82,6 @@ github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gG github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= @@ -102,16 +96,19 @@ github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFU github.com/golang/protobuf v1.0.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1 h1:jAbXjIeW2ZSW2AwFxlGTDoc2CjI2XujLkV3ArsZFCvc= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -264,7 +261,6 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -280,7 +276,6 @@ github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M= github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -293,7 +288,6 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -339,7 +333,6 @@ golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= @@ -363,13 +356,11 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200505041828-1ed23360d12c h1:zJ0mtu4jCalhKg6Oaukv6iIkb+cOvDrajDH9DH46Q4M= golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -378,7 +369,6 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180117170059-2c42eef0765b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -408,7 +398,6 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20171227012246-e19ae1496984/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -456,7 +445,6 @@ google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRn google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a h1:Ob5/580gVHBJZgXnff1cZDbG+xLtMVE5mDRTe+nIsX4= @@ -468,6 +456,9 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.26.0 h1:2dTRdpdFEEhJYQD8EMLB61nnrzSCTbG38PhqdhvOltg= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= @@ -501,7 +492,6 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= sigs.k8s.io/testing_frameworks v0.1.2 h1:vK0+tvjF0BZ/RYFeZ1E6BYBwHJJXhjuZ3TdsEKH+UQM= diff --git a/pkg/groups/groups_test.go b/pkg/groups/groups_test.go index 0be62fb430..2ff38a2df8 100644 --- a/pkg/groups/groups_test.go +++ b/pkg/groups/groups_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/dexidp/dex/pkg/groups" + "github.com/concourse/dex/pkg/groups" ) func TestFilter(t *testing.T) { diff --git a/server/api.go b/server/api.go index 5560c3bccb..8d07960e38 100644 --- a/server/api.go +++ b/server/api.go @@ -8,10 +8,10 @@ import ( "golang.org/x/crypto/bcrypt" "github.com/dexidp/dex/api/v2" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/server/internal" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/version" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/server/internal" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/version" ) // apiVersion increases every time a new call is added to the API. Clients should use this info diff --git a/server/api_test.go b/server/api_test.go index e7725063a5..1208cdfa0f 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -11,10 +11,10 @@ import ( "google.golang.org/grpc" "github.com/dexidp/dex/api/v2" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/server/internal" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/memory" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/server/internal" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/memory" ) // apiClient is a test gRPC client. When constructed, it runs a server in diff --git a/server/deviceflowhandlers.go b/server/deviceflowhandlers.go index a73dafe8ee..4e887b8238 100644 --- a/server/deviceflowhandlers.go +++ b/server/deviceflowhandlers.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/storage" ) type deviceCodeResponse struct { diff --git a/server/deviceflowhandlers_test.go b/server/deviceflowhandlers_test.go index 3898d4fc8e..4fd59aa2bd 100644 --- a/server/deviceflowhandlers_test.go +++ b/server/deviceflowhandlers_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/storage" ) func TestDeviceVerificationURI(t *testing.T) { diff --git a/server/handlers.go b/server/handlers.go index eb65f490bd..4a24e91e24 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -2,6 +2,7 @@ package server import ( "crypto/sha256" + "crypto/subtle" "encoding/base64" "encoding/json" "errors" @@ -16,11 +17,12 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/gorilla/mux" + "golang.org/x/crypto/bcrypt" jose "gopkg.in/square/go-jose.v2" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/server/internal" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/server/internal" + "github.com/concourse/dex/storage" ) const ( @@ -679,14 +681,22 @@ func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) { } return } - if client.Secret != clientSecret { - if clientSecret == "" { - s.logger.Infof("missing client_secret on token request for client: %s", client.ID) - } else { - s.logger.Infof("invalid client_secret on token request for client: %s", client.ID) + + if s.hashClientSecret { + if err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)); err != nil { + s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized) + return + } + } else { + if subtle.ConstantTimeCompare([]byte(client.Secret), []byte(clientSecret)) != 1 { + if clientSecret == "" { + s.logger.Infof("missing client_secret on token request for client: %s", client.ID) + } else { + s.logger.Infof("invalid client_secret on token request for client: %s", client.ID) + } + s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized) + return } - s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized) - return } grantType := r.PostFormValue("grant_type") @@ -697,6 +707,8 @@ func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) { s.handleRefreshToken(w, r, client) case grantTypePassword: s.handlePasswordGrant(w, r, client) + case grantTypeClientCredentials: + s.handleClientCredentialsGrant(w, r, client) default: s.tokenErrHelper(w, errInvalidGrant, "", http.StatusBadRequest) } @@ -1147,6 +1159,29 @@ func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) { w.Write(claims) } +func (s *Server) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request, client storage.Client) { + if err := r.ParseForm(); err != nil { + s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest) + return + } + q := r.Form + + nonce := q.Get("nonce") + scopes := strings.Fields(q.Get("scope")) + + claims := storage.Claims{UserID: client.ID} + + accessToken := storage.NewID() + idToken, expiry, err := s.newIDToken(client.ID, claims, scopes, nonce, accessToken, "", "client") + if err != nil { + s.tokenErrHelper(w, errServerError, fmt.Sprintf("failed to create ID token: %v", err), http.StatusInternalServerError) + return + } + + resp := s.toAccessTokenResponse(idToken, accessToken, "", expiry) + s.writeAccessToken(w, resp) +} + func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, client storage.Client) { // Parse the fields if err := r.ParseForm(); err != nil { diff --git a/server/handlers_test.go b/server/handlers_test.go index 8ad59d947b..a622d54926 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -7,6 +7,7 @@ import ( "errors" "net/http" "net/http/httptest" + "os" "testing" "time" @@ -17,8 +18,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/oauth2" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/memory" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/memory" ) func TestHandleHealth(t *testing.T) { @@ -135,7 +136,7 @@ func TestHandleInvalidSAMLCallbacks(t *testing.T) { func TestConnectorLoginDoesNotAllowToChangeConnectorForAuthRequest(t *testing.T) { memStorage := memory.New(logger) - templates, err := loadTemplates(webConfig{}, "../web/templates") + templates, err := loadTemplates(webConfig{webFS: os.DirFS("../web")}, "templates") if err != nil { t.Fatal("failed to load templates") } diff --git a/server/oauth2.go b/server/oauth2.go index 577fb94444..0f88d32f8d 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -22,9 +22,9 @@ import ( jose "gopkg.in/square/go-jose.v2" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/server/internal" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/server/internal" + "github.com/concourse/dex/storage" ) // TODO(ericchiang): clean this file up and figure out more idiomatic error handling. @@ -128,6 +128,7 @@ const ( grantTypeRefreshToken = "refresh_token" grantTypePassword = "password" grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" + grantTypeClientCredentials = "client_credentials" ) const ( diff --git a/server/oauth2_test.go b/server/oauth2_test.go index 518e22ee86..d25575f2fc 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -13,8 +13,8 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/square/go-jose.v2" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/memory" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/memory" ) func TestParseAuthorizationRequest(t *testing.T) { diff --git a/server/rotation.go b/server/rotation.go index b7dd8116a8..6eaa4a4333 100644 --- a/server/rotation.go +++ b/server/rotation.go @@ -12,8 +12,8 @@ import ( "gopkg.in/square/go-jose.v2" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/storage" ) var errAlreadyRotated = errors.New("keys already rotated by another server instance") diff --git a/server/rotation_test.go b/server/rotation_test.go index 6f9b2ecb3f..6b8a468b7c 100644 --- a/server/rotation_test.go +++ b/server/rotation_test.go @@ -8,8 +8,8 @@ import ( "github.com/sirupsen/logrus" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/memory" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/memory" ) func signingKeyID(t *testing.T, s storage.Storage) string { diff --git a/server/server.go b/server/server.go index a79b7cfd3b..6698db5b9c 100644 --- a/server/server.go +++ b/server/server.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "net/http" "net/url" "path" @@ -22,24 +23,26 @@ import ( "github.com/prometheus/client_golang/prometheus" "golang.org/x/crypto/bcrypt" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/connector/atlassiancrowd" - "github.com/dexidp/dex/connector/authproxy" - "github.com/dexidp/dex/connector/bitbucketcloud" - "github.com/dexidp/dex/connector/gitea" - "github.com/dexidp/dex/connector/github" - "github.com/dexidp/dex/connector/gitlab" - "github.com/dexidp/dex/connector/google" - "github.com/dexidp/dex/connector/keystone" - "github.com/dexidp/dex/connector/ldap" - "github.com/dexidp/dex/connector/linkedin" - "github.com/dexidp/dex/connector/microsoft" - "github.com/dexidp/dex/connector/mock" - "github.com/dexidp/dex/connector/oidc" - "github.com/dexidp/dex/connector/openshift" - "github.com/dexidp/dex/connector/saml" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/connector/atlassiancrowd" + "github.com/concourse/dex/connector/authproxy" + "github.com/concourse/dex/connector/bitbucketcloud" + "github.com/concourse/dex/connector/cf" + "github.com/concourse/dex/connector/gitea" + "github.com/concourse/dex/connector/github" + "github.com/concourse/dex/connector/gitlab" + "github.com/concourse/dex/connector/google" + "github.com/concourse/dex/connector/keystone" + "github.com/concourse/dex/connector/ldap" + "github.com/concourse/dex/connector/linkedin" + "github.com/concourse/dex/connector/microsoft" + "github.com/concourse/dex/connector/mock" + "github.com/concourse/dex/connector/oauth" + "github.com/concourse/dex/connector/oidc" + "github.com/concourse/dex/connector/openshift" + "github.com/concourse/dex/connector/saml" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/storage" ) // LocalConnector is the local passwordDB connector which is an internal @@ -77,6 +80,9 @@ type Config struct { // If enabled, the connectors selection page will always be shown even if there's only one AlwaysShowLoginScreen bool + // If enabled, the client secret is expected to be encrypted + HashClientSecret bool + RotateKeysAfter time.Duration // Defaults to 6 hours. IDTokensValidFor time.Duration // Defaults to 24 hours AuthRequestsValidFor time.Duration // Defaults to 24 hours @@ -100,7 +106,7 @@ type Config struct { // WebConfig holds the server's frontend templates and asset configuration. type WebConfig struct { - // A filepath to web static. + // A file path to web static. If set, WebFS will be ignored. // // It is expected to contain the following directories: // @@ -110,6 +116,12 @@ type WebConfig struct { // Dir string + // Alternative way to configure web static filesystem. Dir overrides this. + // It's expected to contain the same files and directories as mentioned + // above in Dir doc. + // + WebFS fs.FS + // Defaults to "( issuer URL )/theme/logo.png" LogoURL string @@ -151,6 +163,9 @@ type Server struct { // If enabled, show the connector selection screen even if there's only one alwaysShowLogin bool + // If enabled, the client secret is expected to be encrypted + hashClientSecret bool + // Used for password grant passwordConnector string @@ -189,6 +204,24 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) if c.Storage == nil { return nil, errors.New("server: storage cannot be nil") } + + if c.HashClientSecret { + clients, err := c.Storage.ListClients() + if err != nil { + return nil, fmt.Errorf("server: failed to list clients") + } + + for _, client := range clients { + if client.Secret == "" { + return nil, fmt.Errorf("server: client secret can't be empty") + } + + if err = checkCost([]byte(client.Secret)); err != nil { + return nil, fmt.Errorf("server: failed to check cost of client secret: %v", err) + } + } + } + if len(c.SupportedResponseTypes) == 0 { c.SupportedResponseTypes = []string{responseTypeCode} } @@ -205,6 +238,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) web := webConfig{ dir: c.Web.Dir, + webFS: c.Web.WebFS, logoURL: c.Web.LogoURL, issuerURL: c.Issuer, issuer: c.Web.Issuer, @@ -232,6 +266,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute), skipApproval: c.SkipApprovalScreen, alwaysShowLogin: c.AlwaysShowLoginScreen, + hashClientSecret: c.HashClientSecret, now: now, templates: tmpls, passwordConnector: c.PasswordConnector, @@ -343,6 +378,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) } fmt.Fprintf(w, "Health check passed") })) + handlePrefix("/static", static) handlePrefix("/theme", theme) s.mux = r @@ -501,6 +537,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ "gitlab": func() ConnectorConfig { return new(gitlab.Config) }, "google": func() ConnectorConfig { return new(google.Config) }, "oidc": func() ConnectorConfig { return new(oidc.Config) }, + "oauth": func() ConnectorConfig { return new(oauth.Config) }, "saml": func() ConnectorConfig { return new(saml.Config) }, "authproxy": func() ConnectorConfig { return new(authproxy.Config) }, "linkedin": func() ConnectorConfig { return new(linkedin.Config) }, @@ -508,6 +545,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ "bitbucket-cloud": func() ConnectorConfig { return new(bitbucketcloud.Config) }, "openshift": func() ConnectorConfig { return new(openshift.Config) }, "atlassian-crowd": func() ConnectorConfig { return new(atlassiancrowd.Config) }, + "cf": func() ConnectorConfig { return new(cf.Config) }, // Keep around for backwards compatibility. "samlExperimental": func() ConnectorConfig { return new(saml.Config) }, } diff --git a/server/server_test.go b/server/server_test.go index 87ca6c171e..70af6ab272 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -31,10 +31,10 @@ import ( "golang.org/x/oauth2" jose "gopkg.in/square/go-jose.v2" - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/connector/mock" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/memory" + "github.com/concourse/dex/connector" + "github.com/concourse/dex/connector/mock" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/memory" ) func mustLoad(s string) *rsa.PrivateKey { @@ -1637,3 +1637,163 @@ func TestOAuth2DeviceFlow(t *testing.T) { }() } } + +func TestClientSecretEncryption(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + httpServer, s := newTestServer(ctx, t, func(c *Config) { + c.HashClientSecret = true + }) + defer httpServer.Close() + + clientID := "testclient" + clientSecret := "testclientsecret" + hash, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("failed to bcrypt: %s", err) + } + + // Query server's provider metadata. + p, err := oidc.NewProvider(ctx, httpServer.URL) + if err != nil { + t.Fatalf("failed to get provider: %v", err) + } + + var ( + // If the OAuth2 client didn't get a response, we need + // to print the requests the user saw. + gotCode bool + reqDump, respDump []byte // Auth step, not token. + state = "a_state" + ) + defer func() { + if !gotCode { + t.Errorf("never got a code in callback\n%s\n%s", reqDump, respDump) + } + }() + + // Setup OAuth2 client. + var oauth2Config *oauth2.Config + + requestedScopes := []string{oidc.ScopeOpenID, "email", "profile", "groups", "offline_access"} + + // Create the OAuth2 config. + oauth2Config = &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: p.Endpoint(), + Scopes: requestedScopes, + } + + oauth2Client := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/callback" { + // User is visiting app first time. Redirect to dex. + http.Redirect(w, r, oauth2Config.AuthCodeURL(state), http.StatusSeeOther) + return + } + + // User is at '/callback' so they were just redirected _from_ dex. + q := r.URL.Query() + + // Grab code, exchange for token. + if code := q.Get("code"); code != "" { + gotCode = true + token, err := oauth2Config.Exchange(ctx, code) + if err != nil { + t.Errorf("failed to exchange code for token: %v", err) + return + } + + oidcConfig := &oidc.Config{SkipClientIDCheck: true} + + idToken, ok := token.Extra("id_token").(string) + if !ok { + t.Errorf("no id token found") + return + } + if _, err := p.Verifier(oidcConfig).Verify(ctx, idToken); err != nil { + t.Errorf("failed to verify id token: %v", err) + return + } + } + + w.WriteHeader(http.StatusOK) + })) + + oauth2Config.RedirectURL = oauth2Client.URL + "/callback" + + defer oauth2Client.Close() + + // Regester the client above with dex. + client := storage.Client{ + ID: clientID, + Secret: string(hash), + RedirectURIs: []string{oauth2Client.URL + "/callback"}, + } + if err := s.storage.CreateClient(client); err != nil { + t.Fatalf("failed to create client: %v", err) + } + + // Login! + // + // 1. First request to client, redirects to dex. + // 2. Dex "logs in" the user, redirects to client with "code". + // 3. Client exchanges "code" for "token" (id_token, refresh_token, etc.). + // 4. Test is run with OAuth2 token response. + // + resp, err := http.Get(oauth2Client.URL + "/login") + if err != nil { + t.Fatalf("get failed: %v", err) + } + defer resp.Body.Close() + + if reqDump, err = httputil.DumpRequest(resp.Request, false); err != nil { + t.Fatal(err) + } + if respDump, err = httputil.DumpResponse(resp, true); err != nil { + t.Fatal(err) + } +} + +func TestClientSecretEncryptionCost(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + clientID := "testclient" + clientSecret := "testclientsecret" + hash, err := bcrypt.GenerateFromPassword([]byte(clientSecret), 5) + if err != nil { + t.Fatalf("failed to bcrypt: %s", err) + } + + // Register the client above with dex. + client := storage.Client{ + ID: clientID, + Secret: string(hash), + } + + config := Config{ + Storage: memory.New(logger), + Web: WebConfig{ + Dir: "../web", + }, + Logger: logger, + PrometheusRegistry: prometheus.NewRegistry(), + HashClientSecret: true, + } + + err = config.Storage.CreateClient(client) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + _, err = newServer(ctx, config, staticRotationStrategy(testKey)) + if err == nil { + t.Error("constructing server should have failed") + } + + if !strings.Contains(err.Error(), "failed to check cost") { + t.Error("should have failed with cost error") + } +} diff --git a/server/templates.go b/server/templates.go index bed1c6c86c..ca5e4d24be 100644 --- a/server/templates.go +++ b/server/templates.go @@ -4,7 +4,7 @@ import ( "fmt" "html/template" "io" - "io/ioutil" + "io/fs" "net/http" "net/url" "os" @@ -46,6 +46,7 @@ type templates struct { type webConfig struct { dir string + webFS fs.FS logoURL string issuer string theme string @@ -53,22 +54,9 @@ type webConfig struct { extra map[string]string } -func dirExists(dir string) error { - stat, err := os.Stat(dir) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("directory %q does not exist", dir) - } - return fmt.Errorf("stat directory %q: %v", dir, err) - } - if !stat.IsDir() { - return fmt.Errorf("path %q is a file not a directory", dir) - } - return nil -} - // loadWebConfig returns static assets, theme assets, and templates used by the frontend by -// reading the directory specified in the webConfig. +// reading the dir specified in the webConfig. If directory is not specified it will +// use the file system specified by webFS. // // The directory layout is expected to be: // @@ -89,37 +77,34 @@ func loadWebConfig(c webConfig) (http.Handler, http.Handler, *templates, error) if c.issuer == "" { c.issuer = "dex" } - if c.dir == "" { - c.dir = "./web" + if c.dir != "" { + c.webFS = os.DirFS(c.dir) + } else if c.webFS == nil { + c.webFS = os.DirFS("./web") } if c.logoURL == "" { c.logoURL = "theme/logo.png" } - if err := dirExists(c.dir); err != nil { - return nil, nil, nil, fmt.Errorf("load web dir: %v", err) + staticFiles, err := fs.Sub(c.webFS, "static") + if err != nil { + return nil, nil, nil, fmt.Errorf("read static dir: %v", err) } - - staticDir := filepath.Join(c.dir, "static") - templatesDir := filepath.Join(c.dir, "templates") - themeDir := filepath.Join(c.dir, "themes", c.theme) - - for _, dir := range []string{staticDir, templatesDir, themeDir} { - if err := dirExists(dir); err != nil { - return nil, nil, nil, fmt.Errorf("load dir: %v", err) - } + themeFiles, err := fs.Sub(c.webFS, filepath.Join("themes", c.theme)) + if err != nil { + return nil, nil, nil, fmt.Errorf("read themes dir: %v", err) } - static := http.FileServer(http.Dir(staticDir)) - theme := http.FileServer(http.Dir(themeDir)) + static := http.FileServer(http.FS(staticFiles)) + theme := http.FileServer(http.FS(themeFiles)) - templates, err := loadTemplates(c, templatesDir) + templates, err := loadTemplates(c, "templates") return static, theme, templates, err } // loadTemplates parses the expected templates from the provided directory. func loadTemplates(c webConfig, templatesDir string) (*templates, error) { - files, err := ioutil.ReadDir(templatesDir) + files, err := fs.ReadDir(c.webFS, templatesDir) if err != nil { return nil, fmt.Errorf("read dir: %v", err) } @@ -148,7 +133,7 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) { "extra": func(k string) string { return c.extra[k] }, } - tmpls, err := template.New("").Funcs(funcs).ParseFiles(filenames...) + tmpls, err := template.New("").Funcs(funcs).ParseFS(c.webFS, filenames...) if err != nil { return nil, fmt.Errorf("parse files: %v", err) } diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index 3f5e2aa191..15a180fc66 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -11,7 +11,7 @@ import ( "golang.org/x/crypto/bcrypt" jose "gopkg.in/square/go-jose.v2" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/storage" ) // ensure that values being tested on never expire. diff --git a/storage/conformance/transactions.go b/storage/conformance/transactions.go index 2fc6755b09..0e5e22be9b 100644 --- a/storage/conformance/transactions.go +++ b/storage/conformance/transactions.go @@ -6,7 +6,7 @@ import ( "golang.org/x/crypto/bcrypt" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/storage" ) // RunTransactionTests runs a test suite aimed a verifying the transaction diff --git a/storage/etcd/config.go b/storage/etcd/config.go index 14607316d2..79427f53e2 100644 --- a/storage/etcd/config.go +++ b/storage/etcd/config.go @@ -7,8 +7,8 @@ import ( "go.etcd.io/etcd/clientv3/namespace" "go.etcd.io/etcd/pkg/transport" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/storage" ) var defaultDialTimeout = 2 * time.Second diff --git a/storage/etcd/etcd.go b/storage/etcd/etcd.go index 97aced45a2..585e85f9fc 100644 --- a/storage/etcd/etcd.go +++ b/storage/etcd/etcd.go @@ -9,8 +9,8 @@ import ( "go.etcd.io/etcd/clientv3" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/storage" ) const ( diff --git a/storage/etcd/etcd_test.go b/storage/etcd/etcd_test.go index 122d7daeac..f404518640 100644 --- a/storage/etcd/etcd_test.go +++ b/storage/etcd/etcd_test.go @@ -12,8 +12,8 @@ import ( "github.com/sirupsen/logrus" "go.etcd.io/etcd/clientv3" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/conformance" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/conformance" ) func withTimeout(t time.Duration, f func()) { diff --git a/storage/etcd/types.go b/storage/etcd/types.go index f2ffd9f702..ab3fba7b03 100644 --- a/storage/etcd/types.go +++ b/storage/etcd/types.go @@ -5,7 +5,7 @@ import ( jose "gopkg.in/square/go-jose.v2" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/storage" ) // AuthCode is a mirrored struct from storage with JSON struct tags diff --git a/storage/kubernetes/client.go b/storage/kubernetes/client.go index 593f1c0338..a6a4ded586 100644 --- a/storage/kubernetes/client.go +++ b/storage/kubernetes/client.go @@ -25,9 +25,9 @@ import ( "github.com/ghodss/yaml" "golang.org/x/net/http2" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/kubernetes/k8sapi" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/kubernetes/k8sapi" ) type client struct { diff --git a/storage/kubernetes/storage.go b/storage/kubernetes/storage.go index b670244a0e..cec25cc4db 100644 --- a/storage/kubernetes/storage.go +++ b/storage/kubernetes/storage.go @@ -9,9 +9,9 @@ import ( "strings" "time" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/kubernetes/k8sapi" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/kubernetes/k8sapi" ) const ( diff --git a/storage/kubernetes/storage_test.go b/storage/kubernetes/storage_test.go index 42ba19a401..879696fd45 100644 --- a/storage/kubernetes/storage_test.go +++ b/storage/kubernetes/storage_test.go @@ -17,8 +17,8 @@ import ( "github.com/stretchr/testify/suite" "sigs.k8s.io/testing_frameworks/integration" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/conformance" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/conformance" ) const kubeconfigTemplate = `apiVersion: v1 diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index 07e2508401..d6e831050b 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -6,8 +6,8 @@ import ( jose "gopkg.in/square/go-jose.v2" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/kubernetes/k8sapi" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/kubernetes/k8sapi" ) var crdMeta = k8sapi.TypeMeta{ diff --git a/storage/memory/memory.go b/storage/memory/memory.go index 82264205e7..8eec2a1557 100644 --- a/storage/memory/memory.go +++ b/storage/memory/memory.go @@ -6,8 +6,8 @@ import ( "sync" "time" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/storage" ) // New returns an in memory storage. diff --git a/storage/memory/memory_test.go b/storage/memory/memory_test.go index 84a8826ef2..9c020ee095 100644 --- a/storage/memory/memory_test.go +++ b/storage/memory/memory_test.go @@ -6,8 +6,8 @@ import ( "github.com/sirupsen/logrus" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/conformance" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/conformance" ) func TestStorage(t *testing.T) { diff --git a/storage/memory/static_test.go b/storage/memory/static_test.go index 8513e0ee89..fdca32882d 100644 --- a/storage/memory/static_test.go +++ b/storage/memory/static_test.go @@ -8,7 +8,7 @@ import ( "github.com/sirupsen/logrus" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/storage" ) func TestStaticClients(t *testing.T) { diff --git a/storage/sql/config.go b/storage/sql/config.go index 0ce0f117db..5d2e78257e 100644 --- a/storage/sql/config.go +++ b/storage/sql/config.go @@ -15,8 +15,8 @@ import ( "github.com/go-sql-driver/mysql" "github.com/lib/pq" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/storage" ) const ( diff --git a/storage/sql/config_test.go b/storage/sql/config_test.go index 1178728c1a..1eca0194a8 100644 --- a/storage/sql/config_test.go +++ b/storage/sql/config_test.go @@ -10,9 +10,9 @@ import ( "github.com/sirupsen/logrus" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/conformance" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/storage" + "github.com/concourse/dex/storage/conformance" ) func withTimeout(t time.Duration, f func()) { diff --git a/storage/sql/crud.go b/storage/sql/crud.go index 4451e5c567..d742fb1f20 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/storage" ) // TODO(ericchiang): The update, insert, and select methods queries are all diff --git a/storage/sql/sql.go b/storage/sql/sql.go index 0a29216936..dad6a79ad4 100644 --- a/storage/sql/sql.go +++ b/storage/sql/sql.go @@ -10,7 +10,7 @@ import ( _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/pkg/log" ) // flavor represents a specific SQL implementation, and is used to translate query strings diff --git a/storage/sql/sqlite.go b/storage/sql/sqlite.go index faefd89ae3..235fcbc714 100644 --- a/storage/sql/sqlite.go +++ b/storage/sql/sqlite.go @@ -8,8 +8,8 @@ import ( sqlite3 "github.com/mattn/go-sqlite3" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage" + "github.com/concourse/dex/pkg/log" + "github.com/concourse/dex/storage" ) // SQLite3 options for creating an SQL db. diff --git a/storage/static.go b/storage/static.go index 806b61f9cd..c5a8acaf90 100644 --- a/storage/static.go +++ b/storage/static.go @@ -4,7 +4,7 @@ import ( "errors" "strings" - "github.com/dexidp/dex/pkg/log" + "github.com/concourse/dex/pkg/log" ) // Tests for this code are in the "memory" package, since this package doesn't diff --git a/web/templates/header.html b/web/templates/header.html index 0d4fea0fa2..8cf744e51e 100644 --- a/web/templates/header.html +++ b/web/templates/header.html @@ -18,4 +18,3 @@
-