Repository: dexidp/dex (go)

Issue:
Google connector with ADC: unexpected end of JSON input
### Preflight Checklist

- [X] I agree to follow the [Code of Conduct](https://github.com/dexidp/dex/blob/master/.github/CODE_OF_CONDUCT.md) that this project adheres to.
- [X] I have searched the [issue tracker](https://www.github.com/dexidp/dex/issues) for an issue that matches the one I want to file, without success.
- [X] I am not looking for support or already pursued the available [support channels](https://github.com/dexidp/dex/issues/new/choose) without success.

### Version

ghcr.io/dexidp/dex:v2.34.0

### Storage Type

Kubernetes

### Installation Type

Other (specify below)

### Expected Behavior

google connector successfully use Application Default Credential via Compute Engine metadata server

### Actual Behavior

failed to initialize server: server: Failed to open connector google: failed to open connector: failed to create connector google: could not create directory service: unable to parse credentials to config: unexpected end of JSON input

### Steps To Reproduce

1. deploy dex on GCE / GKE. Use google connector without specifying `serviceAccountFilePath`
2. check the logs

### Additional Information

I installed dex via ArgoCD's `install.yaml`, and replaced its version from v2.30.2 to v2.34.0.
My setup worked OK before, with a key file in serviceAccountFilePath.

I tried running the following code on the same GKE pod by `kubectl exec`, and it went all good.
```
func main() {
        cred, err := google.FindDefaultCredentials(context.Background())
        if err != nil {
                fmt.Errorf("FindDefaultCredentials: %w", err)
        }
        fmt.Printf("Json: %v\n", cred.JSON)
        cfg, err := google.JWTConfigFromJSON(cred.JSON, admin.AdminDirectoryGroupReadonlyScope)
        if err != nil {
                fmt.Errorf("JWTConfigFromJSON: %w\n", err)
        }
        fmt.Printf("cfg: %v\n", cfg)
}
```
The above code works OK with following output, which is something I expected.
```
cred: &{my-project-name 0xc0000aed00 []}
Json:
cfg: <nil>
```

The code does basically the same as dex v2.34.0, so I am not sure why it doesn't work in dex.

cc @ichbinfrog

### Configuration

```yaml
connectors:
    - type: google
      id: google
      name: Google
      config:
        issuer: https://accounts.google.com
        clientID: $GOOGLE_CLIENT_ID
        clientSecret: $GOOGLE_CLIENT_SECRET
        redirectURI: https://my.domain/api/dex/callback
        hostedDomains: ['my.domain']
        serviceAccountFilePath: /tmp/keyfile.json
        adminEmail: test@my.domain
```


### Logs

```shell
Dex Version: v2.34.0-dirty, Go Version: go1.19.1, Go OS/ARCH: linux amd64
config issuer: https://***
config connector: google
config skipping approval screen
config refresh tokens rotation enabled: true
the application default credential is used since the service account file path is not used
failed to initialize server: server: Failed to open connector google: failed to open connector: failed to create connector google: could not create directory service: unable to parse credentials to config: unexpected end of JSON input
```


Pull request description:
Google: Implement groups fetch by default service account from metadata (support for GKE workload identity)
#### Overview
Hello,

This pull request addresses the need to fetch groups using the default service account from metadata in the Dex Google Connector. It adds more robust support for Google Cloud Platform environments, particularly GKE Workload Identity, and increases the module's resilience and versatility.

#### What this PR does / why we need it

Fix #2676

#### Special notes for your reviewer

#### Does this PR introduce a user-facing change?
No

```release-note
Implement groups fetch by default service account from metadata (support for GKE workload identity)
```

Docs PR: https://github.com/dexidp/website/pull/138


Patch:
diff --git a/connector/google/google.go b/connector/google/google.go
index c2df1100..c3042970 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/go.mod b/go.mod
index 107a87cb..80349c2a 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 d10acb0c..54f6f5a4 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=


Test patch:
diff --git a/connector/google/google_test.go b/connector/google/google_test.go
index c56a5e78..2fa2b783 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 {