Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
b03a4b8c1b fix(auth): persist rotated refresh tokens (#373) (thanks @joshp123)
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 17:56:52 +00:00
joshp123
0ffa48a4c9 fix(auth): persist rotated refresh tokens during API calls
Why
- gogcli refreshed access tokens from a stored refresh token but never wrote back
  a rotated refresh token returned by Google.
- In multi-process CLI usage this can leave keyring state stale across invocations
  and eventually cause invalid_grant when the old token is retired.

What
- add persistingTokenSource wrapper in internal/googleapi/client.go
- wrap oauth2.Config TokenSource in tokenSourceForAccountScopes
- persist rotated refresh token back into secrets store when it changes
- keep persistence failures non-fatal (warn, return token)
- add unit tests for rotate/no-rotate/persist-failure paths

Tests
- go test ./internal/googleapi
2026-03-07 17:54:38 +00:00
3 changed files with 171 additions and 7 deletions

View File

@ -20,6 +20,7 @@
- Contacts: send the required `copyMask` when deleting "other contacts", avoiding People API 400 errors. (#384) — thanks @rbansal42.
- Calendar: hide cancelled/deleted events from `calendar events` list output by explicitly setting `showDeleted=false`. (#362) — thanks @sharukh010.
- Calendar: use `Calendars.Get` for timezone lookups so service-account flows dont 404 on `calendarList/primary`. (#325) — thanks @markwatson.
- Auth: persist rotated OAuth refresh tokens returned during API calls so later commands keep working without re-auth. (#373) — thanks @joshp123.
- Gmail: fall back to `MimeType` charset hints when `Content-Type` headers are missing so GBK/GB2312 message bodies decode correctly. (#428) — thanks @WinnCook.
- 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

@ -7,6 +7,8 @@ import (
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/99designs/keyring"
@ -39,6 +41,58 @@ var (
openSecretsStore = secrets.OpenDefault
)
type persistingTokenSource struct {
base oauth2.TokenSource
store secrets.Store
client string
email string
mu sync.Mutex
tok secrets.Token
}
func newPersistingTokenSource(base oauth2.TokenSource, store secrets.Store, client string, email string, tok secrets.Token) oauth2.TokenSource {
return &persistingTokenSource{
base: base,
store: store,
client: client,
email: email,
tok: tok,
}
}
func (p *persistingTokenSource) Token() (*oauth2.Token, error) {
t, err := p.base.Token()
if err != nil {
return nil, fmt.Errorf("base token source: %w", err)
}
refreshToken := strings.TrimSpace(t.RefreshToken)
if refreshToken == "" {
return t, nil
}
p.mu.Lock()
defer p.mu.Unlock()
if refreshToken == p.tok.RefreshToken {
return t, nil
}
updated := p.tok
updated.RefreshToken = refreshToken
if err := p.store.SetToken(p.client, p.email, updated); err != nil {
slog.Warn("persist rotated refresh token failed", "email", p.email, "client", p.client, "err", err)
return t, nil
}
p.tok = updated
slog.Debug("persisted rotated refresh token", "email", p.email, "client", p.client)
return t, nil
}
func tokenSourceForAccount(ctx context.Context, service googleauth.Service, email string) (oauth2.TokenSource, error) {
client, err := authclient.ResolveClient(ctx, email)
if err != nil {
@ -92,7 +146,9 @@ func tokenSourceForAccountScopes(ctx context.Context, serviceLabel string, email
// Ensure refresh-token exchanges don't hang forever.
ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Timeout: tokenExchangeTimeout})
return cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: tok.RefreshToken}), nil
baseSource := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: tok.RefreshToken})
return newPersistingTokenSource(baseSource, store, client, email, tok), nil
}
func optionsForAccount(ctx context.Context, service googleauth.Service, email string) ([]option.ClientOption, error) {

View File

@ -7,8 +7,10 @@ import (
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/99designs/keyring"
"golang.org/x/oauth2"
@ -29,14 +31,33 @@ type stubStore struct {
lastEmail string
tok secrets.Token
err error
setClient string
setEmail string
lastSet secrets.Token
setCalls int
setErr error
}
func (s *stubStore) Keys() ([]string, error) { return nil, nil }
func (s *stubStore) SetToken(string, string, secrets.Token) error { return nil }
func (s *stubStore) DeleteToken(string, string) error { return nil }
func (s *stubStore) ListTokens() ([]secrets.Token, error) { return nil, nil }
func (s *stubStore) GetDefaultAccount(string) (string, error) { return "", nil }
func (s *stubStore) SetDefaultAccount(string, string) error { return nil }
func (s *stubStore) Keys() ([]string, error) { return nil, nil }
func (s *stubStore) SetToken(client string, email string, tok secrets.Token) error {
s.setClient = client
s.setEmail = email
s.lastSet = tok
s.setCalls++
if s.setErr != nil {
return s.setErr
}
s.tok = tok
return nil
}
func (s *stubStore) DeleteToken(string, string) error { return nil }
func (s *stubStore) ListTokens() ([]secrets.Token, error) { return nil, nil }
func (s *stubStore) GetDefaultAccount(string) (string, error) { return "", nil }
func (s *stubStore) SetDefaultAccount(string, string) error { return nil }
func (s *stubStore) GetToken(client string, email string) (secrets.Token, error) {
s.lastClient = client
s.lastEmail = email
@ -124,6 +145,92 @@ func TestTokenSourceForAccountScopes_HappyPath(t *testing.T) {
}
}
func TestPersistingTokenSource_PersistsRotatedRefreshToken(t *testing.T) {
stored := secrets.Token{
Client: config.DefaultClientName,
Email: "a@b.com",
RefreshToken: "old-refresh-token",
Services: []string{"gmail"},
Scopes: []string{"s1"},
CreatedAt: time.Unix(1735689600, 0).UTC(),
}
store := &stubStore{tok: stored}
base := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "access", RefreshToken: "new-refresh-token"})
ts := newPersistingTokenSource(base, store, config.DefaultClientName, "A@B.COM", stored)
if _, err := ts.Token(); err != nil {
t.Fatalf("Token: %v", err)
}
if store.setCalls != 1 {
t.Fatalf("expected 1 SetToken call, got %d", store.setCalls)
}
if store.setClient != config.DefaultClientName {
t.Fatalf("unexpected client: %q", store.setClient)
}
if store.setEmail != "A@B.COM" {
t.Fatalf("unexpected email: %q", store.setEmail)
}
if store.lastSet.RefreshToken != "new-refresh-token" {
t.Fatalf("expected rotated refresh token to persist, got %q", store.lastSet.RefreshToken)
}
if !reflect.DeepEqual(store.lastSet.Services, stored.Services) {
t.Fatalf("services changed unexpectedly: %#v", store.lastSet.Services)
}
if !reflect.DeepEqual(store.lastSet.Scopes, stored.Scopes) {
t.Fatalf("scopes changed unexpectedly: %#v", store.lastSet.Scopes)
}
if !store.lastSet.CreatedAt.Equal(stored.CreatedAt) {
t.Fatalf("createdAt changed unexpectedly: %v", store.lastSet.CreatedAt)
}
}
func TestPersistingTokenSource_NoRotationDoesNotPersist(t *testing.T) {
stored := secrets.Token{Email: "a@b.com", RefreshToken: "same-token"}
store := &stubStore{tok: stored}
base := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "access", RefreshToken: "same-token"})
ts := newPersistingTokenSource(base, store, config.DefaultClientName, "a@b.com", stored)
if _, err := ts.Token(); err != nil {
t.Fatalf("Token: %v", err)
}
if store.setCalls != 0 {
t.Fatalf("expected no SetToken calls, got %d", store.setCalls)
}
}
func TestPersistingTokenSource_PersistFailureIsNonFatal(t *testing.T) {
stored := secrets.Token{Email: "a@b.com", RefreshToken: "old-token"}
store := &stubStore{tok: stored, setErr: errBoom}
base := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "access", RefreshToken: "new-token"})
ts := newPersistingTokenSource(base, store, config.DefaultClientName, "a@b.com", stored)
tok, err := ts.Token()
if err != nil {
t.Fatalf("Token: %v", err)
}
if tok.AccessToken != "access" {
t.Fatalf("unexpected access token: %q", tok.AccessToken)
}
if store.setCalls != 1 {
t.Fatalf("expected 1 SetToken attempt, got %d", store.setCalls)
}
if store.tok.RefreshToken != "old-token" {
t.Fatalf("store should keep old token on persist error, got %q", store.tok.RefreshToken)
}
}
func TestTokenSourceForAccount_ReadCredsError(t *testing.T) {
origRead := readClientCredentials