Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
3165da427d fix(auth): isolate Keep service account fallback (#414) (thanks @jgwesterlund)
Some checks failed
ci / test (push) Has been cancelled
ci / worker (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
2026-03-07 16:47:24 +00:00
John Westerlund
91b4822c60 fix(auth): restrict Keep service account to Keep API calls only
When a Keep-specific service account file (keep-sa-*.json) exists,
tokenSourceForServiceAccountScopes falls back to it for all API calls,
not just Keep. This causes 401 errors on Calendar, Gmail, Drive, and
other services that should use OAuth.

Only use keep-sa and legacy Keep SA files when serviceLabel is "keep",
allowing other services to fall through to OAuth authentication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:45:46 +00:00
4 changed files with 121 additions and 2 deletions

View File

@ -15,6 +15,7 @@
- Contacts: fix grouped parameter types in CRUD helpers to restore builds on newer Go toolchains. (#355) — thanks @laihenyi.
- Timezone: embed the IANA timezone database so Windows builds can resolve calendar timezones correctly. (#388) — thanks @visionik.
- Google API: use transport-level response-header timeouts for API clients while keeping token exchanges bounded, so large downloads are not cut short by `http.Client.Timeout`. (#425) — thanks @laihenyi.
- Auth: keep Keep-only service-account fallback isolated to Keep commands so other Google services do not accidentally pick it up. (#414) — thanks @jgwesterlund.
- 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.
- Gmail: preserve the selected `--client` during `watch serve` push handling instead of falling back to the default client. (#411) — thanks @chrysb.

View File

@ -111,7 +111,7 @@ func optionsForAccountScopes(ctx context.Context, serviceLabel string, email str
var ts oauth2.TokenSource
if serviceAccountTS, saPath, ok, err := tokenSourceForServiceAccountScopes(ctx, email, scopes); err != nil {
if serviceAccountTS, saPath, ok, err := tokenSourceForServiceAccountScopes(ctx, serviceLabel, email, scopes); err != nil {
return nil, fmt.Errorf("service account token source: %w", err)
} else if ok {
slog.Debug("using service account credentials", "email", email, "path", saPath)

View File

@ -25,7 +25,7 @@ var newServiceAccountTokenSource = func(ctx context.Context, keyJSON []byte, sub
return cfg.TokenSource(ctx), nil
}
func tokenSourceForServiceAccountScopes(ctx context.Context, email string, scopes []string) (oauth2.TokenSource, string, bool, error) {
func tokenSourceForServiceAccountScopes(ctx context.Context, serviceLabel string, email string, scopes []string) (oauth2.TokenSource, string, bool, error) {
saPath, err := config.ServiceAccountPath(email)
if err != nil {
return nil, "", false, fmt.Errorf("service account path: %w", err)
@ -45,6 +45,11 @@ func tokenSourceForServiceAccountScopes(ctx context.Context, email string, scope
return nil, "", false, fmt.Errorf("read service account key: %w", readErr)
}
// Keep-specific service account files should only be used for Keep.
if serviceLabel != "keep" {
return nil, "", false, nil
}
// Backwards compatibility: Keep used a dedicated stored service account file.
keepSAPath, keepErr := config.KeepServiceAccountPath(email)
if keepErr == nil {

View File

@ -0,0 +1,113 @@
package googleapi
import (
"context"
"os"
"path/filepath"
"testing"
"golang.org/x/oauth2"
"github.com/steipete/gogcli/internal/config"
)
func TestTokenSourceForServiceAccountScopes_NonKeepIgnoresKeepFallback(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config"))
keepSAPath, err := config.KeepServiceAccountPath("a@b.com")
if err != nil {
t.Fatalf("KeepServiceAccountPath: %v", err)
}
if _, ensureErr := config.EnsureDir(); ensureErr != nil {
t.Fatalf("EnsureDir: %v", ensureErr)
}
if writeErr := os.WriteFile(keepSAPath, []byte(`{"type":"service_account"}`), 0o600); writeErr != nil {
t.Fatalf("write keep sa: %v", writeErr)
}
origSA := newServiceAccountTokenSource
t.Cleanup(func() { newServiceAccountTokenSource = origSA })
called := false
newServiceAccountTokenSource = func(context.Context, []byte, string, []string) (oauth2.TokenSource, error) {
called = true
return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "t"}), nil
}
ts, path, ok, err := tokenSourceForServiceAccountScopes(context.Background(), "gmail", "a@b.com", []string{"s1"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if ok {
t.Fatalf("expected keep-only fallback to be ignored, got ok=true path=%q ts=%v", path, ts)
}
if called {
t.Fatalf("expected keep-only fallback not to initialize a token source")
}
}
func TestTokenSourceForServiceAccountScopes_KeepUsesKeepFallback(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config"))
keepSAPath, err := config.KeepServiceAccountPath("a@b.com")
if err != nil {
t.Fatalf("KeepServiceAccountPath: %v", err)
}
if _, ensureErr := config.EnsureDir(); ensureErr != nil {
t.Fatalf("EnsureDir: %v", ensureErr)
}
if writeErr := os.WriteFile(keepSAPath, []byte(`{"type":"service_account"}`), 0o600); writeErr != nil {
t.Fatalf("write keep sa: %v", writeErr)
}
origSA := newServiceAccountTokenSource
t.Cleanup(func() { newServiceAccountTokenSource = origSA })
called := false
newServiceAccountTokenSource = func(_ context.Context, keyJSON []byte, subject string, scopes []string) (oauth2.TokenSource, error) {
called = true
if subject != "a@b.com" {
t.Fatalf("unexpected subject: %q", subject)
}
if len(scopes) != 1 || scopes[0] != "s1" {
t.Fatalf("unexpected scopes: %#v", scopes)
}
if string(keyJSON) == "" {
t.Fatalf("expected key JSON")
}
return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "t"}), nil
}
ts, path, ok, err := tokenSourceForServiceAccountScopes(context.Background(), "keep", "a@b.com", []string{"s1"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !ok || ts == nil {
t.Fatalf("expected keep fallback token source, got ok=%v ts=%v", ok, ts)
}
if path != keepSAPath {
t.Fatalf("unexpected keep fallback path: %q", path)
}
if !called {
t.Fatalf("expected keep fallback token source initialization")
}
}