diff --git a/connector/google/google.go b/connector/google/google.go index c2df11007c..c3042970dd 100644 --- a/connector/google/google.go +++ b/connector/google/google.go @@ -10,11 +10,13 @@ import ( "strings" "time" + "cloud.google.com/go/compute/metadata" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/exp/slices" "golang.org/x/oauth2" "golang.org/x/oauth2/google" admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/impersonate" "google.golang.org/api/option" "github.com/dexidp/dex/connector" @@ -98,8 +100,7 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e return nil, fmt.Errorf("directory service requires the domainToAdminEmail option to be configured") } - // Fixing a regression caused by default config fallback: https://github.com/dexidp/dex/issues/2699 - if (c.ServiceAccountFilePath != "" && len(c.DomainToAdminEmail) > 0) || slices.Contains(scopes, "groups") { + if (len(c.DomainToAdminEmail) > 0) || slices.Contains(scopes, "groups") { for domain, adminEmail := range c.DomainToAdminEmail { srv, err := createDirectoryService(c.ServiceAccountFilePath, adminEmail, logger) if err != nil { @@ -362,25 +363,83 @@ func (c *googleConnector) extractDomainFromEmail(email string) string { return wildcardDomainToAdminEmail } +// getCredentialsFromFilePath reads and returns the service account credentials from the file at the provided path. +// If an error occurs during the read, it is returned. +func getCredentialsFromFilePath(serviceAccountFilePath string) ([]byte, error) { + jsonCredentials, err := os.ReadFile(serviceAccountFilePath) + if err != nil { + return nil, fmt.Errorf("error reading credentials from file: %v", err) + } + return jsonCredentials, nil +} + +// getCredentialsFromDefault retrieves the application's default credentials. +// If the default credential is empty, it attempts to create a new service with metadata credentials. +// If successful, it returns the service and nil error. +// If unsuccessful, it returns the error and a nil service. +func getCredentialsFromDefault(ctx context.Context, email string, logger log.Logger) ([]byte, *admin.Service, error) { + credential, err := google.FindDefaultCredentials(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to fetch application default credentials: %w", err) + } + + if credential.JSON == nil { + logger.Info("JSON is empty, using flow for GCE") + service, err := createServiceWithMetadataServer(ctx, email, logger) + if err != nil { + return nil, nil, err + } + return nil, service, nil + } + + return credential.JSON, nil, nil +} + +// createServiceWithMetadataServer creates a new service using metadata server. +// If an error occurs during the process, it is returned along with a nil service. +func createServiceWithMetadataServer(ctx context.Context, adminEmail string, logger log.Logger) (*admin.Service, error) { + serviceAccountEmail, err := metadata.Email("default") + logger.Infof("discovered serviceAccountEmail: %s", serviceAccountEmail) + + if err != nil { + return nil, fmt.Errorf("unable to get service account email from metadata server: %v", err) + } + + config := impersonate.CredentialsConfig{ + TargetPrincipal: serviceAccountEmail, + Scopes: []string{admin.AdminDirectoryGroupReadonlyScope}, + Lifetime: 0, + Subject: adminEmail, + } + + tokenSource, err := impersonate.CredentialsTokenSource(ctx, config) + if err != nil { + return nil, fmt.Errorf("unable to impersonate with %s, error: %v", adminEmail, err) + } + + return admin.NewService(ctx, option.WithHTTPClient(oauth2.NewClient(ctx, tokenSource))) +} + // createDirectoryService sets up super user impersonation and creates an admin client for calling // the google admin api. If no serviceAccountFilePath is defined, the application default credential // is used. -func createDirectoryService(serviceAccountFilePath, email string, logger log.Logger) (*admin.Service, error) { +func createDirectoryService(serviceAccountFilePath, email string, logger log.Logger) (service *admin.Service, err error) { var jsonCredentials []byte - var err error ctx := context.Background() if serviceAccountFilePath == "" { logger.Warn("the application default credential is used since the service account file path is not used") - credential, err := google.FindDefaultCredentials(ctx) + jsonCredentials, service, err = getCredentialsFromDefault(ctx, email, logger) if err != nil { - return nil, fmt.Errorf("failed to fetch application default credentials: %w", err) + return + } + if service != nil { + return } - jsonCredentials = credential.JSON } else { - jsonCredentials, err = os.ReadFile(serviceAccountFilePath) + jsonCredentials, err = getCredentialsFromFilePath(serviceAccountFilePath) if err != nil { - return nil, fmt.Errorf("error reading credentials from file: %v", err) + return } } config, err := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope) diff --git a/connector/google/google_test.go b/connector/google/google_test.go index c56a5e78ba..2fa2b783ef 100644 --- a/connector/google/google_test.go +++ b/connector/google/google_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "os" + "strings" "testing" "github.com/sirupsen/logrus" @@ -295,6 +296,103 @@ func TestDomainToAdminEmailConfig(t *testing.T) { } } +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 { diff --git a/go.mod b/go.mod index 107a87cb0f..80349c2a5a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/dexidp/dex go 1.21 require ( + cloud.google.com/go/compute/metadata v0.3.0 entgo.io/ent v0.13.1 github.com/AppsFlyer/go-sundheit v0.5.0 github.com/Masterminds/semver v1.5.0 @@ -45,7 +46,6 @@ require ( ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect cloud.google.com/go/auth v0.4.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect - cloud.google.com/go/compute/metadata v0.3.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Masterminds/goutils v1.1.1 // indirect diff --git a/go.sum b/go.sum index d10acb0c58..54f6f5a477 100644 --- a/go.sum +++ b/go.sum @@ -231,6 +231,8 @@ go.etcd.io/etcd/client/v3 v3.5.13 h1:o0fHTNJLeO0MyVbc7I3fsCf6nrOqn5d+diSarKnB2js go.etcd.io/etcd/client/v3 v3.5.13/go.mod h1:cqiAeY8b5DEEcpxvgWKsbLIWNM/8Wy2xJSDMtioMcoI= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= @@ -327,6 +329,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=