From 91b0baa92daa3535bcb25f5dfbb8acd2cf2bca25 Mon Sep 17 00:00:00 2001 From: Mitsuyuki Osabe <24588751+carrotRakko@users.noreply.github.com> Date: Tue, 3 Mar 2026 05:07:34 +0000 Subject: [PATCH] feat(auth): support pure service account mode without impersonation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip setting cfg.Subject when the subject matches the service account's own client_email. This lets a service account access only resources explicitly shared with it, without requiring Domain-Wide Delegation. Closes steipete/gogcli#346 ✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved) --- CHANGELOG.md | 1 + internal/googleapi/service_account.go | 13 ++++++- internal/googleapi/service_account_test.go | 42 ++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) 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)