diff --git a/internal/googleapi/service_account.go b/internal/googleapi/service_account.go index 021963c5..34de1143 100644 --- a/internal/googleapi/service_account.go +++ b/internal/googleapi/service_account.go @@ -17,7 +17,13 @@ var newServiceAccountTokenSource = func(ctx context.Context, keyJSON []byte, sub if err != nil { return nil, fmt.Errorf("parse service account: %w", err) } - cfg.Subject = subject + // Only set Subject (impersonation) when the caller requests a different + // identity than the service account itself. When subject matches the + // SA's client_email we run in "pure SA mode" — no Domain-Wide Delegation + // required; the SA can only access resources explicitly shared with it. + if subject != "" && subject != cfg.Email { + cfg.Subject = subject + } // Ensure token exchanges don't hang forever. ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Timeout: defaultHTTPTimeout}) diff --git a/internal/googleapi/service_account_test.go b/internal/googleapi/service_account_test.go new file mode 100644 index 00000000..62884b19 --- /dev/null +++ b/internal/googleapi/service_account_test.go @@ -0,0 +1,83 @@ +package googleapi + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "testing" +) + +func generateTestSAKeyJSON(t *testing.T, clientEmail string) []byte { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + + der := x509.MarshalPKCS1PrivateKey(key) + block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der} + keyPEM := string(pem.EncodeToMemory(block)) + + return []byte(fmt.Sprintf(`{ + "type": "service_account", + "project_id": "test-project", + "private_key_id": "key-id", + "private_key": %q, + "client_email": %q, + "client_id": "123456", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + }`, keyPEM, clientEmail)) +} + +func TestNewServiceAccountTokenSource_PureSAMode(t *testing.T) { + const saEmail = "sa@test-project.iam.gserviceaccount.com" + keyJSON := generateTestSAKeyJSON(t, saEmail) + + // Pure SA mode: subject matches the SA's own client_email. + // cfg.Subject should NOT be set, so no DWD is required. + ts, err := newServiceAccountTokenSource(context.Background(), keyJSON, saEmail, []string{"https://www.googleapis.com/auth/calendar"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ts == nil { + t.Fatalf("expected non-nil token source") + } +} + +func TestNewServiceAccountTokenSource_Impersonation(t *testing.T) { + const saEmail = "sa@test-project.iam.gserviceaccount.com" + const userEmail = "user@example.com" + keyJSON := generateTestSAKeyJSON(t, saEmail) + + // Impersonation mode: subject differs from the SA's client_email. + // cfg.Subject should be set to the user email (DWD required). + ts, err := newServiceAccountTokenSource(context.Background(), keyJSON, userEmail, []string{"https://www.googleapis.com/auth/calendar"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ts == nil { + t.Fatalf("expected non-nil token source") + } +} + +func TestNewServiceAccountTokenSource_EmptySubject(t *testing.T) { + const saEmail = "sa@test-project.iam.gserviceaccount.com" + keyJSON := generateTestSAKeyJSON(t, saEmail) + + // Empty subject: should not set cfg.Subject. + ts, err := newServiceAccountTokenSource(context.Background(), keyJSON, "", []string{"https://www.googleapis.com/auth/calendar"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ts == nil { + t.Fatalf("expected non-nil token source") + } +}