Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 71 additions & 46 deletions connector/google/google.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package google

import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
85 changes: 85 additions & 0 deletions connector/google/google_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down