diff --git a/CHANGELOG.md b/CHANGELOG.md index 519e0d0..276cb3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Groups: include required label filters in transitive group searches so `groups list` doesn’t 400 on Cloud Identity. (#315) — thanks @salmonumbrella. - Gmail: fall back to `MimeType` charset hints when `Content-Type` headers are missing so GBK/GB2312 message bodies decode correctly. (#428) — thanks @WinnCook. - Auth: preserve scope-shaping flags in the remote step-2 replay guidance for `auth add --remote`. (#427) — thanks @doodaaatimmy-creator. +- Auth: allow pure service-account mode when the configured subject matches the service account itself, instead of forcing domain-wide delegation impersonation. (#399) — thanks @carrotRakko. - Calendar: preserve full RRULE values and recurring-event timezones during updates so recurrence edits don’t lose BYDAY lists or hit missing-timezone API errors. (#392) — thanks @salmonumbrella. - Gmail: add a fetch delay in `watch serve` so History API reads don't race message indexing. (#397) — thanks @salmonumbrella. - Gmail: allow Workspace-managed send-as aliases with empty verification status in `send` and `drafts create`. (#407) — thanks @salmonumbrella. diff --git a/internal/googleapi/service_account.go b/internal/googleapi/service_account.go index be01871..d26fb58 100644 --- a/internal/googleapi/service_account.go +++ b/internal/googleapi/service_account.go @@ -12,12 +12,23 @@ import ( "github.com/steipete/gogcli/internal/config" ) +func serviceAccountSubject(subject string, serviceAccountEmail string) string { + if subject == "" || subject == serviceAccountEmail { + return "" + } + + return subject +} + var newServiceAccountTokenSource = func(ctx context.Context, keyJSON []byte, subject string, scopes []string) (oauth2.TokenSource, error) { cfg, err := google.JWTConfigFromJSON(keyJSON, scopes...) 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. + cfg.Subject = serviceAccountSubject(subject, cfg.Email) // Ensure token exchanges don't hang forever. ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Timeout: tokenExchangeTimeout}) diff --git a/internal/googleapi/service_account_test.go b/internal/googleapi/service_account_test.go index c73014d..6cdcfe8 100644 --- a/internal/googleapi/service_account_test.go +++ b/internal/googleapi/service_account_test.go @@ -1,3 +1,4 @@ +<<<<<<< HEAD package googleapi import ( @@ -11,6 +12,47 @@ import ( "github.com/steipete/gogcli/internal/config" ) +func TestServiceAccountSubject(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + subject string + serviceAccountEmail string + want string + }{ + { + name: "empty subject stays empty", + subject: "", + serviceAccountEmail: "sa@test-project.iam.gserviceaccount.com", + want: "", + }, + { + name: "same subject becomes pure service account mode", + subject: "sa@test-project.iam.gserviceaccount.com", + serviceAccountEmail: "sa@test-project.iam.gserviceaccount.com", + want: "", + }, + { + name: "different subject keeps impersonation target", + subject: "user@example.com", + serviceAccountEmail: "sa@test-project.iam.gserviceaccount.com", + want: "user@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := serviceAccountSubject(tt.subject, tt.serviceAccountEmail) + if got != tt.want { + t.Fatalf("serviceAccountSubject(%q, %q) = %q, want %q", tt.subject, tt.serviceAccountEmail, got, tt.want) + } + }) + } +} + func TestTokenSourceForServiceAccountScopes_NonKeepIgnoresKeepFallback(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home)