diff --git a/README.md b/README.md index dac886ee..8a6ee01f 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Dex implements the following connectors: | [Atlassian Crowd](https://dexidp.io/docs/connectors/atlassian-crowd/) | yes | yes | yes * | beta | preferred_username claim must be configured through config | | [Gitea](https://dexidp.io/docs/connectors/gitea/) | yes | no | yes | beta | | | [OpenStack Keystone](https://dexidp.io/docs/connectors/keystone/) | yes | yes | no | alpha | | +| [Cloud Foundry](https://dexidp.io/docs/connectors/cloudfoundry/) | no | yes | no | alpha | This connector is community maintained by [Concourse](https://github.com/concourse) | Stable, beta, and alpha are defined as: diff --git a/connector/cloudfoundry/cloudfoundry.go b/connector/cloudfoundry/cloudfoundry.go new file mode 100644 index 00000000..ff10bccd --- /dev/null +++ b/connector/cloudfoundry/cloudfoundry.go @@ -0,0 +1,412 @@ +package cloudfoundry + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "sort" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/dexidp/dex/connector" +) + +type cloudfoundryConnector struct { + clientID string + clientSecret string + redirectURI string + apiURL string + tokenURL string + authorizationURL string + userInfoURL string + httpClient *http.Client + logger *slog.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 { + Pagination pagination `json:"pagination"` + Resources []resource `json:"resources"` +} + +type pagination struct { + Next href `json:"next"` +} + +type href struct { + Href string `json:"href"` +} + +type resource struct { + GUID string `json:"guid"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Relationships relationships `json:"relationships"` +} + +type relationships struct { + Organization relOrganization `json:"organization"` + Space relSpace `json:"space"` +} + +type relOrganization struct { + Data data `json:"data"` +} + +type relSpace struct { + Data data `json:"data"` +} + +type data struct { + GUID string `json:"guid"` +} + +type space struct { + Name string + GUID string + OrgGUID string + Role string +} + +type org struct { + Name string + GUID string +} + +type infoResp struct { + Links links `json:"links"` +} + +type links struct { + Login login `json:"login"` +} + +type login struct { + Href string `json:"href"` +} + +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { + var err error + + cloudfoundryConn := &cloudfoundryConnector{ + clientID: c.ClientID, + clientSecret: c.ClientSecret, + apiURL: c.APIURL, + redirectURI: c.RedirectURI, + logger: logger, + } + + cloudfoundryConn.httpClient, err = newHTTPClient(c.RootCAs, c.InsecureSkipVerify) + if err != nil { + return nil, err + } + + apiURL := strings.TrimRight(c.APIURL, "/") + apiResp, err := cloudfoundryConn.httpClient.Get(apiURL) + if err != nil { + return nil, fmt.Errorf("failed-to-send-request-to-cloud-controller-api: %w", err) + } + + defer apiResp.Body.Close() + + if apiResp.StatusCode != http.StatusOK { + err = fmt.Errorf("request failed with status %d", apiResp.StatusCode) + return nil, fmt.Errorf("failed-get-info-response-from-api: %w", err) + } + + var apiResult infoResp + + json.NewDecoder(apiResp.Body).Decode(&apiResult) + + uaaURL := strings.TrimRight(apiResult.Links.Login.Href, "/") + uaaResp, err := cloudfoundryConn.httpClient.Get(fmt.Sprintf("%s/.well-known/openid-configuration", uaaURL)) + if err != nil { + return nil, fmt.Errorf("failed-to-send-request-to-uaa-api: %w", err) + } + + if apiResp.StatusCode != http.StatusOK { + err = fmt.Errorf("request failed with status %d", apiResp.StatusCode) + return nil, fmt.Errorf("failed-to-get-well-known-config-response-from-api: %w", err) + } + + defer uaaResp.Body.Close() + + var uaaResult map[string]interface{} + err = json.NewDecoder(uaaResp.Body).Decode(&uaaResult) + + if err != nil { + return nil, fmt.Errorf("failed-to-decode-response-from-uaa-api: %w", err) + } + + cloudfoundryConn.tokenURL, _ = uaaResult["token_endpoint"].(string) + cloudfoundryConn.authorizationURL, _ = uaaResult["authorization_endpoint"].(string) + cloudfoundryConn.userInfoURL, _ = uaaResult["userinfo_endpoint"].(string) + + return cloudfoundryConn, 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 := os.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 *cloudfoundryConnector) 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 filterUserOrgsSpaces(userOrgsSpaces []resource, orgs []resource, spaces []resource) ([]org, []space) { + var filteredOrgs []org + var filteredSpaces []space + + orgMap := make(map[string]org) + spaceMap := make(map[string]space) + + for _, org_resource := range orgs { + orgMap[org_resource.GUID] = org{ + Name: org_resource.Name, + GUID: org_resource.GUID, + } + } + + for _, space_resource := range spaces { + spaceMap[space_resource.GUID] = space{ + Name: space_resource.Name, + GUID: space_resource.GUID, + OrgGUID: space_resource.Relationships.Organization.Data.GUID, + } + } + + for _, userOrgSpace := range userOrgsSpaces { + if space, ok := spaceMap[userOrgSpace.Relationships.Space.Data.GUID]; ok { + space.Role = strings.TrimPrefix(userOrgSpace.Type, "space_") + filteredSpaces = append(filteredSpaces, space) + } + if org, ok := orgMap[userOrgSpace.Relationships.Organization.Data.GUID]; ok { + filteredOrgs = append(filteredOrgs, org) + } + } + + return filteredOrgs, filteredSpaces +} + +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 response: %v", err) + } + + resources = append(resources, response.Resources...) + + path = strings.TrimPrefix(response.Pagination.Next.Href, baseURL) + 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 *cloudfoundryConnector) 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 ( + orgsPath = "/v3/organizations" + spacesPath = "/v3/spaces" + userOrgsSpacesPath = fmt.Sprintf("/v3/roles?user_guids=%s&types=space_developer,space_manager,space_auditor,organization_user", identity.UserID) + ) + + if s.Groups { + userOrgsSpaces, err := fetchResources(c.apiURL, userOrgsSpacesPath, client) + if err != nil { + return identity, fmt.Errorf("failed to fetch user organizations: %v", err) + } + + orgs, err := fetchResources(c.apiURL, orgsPath, client) + if err != nil { + return identity, fmt.Errorf("failed to fetch organizaitons: %v", err) + } + + spaces, err := fetchResources(c.apiURL, spacesPath, client) + if err != nil { + return identity, fmt.Errorf("failed to fetch spaces: %v", err) + } + + developerOrgs, developerSpaces := filterUserOrgsSpaces(userOrgsSpaces, orgs, spaces) + + identity.Groups = getGroupsClaims(developerOrgs, developerSpaces) + } + + 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/cloudfoundry/cloudfoundry_test.go b/connector/cloudfoundry/cloudfoundry_test.go new file mode 100644 index 00000000..d15b3261 --- /dev/null +++ b/connector/cloudfoundry/cloudfoundry_test.go @@ -0,0 +1,538 @@ +package cloudfoundry + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/dexidp/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() + + cloudfoundryConn := &cloudfoundryConnector{ + 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 := cloudfoundryConn.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 := cloudfoundryConn.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 := cloudfoundryConn.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 string) (result map[string]interface{}) { + if strings.Contains(reqURL, "spaces?page=2&per_page=50") { + result = map[string]interface{}{ + "pagination": map[string]interface{}{ + "next": map[string]interface{}{ + "href": nil, + }, + }, + "resources": []map[string]interface{}{ + { + "guid": "some-space-guid-2", + "name": "some-space-name-2", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-2", + }, + }, + "space": nil, + }, + }, + }, + } + } else { + nextURL := fmt.Sprintf("%s?page=2&per_page=50", reqURL) + result = map[string]interface{}{ + "pagination": map[string]interface{}{ + "next": map[string]interface{}{ + "href": nextURL, + }, + }, + "resources": []map[string]interface{}{ + { + "guid": "some-space-guid-1", + "name": "some-space-name-1", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-1", + }, + }, + "space": nil, + }, + }, + }, + } + } + return result +} + +func testOrgHandler(reqURL string) (result map[string]interface{}) { + if strings.Contains(reqURL, "organizations?page=2&per_page=50") { + result = map[string]interface{}{ + "pagination": map[string]interface{}{ + "next": map[string]interface{}{ + "href": nil, + }, + }, + "resources": []map[string]interface{}{ + { + "guid": "some-org-guid-3", + "name": "some-org-name-3", + "relationships": map[string]interface{}{ + "user": nil, + "organization": nil, + "space": nil, + }, + }, + { + "guid": "some-org-guid-4", + "name": "some-org-name-4", + "relationships": map[string]interface{}{ + "user": nil, + "organization": nil, + "space": nil, + }, + }, + }, + } + } else { + nextURL := fmt.Sprintf("%s?page=2&per_page=50", reqURL) + result = map[string]interface{}{ + "pagination": map[string]interface{}{ + "next": map[string]interface{}{ + "href": nextURL, + }, + }, + "resources": []map[string]interface{}{ + { + "guid": "some-org-guid-1", + "name": "some-org-name-1", + "relationships": map[string]interface{}{ + "user": nil, + "organization": nil, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-1", + }, + }, + }, + }, + { + "guid": "some-org-guid-2", + "name": "some-org-name-2", + "relationships": map[string]interface{}{ + "user": nil, + "organization": nil, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-2", + }, + }, + }, + }, + }, + } + } + return result +} + +func testUserOrgsSpacesHandler(reqURL string) (result map[string]interface{}) { + if strings.Contains(reqURL, "page=2&per_page=50") { + result = map[string]interface{}{ + "pagination": map[string]interface{}{ + "next": map[string]interface{}{ + "href": nil, + }, + }, + "resources": []map[string]interface{}{ + { + "guid": "some-type-guid-3", + "type": "organization_user", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-3", + }, + }, + "space": nil, + }, + }, + { + "guid": "some-type-guid-4", + "type": "organization_user", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-4", + }, + }, + "space": nil, + }, + }, + { + "guid": "some-type-guid-1", + "type": "space_manager", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-1", + }, + }, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-1", + }, + }, + }, + }, + { + "guid": "some-type-guid-2", + "type": "space_developer", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-2", + }, + }, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-2", + }, + }, + }, + }, + { + "guid": "some-type-guid-2", + "type": "space_auditor", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-2", + }, + }, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-2", + }, + }, + }, + }, + { + "guid": "some-type-guid-2", + "type": "space_manager", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-2", + }, + }, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-2", + }, + }, + }, + }, + }, + } + } else { + nextURL := fmt.Sprintf("%s?page=2&per_page=50", reqURL) + result = map[string]interface{}{ + "pagination": map[string]interface{}{ + "next": map[string]interface{}{ + "href": nextURL, + }, + }, + "resources": []map[string]interface{}{ + { + "guid": "some-type-guid-1", + "type": "space_developer", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-1", + }, + }, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-1", + }, + }, + }, + }, + { + "guid": "some-type-guid-1", + "type": "space_auditor", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-1", + }, + }, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-1", + }, + }, + }, + }, + { + "guid": "some-type-guid-1", + "type": "space_manager", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-1", + }, + }, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-1", + }, + }, + }, + }, + { + "guid": "some-type-guid-2", + "type": "space_developer", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-2", + }, + }, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-2", + }, + }, + }, + }, + { + "guid": "some-type-guid-2", + "type": "space_auditor", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-2", + }, + }, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-2", + }, + }, + }, + }, + { + "guid": "some-type-guid-2", + "type": "space_manager", + "relationships": map[string]interface{}{ + "user": nil, + "organization": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-org-guid-2", + }, + }, + "space": map[string]interface{}{ + "data": map[string]interface{}{ + "guid": "some-space-guid-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("/", func(w http.ResponseWriter, r *http.Request) { + url := fmt.Sprintf("http://%s", r.Host) + + json.NewEncoder(w).Encode(map[string]interface{}{ + "links": map[string]interface{}{ + "login": map[string]string{ + "href": 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("/v3/organizations", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(testOrgHandler(r.URL.String())) + }) + + mux.HandleFunc("/v3/spaces", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(testSpaceHandler(r.URL.String())) + }) + + mux.HandleFunc("/v3/roles", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(testUserOrgsSpacesHandler(r.URL.String())) + }) + + return httptest.NewServer(mux) +} + +func newConnector(t *testing.T, serverURL string) *cloudfoundryConnector { + callBackURL := fmt.Sprintf("%s/callback", serverURL) + + testConfig := Config{ + APIURL: serverURL, + ClientID: "test-client", + ClientSecret: "secret", + RedirectURI: callBackURL, + InsecureSkipVerify: true, + } + + log := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})) + + conn, err := testConfig.Open("id", log) + if err != nil { + t.Fatal(err) + } + + cloudfoundryConn, ok := conn.(*cloudfoundryConnector) + if !ok { + t.Fatal(errors.New("it is not a cloudfoundry conn")) + } + + return cloudfoundryConn +} + +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/server/server.go b/server/server.go index c4f16536..6cdeb160 100644 --- a/server/server.go +++ b/server/server.go @@ -32,6 +32,7 @@ import ( "github.com/dexidp/dex/connector/atlassiancrowd" "github.com/dexidp/dex/connector/authproxy" "github.com/dexidp/dex/connector/bitbucketcloud" + "github.com/dexidp/dex/connector/cloudfoundry" "github.com/dexidp/dex/connector/gitea" "github.com/dexidp/dex/connector/github" "github.com/dexidp/dex/connector/gitlab" @@ -701,6 +702,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) }, + "cloudfoundry": func() ConnectorConfig { return new(cloudfoundry.Config) }, // Keep around for backwards compatibility. "samlExperimental": func() ConnectorConfig { return new(saml.Config) }, }