package google import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "github.com/stretchr/testify/assert" admin "google.golang.org/api/admin/directory/v1" "google.golang.org/api/option" "github.com/dexidp/dex/connector" ) var ( // groups_0 // ┌───────┤ // groups_2 groups_1 // │ ├────────┐ // └── user_1 user_2 testGroups = map[string][]*admin.Group{ "user_1@dexidp.com": {{Email: "groups_2@dexidp.com"}, {Email: "groups_1@dexidp.com"}}, "user_2@dexidp.com": {{Email: "groups_1@dexidp.com"}}, "groups_1@dexidp.com": {{Email: "groups_0@dexidp.com"}}, "groups_2@dexidp.com": {{Email: "groups_0@dexidp.com"}}, "groups_0@dexidp.com": {}, } callCounter = make(map[string]int) ) func testSetup() *httptest.Server { mux := http.NewServeMux() mux.HandleFunc("/admin/directory/v1/groups/", func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json") userKey := r.URL.Query().Get("userKey") if groups, ok := testGroups[userKey]; ok { json.NewEncoder(w).Encode(admin.Groups{Groups: groups}) callCounter[userKey]++ } }) return httptest.NewServer(mux) } func newConnector(config *Config) (*googleConnector, error) { log := slog.New(slog.DiscardHandler) conn, err := config.Open("id", log) if err != nil { return nil, err } googleConn, ok := conn.(*googleConnector) if !ok { return nil, fmt.Errorf("failed to convert to googleConnector") } return googleConn, nil } func tempServiceAccountKey() (string, error) { fd, err := os.CreateTemp("", "google_service_account_key") if err != nil { return "", err } defer fd.Close() err = json.NewEncoder(fd).Encode(map[string]string{ "type": "service_account", "project_id": "sample-project", "private_key_id": "sample-key-id", "private_key": "-----BEGIN PRIVATE KEY-----\nsample-key\n-----END PRIVATE KEY-----\n", "client_id": "sample-client-id", "client_x509_cert_url": "localhost", }) return fd.Name(), err } func TestOpen(t *testing.T) { ts := testSetup() defer ts.Close() type testCase struct { config *Config expectedErr string // string to set in GOOGLE_APPLICATION_CREDENTIALS. As local development environments can // already contain ADC, test cases will be built upon this setting this env variable adc string } serviceAccountFilePath, err := tempServiceAccountKey() assert.Nil(t, err) for name, reference := range map[string]testCase{ "missing_admin_email": { config: &Config{ ClientID: "testClient", ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, ServiceAccountFilePath: serviceAccountFilePath, }, expectedErr: "requires the domainToAdminEmail", }, "service_account_key_not_found": { config: &Config{ ClientID: "testClient", ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, DomainToAdminEmail: map[string]string{"*": "foo@bar.com"}, ServiceAccountFilePath: "not_found.json", }, expectedErr: "error reading credentials", }, "service_account_key_valid": { config: &Config{ ClientID: "testClient", ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, DomainToAdminEmail: map[string]string{"bar.com": "foo@bar.com"}, ServiceAccountFilePath: serviceAccountFilePath, }, expectedErr: "", }, "adc": { config: &Config{ ClientID: "testClient", ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, DomainToAdminEmail: map[string]string{"*": "foo@bar.com"}, }, adc: serviceAccountFilePath, expectedErr: "", }, "adc_priority": { config: &Config{ ClientID: "testClient", ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, DomainToAdminEmail: map[string]string{"*": "foo@bar.com"}, ServiceAccountFilePath: serviceAccountFilePath, }, adc: "/dev/null", expectedErr: "", }, } { reference := reference t.Run(name, func(t *testing.T) { assert := assert.New(t) os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", reference.adc) conn, err := newConnector(reference.config) if reference.expectedErr == "" { assert.Nil(err) assert.NotNil(conn) } else { assert.ErrorContains(err, reference.expectedErr) } }) } } func TestGetGroups(t *testing.T) { ts := testSetup() defer ts.Close() serviceAccountFilePath, err := tempServiceAccountKey() assert.Nil(t, err) os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountFilePath) conn, err := newConnector(&Config{ ClientID: "testClient", ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, DomainToAdminEmail: map[string]string{"*": "admin@dexidp.com"}, }) assert.Nil(t, err) conn.adminSrv[wildcardDomainToAdminEmail], err = admin.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(ts.URL)) assert.Nil(t, err) type testCase struct { userKey string fetchTransitiveGroupMembership bool shouldErr bool expectedGroups []string } for name, testCase := range map[string]testCase{ "user1_non_transitive_lookup": { userKey: "user_1@dexidp.com", fetchTransitiveGroupMembership: false, shouldErr: false, expectedGroups: []string{"groups_1@dexidp.com", "groups_2@dexidp.com"}, }, "user1_transitive_lookup": { userKey: "user_1@dexidp.com", fetchTransitiveGroupMembership: true, shouldErr: false, expectedGroups: []string{"groups_0@dexidp.com", "groups_1@dexidp.com", "groups_2@dexidp.com"}, }, "user2_non_transitive_lookup": { userKey: "user_2@dexidp.com", fetchTransitiveGroupMembership: false, shouldErr: false, expectedGroups: []string{"groups_1@dexidp.com"}, }, "user2_transitive_lookup": { userKey: "user_2@dexidp.com", fetchTransitiveGroupMembership: true, shouldErr: false, expectedGroups: []string{"groups_0@dexidp.com", "groups_1@dexidp.com"}, }, } { testCase := testCase callCounter = map[string]int{} t.Run(name, func(t *testing.T) { assert := assert.New(t) lookup := make(map[string]struct{}) groups, err := conn.getGroups(testCase.userKey, testCase.fetchTransitiveGroupMembership, lookup) if testCase.shouldErr { assert.NotNil(err) } else { assert.Nil(err) } assert.ElementsMatch(testCase.expectedGroups, groups) t.Logf("[%s] Amount of API calls per userKey: %+v\n", t.Name(), callCounter) }) } } func TestDomainToAdminEmailConfig(t *testing.T) { ts := testSetup() defer ts.Close() serviceAccountFilePath, err := tempServiceAccountKey() assert.Nil(t, err) os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountFilePath) conn, err := newConnector(&Config{ ClientID: "testClient", ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, DomainToAdminEmail: map[string]string{"dexidp.com": "admin@dexidp.com"}, }) assert.Nil(t, err) conn.adminSrv["dexidp.com"], err = admin.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(ts.URL)) assert.Nil(t, err) type testCase struct { userKey string expectedErr string } for name, testCase := range map[string]testCase{ "correct_user_request": { userKey: "user_1@dexidp.com", expectedErr: "", }, "wrong_user_request": { userKey: "user_1@foo.bar", expectedErr: "unable to find super admin email", }, "wrong_connector_response": { userKey: "user_1_foo.bar", expectedErr: "unable to find super admin email", }, } { testCase := testCase callCounter = map[string]int{} t.Run(name, func(t *testing.T) { assert := assert.New(t) lookup := make(map[string]struct{}) _, err := conn.getGroups(testCase.userKey, true, lookup) if testCase.expectedErr != "" { assert.ErrorContains(err, testCase.expectedErr) } else { assert.Nil(err) } t.Logf("[%s] Amount of API calls per userKey: %+v\n", t.Name(), callCounter) }) } } var gceMetadataFlags = map[string]bool{ "failOnEmailRequest": false, } func mockGCEMetadataServer() *httptest.Server { mux := http.NewServeMux() mux.HandleFunc("/computeMetadata/v1/instance/service-accounts/default/email", func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json") if gceMetadataFlags["failOnEmailRequest"] { w.WriteHeader(http.StatusBadRequest) } json.NewEncoder(w).Encode("my-service-account@example-project.iam.gserviceaccount.com") }) mux.HandleFunc("/computeMetadata/v1/instance/service-accounts/default/token", func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json") json.NewEncoder(w).Encode(struct { AccessToken string `json:"access_token"` ExpiresInSec int `json:"expires_in"` TokenType string `json:"token_type"` }{ AccessToken: "my-example.token", ExpiresInSec: 3600, TokenType: "Bearer", }) }) return httptest.NewServer(mux) } func TestGCEWorkloadIdentity(t *testing.T) { ts := testSetup() defer ts.Close() metadataServer := mockGCEMetadataServer() defer metadataServer.Close() metadataServerHost := strings.Replace(metadataServer.URL, "http://", "", 1) os.Setenv("GCE_METADATA_HOST", metadataServerHost) os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") os.Setenv("HOME", "/tmp") gceMetadataFlags["failOnEmailRequest"] = true _, err := newConnector(&Config{ ClientID: "testClient", ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, DomainToAdminEmail: map[string]string{"dexidp.com": "admin@dexidp.com"}, }) assert.Error(t, err) gceMetadataFlags["failOnEmailRequest"] = false conn, err := newConnector(&Config{ ClientID: "testClient", ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, DomainToAdminEmail: map[string]string{"dexidp.com": "admin@dexidp.com"}, }) assert.Nil(t, err) conn.adminSrv["dexidp.com"], err = admin.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(ts.URL)) assert.Nil(t, err) type testCase struct { userKey string expectedErr string } for name, testCase := range map[string]testCase{ "correct_user_request": { userKey: "user_1@dexidp.com", expectedErr: "", }, "wrong_user_request": { userKey: "user_1@foo.bar", expectedErr: "unable to find super admin email", }, "wrong_connector_response": { userKey: "user_1_foo.bar", expectedErr: "unable to find super admin email", }, } { t.Run(name, func(t *testing.T) { assert := assert.New(t) lookup := make(map[string]struct{}) _, err := conn.getGroups(testCase.userKey, true, lookup) if testCase.expectedErr != "" { assert.ErrorContains(err, testCase.expectedErr) } else { assert.Nil(err) } }) } } func TestPromptTypeConfig(t *testing.T) { promptTypeLogin := "login" cases := []struct { name string promptType *string expectedPromptTypeValue string }{ { name: "prompt type is nil", promptType: nil, expectedPromptTypeValue: "consent", }, { name: "prompt type is empty", promptType: new(string), expectedPromptTypeValue: "", }, { name: "prompt type is set", promptType: &promptTypeLogin, expectedPromptTypeValue: "login", }, } ts := testSetup() defer ts.Close() serviceAccountFilePath, err := tempServiceAccountKey() assert.Nil(t, err) os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountFilePath) for _, test := range cases { t.Run(test.name, func(t *testing.T) { conn, err := newConnector(&Config{ ClientID: "testClient", ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups", "offline_access"}, DomainToAdminEmail: map[string]string{"dexidp.com": "admin@dexidp.com"}, PromptType: test.promptType, }) assert.Nil(t, err) assert.Equal(t, test.expectedPromptTypeValue, conn.promptType) loginURL, _, err := conn.LoginURL(connector.Scopes{OfflineAccess: true}, ts.URL+"/callback", "state") assert.Nil(t, err) urlp, err := url.Parse(loginURL) assert.Nil(t, err) assert.Equal(t, test.expectedPromptTypeValue, urlp.Query().Get("prompt")) }) } }