diff --git a/connector/google/google.go b/connector/google/google.go index 4a8599c0b1..e39f5f6b8b 100644 --- a/connector/google/google.go +++ b/connector/google/google.go @@ -3,6 +3,7 @@ package google import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -364,38 +365,6 @@ 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 *slog.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 *slog.Logger) (*admin.Service, error) { @@ -424,34 +393,90 @@ func createServiceWithMetadataServer(ctx context.Context, adminEmail string, log // 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 *slog.Logger) (service *admin.Service, err error) { +func createDirectoryService(serviceAccountFilePath, email string, logger *slog.Logger) (*admin.Service, error) { + ctx := context.Background() + 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") - jsonCredentials, service, err = getCredentialsFromDefault(ctx, email, logger) + creds, err := google.FindDefaultCredentialsWithParams(ctx, google.CredentialsParams{ + Scopes: []string{admin.AdminDirectoryGroupReadonlyScope}, + }) if err != nil { - return + return nil, fmt.Errorf("failed to fetch application default credentials: %v", err) } - if service != nil { - return + if creds.JSON == nil { + logger.Info("JSON is empty, using flow for GCE") + return createServiceWithMetadataServer(ctx, email, logger) } + jsonCredentials = creds.JSON } else { - jsonCredentials, err = getCredentialsFromFilePath(serviceAccountFilePath) + jsonCredentials, err = os.ReadFile(serviceAccountFilePath) if err != nil { - return + return nil, fmt.Errorf("error reading credentials from file: %v", err) + } + } + + // For service_account credentials, JWTConfigFromJSON handles Subject (domain-wide delegation) + // natively by signing JWTs with the private key. + config, jwtErr := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope) + if jwtErr == nil { + if email != "" { + config.Subject = email } + return admin.NewService(ctx, option.WithHTTPClient(config.Client(ctx))) + } + + // For other credential types (e.g. external_account), the oauth2 library does not support + // Subject (domain-wide delegation). Use impersonate.CredentialsTokenSource which handles + // domain-wide delegation by calling the signJwt API on the target service account. + var extCred struct { + ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"` } - config, err := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope) + if err := json.Unmarshal(jsonCredentials, &extCred); err != nil { + return nil, fmt.Errorf("unable to parse credentials: %v", err) + } + + targetPrincipal, err := extractServiceAccountEmail(extCred.ServiceAccountImpersonationURL) if err != nil { - return nil, fmt.Errorf("unable to parse client secret file to config: %v", err) + return nil, fmt.Errorf("unable to extract service account from credentials: %v", err) } - // Only attempt impersonation when there is a user configured - if email != "" { - config.Subject = email + logger.Info("using workload identity federation", "targetPrincipal", targetPrincipal) + + impConfig := impersonate.CredentialsConfig{ + TargetPrincipal: targetPrincipal, + Scopes: []string{admin.AdminDirectoryGroupReadonlyScope}, + Subject: email, + } + + tokenSource, err := impersonate.CredentialsTokenSource(ctx, impConfig, option.WithCredentialsJSON(jsonCredentials)) + if err != nil { + return nil, fmt.Errorf("unable to create impersonated token source: %v", err) + } + + return admin.NewService(ctx, option.WithHTTPClient(oauth2.NewClient(ctx, tokenSource))) +} + +// extractServiceAccountEmail extracts the service account email from a service account impersonation URL. +// The URL format is: https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/EMAIL:generateAccessToken +func extractServiceAccountEmail(impersonationURL string) (string, error) { + if impersonationURL == "" { + return "", fmt.Errorf("service_account_impersonation_url is empty in credentials") + } + + parts := strings.Split(impersonationURL, "/") + for i, part := range parts { + if part == "serviceAccounts" && i+1 < len(parts) { + sa := parts[i+1] + if idx := strings.Index(sa, ":"); idx != -1 { + sa = sa[:idx] + } + return sa, nil + } } - return admin.NewService(ctx, option.WithHTTPClient(config.Client(ctx))) + return "", fmt.Errorf("unable to extract service account email from URL: %s", impersonationURL) } diff --git a/connector/google/google_test.go b/connector/google/google_test.go index ce0e017cf8..6cd294a357 100644 --- a/connector/google/google_test.go +++ b/connector/google/google_test.go @@ -393,6 +393,91 @@ func TestGCEWorkloadIdentity(t *testing.T) { } } +func TestExtractServiceAccountEmail(t *testing.T) { + cases := []struct { + name string + url string + expected string + expectedErr string + }{ + { + name: "valid URL", + url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa@my-project.iam.gserviceaccount.com:generateAccessToken", + expected: "sa@my-project.iam.gserviceaccount.com", + }, + { + name: "empty URL", + url: "", + expectedErr: "service_account_impersonation_url is empty", + }, + { + name: "URL without serviceAccounts segment", + url: "https://iamcredentials.googleapis.com/v1/projects/-/something/else", + expectedErr: "unable to extract service account email", + }, + { + name: "URL without generateAccessToken suffix", + url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa@proj.iam.gserviceaccount.com", + expected: "sa@proj.iam.gserviceaccount.com", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + email, err := extractServiceAccountEmail(tc.url) + if tc.expectedErr != "" { + assert.ErrorContains(t, err, tc.expectedErr) + } else { + assert.Nil(t, err) + assert.Equal(t, tc.expected, email) + } + }) + } +} + +func tempExternalAccountCredential(impersonationURL string) (string, error) { + fd, err := os.CreateTemp("", "google_external_account_cred") + if err != nil { + return "", err + } + defer fd.Close() + err = json.NewEncoder(fd).Encode(map[string]interface{}{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", + "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request", + "service_account_impersonation_url": impersonationURL, + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": map[string]interface{}{ + "environment_id": "aws1", + "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone", + "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials", + "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + }, + }) + return fd.Name(), err +} + +func TestOpenWithExternalAccount(t *testing.T) { + ts := testSetup() + defer ts.Close() + + impersonationURL := "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa@my-project.iam.gserviceaccount.com:generateAccessToken" + externalAccountFilePath, err := tempExternalAccountCredential(impersonationURL) + assert.Nil(t, err) + defer os.Remove(externalAccountFilePath) + + os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", externalAccountFilePath) + conn, err := newConnector(&Config{ + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + DomainToAdminEmail: map[string]string{"*": "admin@example.com"}, + }) + assert.Nil(t, err) + assert.NotNil(t, conn) +} + func TestPromptTypeConfig(t *testing.T) { promptTypeLogin := "login" cases := []struct {