feat(auth): support pure service account mode without impersonation

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)
This commit is contained in:
Mitsuyuki Osabe 2026-03-03 05:07:34 +00:00 committed by Peter Steinberger
parent b8df361598
commit 91b0baa92d
3 changed files with 55 additions and 1 deletions

View File

@ -33,6 +33,7 @@
- Groups: include required label filters in transitive group searches so `groups list` doesnt 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 dont 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.

View File

@ -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})

View File

@ -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)