diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9303577..06b76b26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Auth: automatically re-authorize when the stored refresh token is expired or revoked (`invalid_grant`). In interactive sessions, `gog` opens a browser-based OAuth flow, persists the new refresh token to the keyring, resets the in-memory token source, and retries the original API request — mirroring `yup-oauth2`'s `InstalledFlowAuthenticator` fallback. The reauth preserves the original grant's full scope/service set (preventing silent grant narrowing) and verifies the authorized email matches. Suppressed under `--no-input` or non-TTY stdin so CI and piped runs surface a clear error with the manual `gog auth add` command instead. Excluded for ADC, service accounts, and direct access tokens. + ## 0.35.0 - 2026-08-09 - Install: move the Go module to `github.com/openclaw/gogcli`; new releases install with `go install github.com/openclaw/gogcli/cmd/gog@latest` instead of the former `github.com/steipete/gogcli` path. diff --git a/docs/auto-reauth-issue-draft.md b/docs/auto-reauth-issue-draft.md new file mode 100644 index 000000000..c2e5c888a --- /dev/null +++ b/docs/auto-reauth-issue-draft.md @@ -0,0 +1,92 @@ +# Feature: Auto-reauth on expired/revoked refresh tokens + +## Summary + +When a stored OAuth refresh token is expired or revoked (`invalid_grant`), +`gog` fails with a hard error and requires the user to manually re-run +`gog auth add`. This is in contrast to other CLI tools (e.g. Rust's +`yup-oauth2` `InstalledFlowAuthenticator`) which automatically fall back to +a browser-based re-authorization flow when the refresh token is invalid, +making the failure transparent to the user. + +## Problem + +When the refresh token becomes invalid (Google revokes it for various +reasons: 6-month inactivity, password change, token limit reached, app in +Testing mode with 7-day expiry, user manually revoking access), any +`gog` command that needs authentication fails: + +``` +$ gog calendar events --today +Error: refresh access token: oauth2: "invalid_grant" "Token has been expired or revoked." +``` + +The user must then manually run: + +``` +gog auth add you@example.com --services gmail,calendar,drive +``` + +This is particularly frustrating because: + +1. **It's a hard stop** — no graceful degradation or recovery hint beyond the + raw OAuth error. +2. **The error message doesn't tell the user what to do** — it's a wrapped + `golang.org/x/oauth2` error, not a user-facing diagnostic. +3. **It happens repeatedly** — especially for users whose OAuth app is in + Testing mode (7-day refresh token expiry) or who don't use `gog` daily. +4. **Other tools handle this transparently** — `yup-oauth2` (used by the + `today` Rust CLI) detects `invalid_grant` during token refresh and + automatically falls back to the Installed Flow (opens a browser, obtains a + new refresh token, persists it, and retries the original request). + +## Proposed behavior + +When `gog` detects `invalid_grant` during a token refresh attempt: + +1. **If running interactively** (stdin is a terminal, `--no-input` is not set): + - Print a message to stderr: "Refresh token expired or revoked. Re-authorizing…" + - Automatically launch the browser-based OAuth flow (same as `gog auth add`) + using the stored account's services and client. + - On success, persist the new refresh token to the keyring and retry the + original API request. + - On failure (user denies, browser doesn't open, etc.), surface a clear + error with the re-auth command to run manually. + +2. **If running non-interactively** (`--no-input`, CI, pipes): + - Do NOT auto-launch a browser. + - Surface a clear error message with the exact `gog auth add` command to run. + +## Design considerations + +- **Security**: Auto-reauth should only trigger when the refresh token is + specifically revoked/expired (`invalid_grant`), not for other OAuth errors. + The browser flow uses the same PKCE + state validation as `gog auth add`. +- **Scope preservation**: The reauth should request the same services/scopes + as the stored token, not a broader or narrower set. +- `--force-consent` should be used during auto-reauth to ensure Google + returns a new refresh token (without it, Google may omit the refresh token + for returning users). +- **Keychain access**: The reauth flow needs keychain write access to persist + the new token. On macOS, this may trigger a Keychain permission prompt. +- **Timeout**: The auto-reauth browser flow should have a reasonable timeout + (e.g. 2 minutes) to avoid hanging indefinitely in CI-like environments. + +## Prior art + +- `yup-o-auth2` (Rust): `InstalledFlowAuthenticator::find_token_info()` — + on refresh failure, falls back to `auth_flow.token()` which opens a browser. + Source: [authenticator.rs](https://github.com/dermesser/yup-oauth2/blob/master/src/authenticator.rs) + +- gogcli already has partial auth resilience: + - v0.31.0: Recover from corrupt token payloads (#872) + - v0.32.0: Retry on 403 insufficient scopes by refreshing credentials (#889) + - v0.33.0: Trust Developer-ID-signed binaries for Keychain access + + Auto-reauth on `invalid_grant` is the next gap in this progression. + +## Environment + +- gog: v0.34.0 (Homebrew) +- macOS: 15.x (also reproduced on macOS 27 Tahoe beta) +- Keyring: macOS Keychain (also affects file backend) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6891e1c85..77e4deadc 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -220,6 +220,9 @@ func executeWithRuntime(args []string, runtime *app.Runtime) (err error) { ctx := context.Background() ctx = app.WithRuntime(ctx, runtime) ctx = googleapi.WithReadOnly(ctx, cli.ReadOnly) + if cli.NoInput || !stdinIsTerminal(ctx) { + ctx = googleapi.WithNoInput(ctx) + } runtimeContext := ctx serviceAccounts := func() (*config.ServiceAccountStore, error) { return commandServiceAccountStore(runtimeContext) @@ -247,6 +250,25 @@ func executeWithRuntime(args []string, runtime *app.Runtime) (err error) { resolveClient := func(email string, override string) (string, error) { return resolveRuntimeClient(runtime, email, override) } + + // reauthFn is the auto-reauth closure called when the stored refresh + // token is expired or revoked (invalid_grant). It launches a browser- + // based OAuth flow and persists the new token, mirroring `gog auth add`. + reauthFn := func(ctx context.Context, email string, client string, services []string, scopes []string, storedToken *secrets.Token) (string, error) { + opts := googleauth.ReauthOptions{ + Email: email, + Client: client, + Services: services, + Scopes: scopes, + StoredToken: storedToken, + OpenSecretsStore: openTokens, + EnsureKeychainAccess: ensureKeychainAccessIfNeeded, + AuthorizeFunc: authorizeGoogleAccount, + FetchIdentityFunc: fetchAuthIdentity, + } + return googleauth.Reauth(ctx, opts) + } + authDependencies := googleapi.AuthDependencies{ ResolveClient: resolveClient, ReadCredentials: readCredentials, @@ -256,6 +278,7 @@ func executeWithRuntime(args []string, runtime *app.Runtime) (err error) { Mode: cli.authMode, ADCTokenSource: googleapi.DefaultADCTokenSource, ServiceAccountTokenSource: googleapi.DefaultServiceAccountTokenSource, + Reauth: reauthFn, } ctx = googleapi.WithAuthDependencies(ctx, authDependencies) composeRuntimeGoogleServices(runtime, googleapi.NewFactory(authDependencies, googleapi.FactoryOptions{ diff --git a/internal/googleapi/auth_dependencies.go b/internal/googleapi/auth_dependencies.go index d716209ea..f1190beab 100644 --- a/internal/googleapi/auth_dependencies.go +++ b/internal/googleapi/auth_dependencies.go @@ -27,6 +27,17 @@ type ( ServiceAccountTokenSourceFunc func(context.Context, []byte, string, []string) (oauth2.TokenSource, error) ) +// ReauthFunc attempts to re-authorize the given account by launching a +// browser-based OAuth flow and persisting the new refresh token. It is +// called automatically when the stored refresh token is expired or revoked +// (invalid_grant) and the session is interactive. +// +// storedToken, if non-nil, carries the full scope/service set from the +// original authorization so the reauth can preserve the grant width. +// Returns the new refresh token so the caller can update any in-memory +// token source that still holds the revoked token. +type ReauthFunc func(ctx context.Context, email string, client string, services []string, scopes []string, storedToken *secrets.Token) (string, error) + type AuthDependencies struct { ResolveClient authclient.ClientResolver ReadCredentials authclient.CredentialsReader @@ -36,6 +47,7 @@ type AuthDependencies struct { Mode AuthMode ADCTokenSource ADCTokenSourceFunc ServiceAccountTokenSource ServiceAccountTokenSourceFunc + Reauth ReauthFunc } var ( @@ -183,3 +195,26 @@ func DefaultADCTokenSource(ctx context.Context, scopes ...string) (oauth2.TokenS return tokenSource, nil } + +// noInputContextKey controls whether auto-reauth is suppressed. When +// --no-input is set (or stdin is not a terminal), the auto-reauth fallback +// must not launch a browser. +type noInputContextKey struct{} + +// WithNoInput marks the context as non-interactive. Auto-reauth will be +// suppressed and a clear error message with manual instructions is returned +// instead. +func WithNoInput(ctx context.Context) context.Context { + return context.WithValue(ctx, noInputContextKey{}, true) +} + +// NoInputFromContext reports whether the context was marked non-interactive. +func NoInputFromContext(ctx context.Context) bool { + if ctx == nil { + return false + } + + enabled, _ := ctx.Value(noInputContextKey{}).(bool) + + return enabled +} diff --git a/internal/googleapi/auto_reauth_test.go b/internal/googleapi/auto_reauth_test.go new file mode 100644 index 000000000..48e9ef779 --- /dev/null +++ b/internal/googleapi/auto_reauth_test.go @@ -0,0 +1,461 @@ +package googleapi + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "golang.org/x/oauth2" +) + +func TestIsInvalidGrantError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error", + err: nil, + want: false, + }, + { + name: "unrelated error", + err: errors.New("network timeout"), + want: false, + }, + { + name: `invalid_grant with "Token has been expired or revoked"`, + err: errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`), + want: true, + }, + { + name: "invalid_grant Bad Request", + err: errors.New(`oauth2: "invalid_grant" "Bad Request"`), + want: true, + }, + { + name: "wrapped invalid_grant", + err: fmt.Errorf("refresh access token: %w", errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`)), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isInvalidGrantError(tt.err); got != tt.want { + t.Errorf("isInvalidGrantError() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRetryTransportAutoReauthOnInvalidGrant(t *testing.T) { + tokenSource := &refreshableTestTokenSource{token: "stale-token"} + reauthCalls := 0 + calls := 0 + + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + _ = body + + switch calls { + case 1: + // First call: the oauth2 transport will fail with invalid_grant + // because the refresh token is stale. We simulate this by + // returning an error from the base transport. + return nil, errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`) + case 2: + // After reauth, the token should be fresh. + if got := req.Header.Get("Authorization"); got != "Bearer fresh-token" { + t.Fatalf("second authorization = %q, want %q", got, "Bearer fresh-token") + } + + return newTestResponse(http.StatusOK, "ok"), nil + default: + t.Fatalf("unexpected call %d", calls) + return nil, errUnexpectedRequestBody + } + }) + + rt := &RetryTransport{ + Base: &oauth2TransportWrapper{ + source: tokenSource, + base: base, + }, + MaxRetries429: 0, + MaxRetries5xx: 0, + BaseDelay: 0, + Reauth: func(ctx context.Context) error { + reauthCalls++ + tokenSource.token = "fresh-token" + + return nil + }, + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example.com", io.NopCloser(strings.NewReader("payload"))) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.ContentLength = int64(len("payload")) + + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("round trip: %v", err) + } + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + + if calls != 2 { + t.Fatalf("calls = %d, want 2", calls) + } + + if reauthCalls != 1 { + t.Fatalf("reauth calls = %d, want 1", reauthCalls) + } +} + +func TestRetryTransportAutoReauthFailureSurfacesError(t *testing.T) { + calls := 0 + + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + _ = req.Body + + return nil, errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`) + }) + + rt := &RetryTransport{ + Base: base, + Reauth: func(context.Context) error { + return errors.New("browser did not open") + }, + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + + _, err = rt.RoundTrip(req) + if err == nil { + t.Fatalf("expected error") + } + + if !strings.Contains(err.Error(), "invalid_grant") { + t.Fatalf("error should mention invalid_grant: %v", err) + } + + if !strings.Contains(err.Error(), "re-authentication failed") { + t.Fatalf("error should mention re-authentication failed: %v", err) + } + + if calls != 1 { + t.Fatalf("calls = %d, want 1", calls) + } +} + +func TestRetryTransportNoReauthWhenNil(t *testing.T) { + calls := 0 + + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return nil, errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`) + }) + + rt := &RetryTransport{ + Base: base, + Reauth: nil, // No reauth function + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + + _, err = rt.RoundTrip(req) + if err == nil { + t.Fatalf("expected error") + } + + if !strings.Contains(err.Error(), "invalid_grant") { + t.Fatalf("error should contain invalid_grant: %v", err) + } + + if !strings.Contains(err.Error(), "gog auth add") { + t.Fatalf("error should contain 'gog auth add' hint: %v", err) + } + + if calls != 1 { + t.Fatalf("calls = %d, want 1", calls) + } +} + +func TestRetryTransportNoReauthWhenRetriesDisabled(t *testing.T) { + reauthCalls := 0 + calls := 0 + + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return nil, errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`) + }) + + rt := &RetryTransport{ + Base: base, + Reauth: func(context.Context) error { + reauthCalls++ + return nil + }, + } + + ctx := WithoutRetries(context.Background()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + + _, err = rt.RoundTrip(req) + if err == nil { + t.Fatalf("expected error") + } + + if !strings.Contains(err.Error(), "invalid_grant") { + t.Fatalf("error should contain invalid_grant: %v", err) + } + + if !strings.Contains(err.Error(), "gog auth add") { + t.Fatalf("error should contain 'gog auth add' hint: %v", err) + } + + if reauthCalls != 0 { + t.Fatalf("reauth should not be called when retries are disabled, got %d calls", reauthCalls) + } + + if calls != 1 { + t.Fatalf("calls = %d, want 1", calls) + } +} + +func TestRetryTransportNoReauthForNonReplayableBody(t *testing.T) { + reauthCalls := 0 + calls := 0 + + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + body, _ := io.ReadAll(req.Body) + _ = body + return nil, errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`) + }) + + rt := &RetryTransport{ + Base: base, + Reauth: func(context.Context) error { + reauthCalls++ + return nil + }, + } + + // Non-replayable: large body with unknown content length + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example.com", io.NopCloser(strings.NewReader("payload"))) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.ContentLength = maxBufferedReplayBodyBytes + 1 + + _, err = rt.RoundTrip(req) + if err == nil { + t.Fatalf("expected error") + } + + if !strings.Contains(err.Error(), "invalid_grant") { + t.Fatalf("error should contain invalid_grant: %v", err) + } + + if !strings.Contains(err.Error(), "gog auth add") { + t.Fatalf("error should contain 'gog auth add' hint: %v", err) + } + + if reauthCalls != 0 { + t.Fatalf("reauth should not be called for non-replayable body, got %d calls", reauthCalls) + } +} + +func TestRetryTransportReauthOnlyOnce(t *testing.T) { + reauthCalls := 0 + calls := 0 + + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + // Always return invalid_grant, even after reauth + return nil, errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`) + }) + + rt := &RetryTransport{ + Base: base, + Reauth: func(context.Context) error { + reauthCalls++ + return nil + }, + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + + _, err = rt.RoundTrip(req) + if err == nil { + t.Fatalf("expected error") + } + + if !strings.Contains(err.Error(), "invalid_grant") { + t.Fatalf("error should contain invalid_grant: %v", err) + } + + if !strings.Contains(err.Error(), "gog auth add") { + t.Fatalf("error should contain 'gog auth add' hint: %v", err) + } + + // Should only reauth once, not loop + if reauthCalls != 1 { + t.Fatalf("reauth calls = %d, want 1", reauthCalls) + } + + if calls != 2 { + t.Fatalf("calls = %d, want 2", calls) + } +} + +func TestNoInputFromContext(t *testing.T) { + if NoInputFromContext(context.Background()) { + t.Fatal("default context should not be no-input") + } + + ctx := WithNoInput(context.Background()) + if !NoInputFromContext(ctx) { + t.Fatal("WithNoInput context should report NoInput") + } +} + +// TestRetryTransportReauthResetsTokenSource verifies that after a successful +// reauth, the retried request uses the NEW refresh token, not the stale one. +// This is the critical test that exposes the "store-only" hole: if the +// in-memory token source is not reset, the retried request reuses the +// revoked refresh token and fails again. +func TestRetryTransportReauthResetsTokenSource(t *testing.T) { + // simulateResettableSource is a token source that tracks which refresh + // token it uses, simulating resettableOAuthTokenSource behavior. + source := &simulResettableSource{refreshToken: "revoked-token"} + + reauthCalled := false + calls := 0 + + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + body, _ := io.ReadAll(req.Body) + _ = body + + switch calls { + case 1: + // First call: the oauth2 transport tries to refresh using the + // revoked token and fails with invalid_grant. + return nil, errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`) + case 2: + // After reauth, the token source should have been reset. + // If it wasn't, this test would fail because the reauth + // function only updates the store, not the source. + if source.refreshToken == "revoked-token" { + t.Fatal("token source was not reset after reauth; retried request would use revoked token") + } + return newTestResponse(http.StatusOK, "ok"), nil + default: + t.Fatalf("unexpected call %d", calls) + return nil, nil + } + }) + + rt := &RetryTransport{ + Base: &oauth2TransportWrapper{ + source: source, + base: base, + }, + Reauth: func(ctx context.Context) error { + reauthCalled = true + // Simulate: reauth gets a new token from the browser and + // persists it to the store. The critical step is resetting + // the in-memory token source. + source.ResetRefreshToken("new-refresh-token") + return nil + }, + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example.com", io.NopCloser(strings.NewReader("payload"))) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.ContentLength = int64(len("payload")) + + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("round trip: %v", err) + } + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + + if !reauthCalled { + t.Fatal("reauth was not called") + } + + if calls != 2 { + t.Fatalf("calls = %d, want 2", calls) + } +} + +// simulResettableSource is a minimal token source that simulates +// resettableOAuthTokenSource for testing the reset-after-reauth behavior. +type simulResettableSource struct { + refreshToken string +} + +func (s *simulResettableSource) Token() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "access-" + s.refreshToken}, nil +} + +func (s *simulResettableSource) ResetRefreshToken(token string) { + s.refreshToken = token +} + +// oauth2TransportWrapper is a minimal oauth2.Transport-like wrapper for +// testing. It calls the token source to get a token and sets the +// Authorization header before delegating to the base transport. +type oauth2TransportWrapper struct { + source oauth2.TokenSource + base http.RoundTripper +} + +func (w *oauth2TransportWrapper) RoundTrip(req *http.Request) (*http.Response, error) { + tok, err := w.source.Token() + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + + return w.base.RoundTrip(req) +} diff --git a/internal/googleapi/client.go b/internal/googleapi/client.go index 101626bed..baef62952 100644 --- a/internal/googleapi/client.go +++ b/internal/googleapi/client.go @@ -13,6 +13,7 @@ import ( "github.com/openclaw/gogcli/internal/authclient" "github.com/openclaw/gogcli/internal/googleauth" + "github.com/openclaw/gogcli/internal/secrets" ) const ( @@ -129,6 +130,7 @@ func authenticatedTransportWithStoredScopeCheck( requireStoredGrant bool, ) (http.RoundTripper, error) { var ts oauth2.TokenSource + var isStoredOAuth bool if dependencies, ok := authDependenciesFromContext(ctx); ok && dependencies.Mode == AuthModeADC { slog.Debug("using Application Default Credentials (GOG_AUTH_MODE=adc)", "serviceLabel", serviceLabel) @@ -146,6 +148,13 @@ func authenticatedTransportWithStoredScopeCheck( if err != nil { return nil, err } + + // Only stored OAuth tokens (wrapped in persistingTokenSource) can + // be auto-reauthorized via a browser flow. Direct access tokens + // (StaticTokenSource) and service-account sources never return + // invalid_grant through the refresh-token path, but even if they + // did, launching a user browser flow would be nonsensical. + _, isStoredOAuth = ts.(*persistingTokenSource) } retryTransport := NewRetryTransport(&oauth2.Transport{ @@ -159,6 +168,15 @@ func authenticatedTransportWithStoredScopeCheck( retryTransport.RefreshAuth = refresher.ForceRefresh } + // Wire auto-reauth for invalid_grant (expired/revoked refresh token). + // Only enabled for stored OAuth (not ADC, not direct access tokens, + // not service accounts) and only when the session is interactive. + if isStoredOAuth && !NoInputFromContext(ctx) { + if reauthFn := reauthFunctionFromContext(ctx, serviceLabel, email, scopes, ts); reauthFn != nil { + retryTransport.Reauth = reauthFn + } + } + return readOnlyTransportFromContext(ctx, retryTransport), nil } @@ -298,3 +316,60 @@ func newBaseTransport() *http.Transport { return transport } + +// reauthFunctionFromContext builds a Reauth closure from the auth +// dependencies stored in the context. Returns nil if the dependencies are +// not available or the Reauth function is not configured, in which case +// auto-reauth is disabled and invalid_grant errors are surfaced directly. +// +// The token source is captured so that after a successful reauth, the +// in-memory token source can be reset with the new refresh token — +// otherwise the retried request would reuse the revoked token. +func reauthFunctionFromContext(ctx context.Context, serviceLabel string, email string, scopes []string, ts oauth2.TokenSource) func(context.Context) error { + dependencies, ok := authDependenciesFromContext(ctx) + if !ok || dependencies.Reauth == nil { + return nil + } + + resolvedEmail := email + + // Derive the service list from the single service label when available. + var services []string + if serviceLabel != "" { + services = []string{serviceLabel} + } + + return func(ctx context.Context) error { + // Resolve the client at call time so it reflects the latest config. + client, err := dependencies.resolveClient(resolvedEmail, authclient.ClientOverrideFromContext(ctx)) + if err != nil { + return fmt.Errorf("resolve client for reauth: %w", err) + } + + // Load the stored token to preserve the full scope/service set + // from the original authorization, preventing silent grant narrowing. + var storedToken *secrets.Token + if store, storeErr := dependencies.openTokens(); storeErr == nil { + if tok, getErr := store.GetToken(client, resolvedEmail); getErr == nil { + storedToken = &tok + } + } + + newRefreshToken, err := dependencies.Reauth(ctx, resolvedEmail, client, services, scopes, storedToken) + if err != nil { + return err + } + + // Reset the in-memory token source so the retried request uses + // the new refresh token instead of the revoked one. Guard against + // an empty token to avoid rebuilding the source with no refresh + // token, which would produce a confusing refresh error. + if newRefreshToken != "" { + if resetter, ok := ts.(refreshTokenResetter); ok { + resetter.ResetRefreshToken(newRefreshToken) + } + } + + return nil + } +} diff --git a/internal/googleapi/client_auth.go b/internal/googleapi/client_auth.go index 3729e7dfb..b71fd7b1b 100644 --- a/internal/googleapi/client_auth.go +++ b/internal/googleapi/client_auth.go @@ -44,6 +44,26 @@ var ( errBaseTokenSourceReturnedNilToken = errors.New("base token source returned nil token") ) +// isInvalidGrantError checks whether an OAuth2 token refresh error is an +// invalid_grant failure, meaning the stored refresh token is no longer +// usable and must be re-obtained via a full browser-based authorization. +func isInvalidGrantError(err error) bool { + if err == nil { + return false + } + + msg := strings.ToLower(err.Error()) + + // golang.org/x/oauth2 wraps the Google response in a *RetrieveError + // whose Error() contains the raw JSON body. Google's response for a + // revoked/expired refresh token is: + // {"error":"invalid_grant","error_description":"Token has been expired or revoked."} + // + // We match on "invalid_grant" to catch all variants (expired, revoked, + // bad request) without being brittle to the exact error_description. + return strings.Contains(msg, "invalid_grant") +} + type resettableOAuthTokenSource struct { mu sync.Mutex source oauth2.TokenSource @@ -95,6 +115,18 @@ func (r *resettableOAuthTokenSource) ForceRefresh(context.Context) (*oauth2.Toke return t, nil } +// ResetRefreshToken replaces the stored refresh token and rebuilds the +// underlying oauth2.TokenSource so the next Token() call uses the new +// token. This is called after a successful auto-reauth to ensure the +// retried request doesn't reuse the revoked refresh token. +func (r *resettableOAuthTokenSource) ResetRefreshToken(refreshToken string) { + r.mu.Lock() + defer r.mu.Unlock() + + r.refreshToken = strings.TrimSpace(refreshToken) + r.source = r.newSource(&oauth2.Token{RefreshToken: r.refreshToken}) +} + func (r *resettableOAuthTokenSource) rememberRefreshTokenLocked(t *oauth2.Token) { if t == nil { return @@ -148,6 +180,20 @@ func (p *persistingTokenSource) ForceRefresh(ctx context.Context) error { return err } +// ResetRefreshToken delegates to the base token source to replace the +// stored refresh token after a successful auto-reauth. +func (p *persistingTokenSource) ResetRefreshToken(refreshToken string) { + if resetter, ok := p.base.(*resettableOAuthTokenSource); ok { + resetter.ResetRefreshToken(refreshToken) + } +} + +// refreshTokenResetter is implemented by token sources that can replace +// their stored refresh token at runtime (used after auto-reauth). +type refreshTokenResetter interface { + ResetRefreshToken(refreshToken string) +} + func (p *persistingTokenSource) persistTokenLocked(t *oauth2.Token) (*oauth2.Token, error) { if t == nil { return nil, errBaseTokenSourceReturnedNilToken diff --git a/internal/googleapi/reauth_glue_test.go b/internal/googleapi/reauth_glue_test.go new file mode 100644 index 000000000..c52490dfb --- /dev/null +++ b/internal/googleapi/reauth_glue_test.go @@ -0,0 +1,276 @@ +package googleapi + +import ( + "context" + "errors" + "testing" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + + "github.com/openclaw/gogcli/internal/config" + "github.com/openclaw/gogcli/internal/secrets" +) + +// TestReauthFunctionFromContextResetsTokenSource verifies that the production +// closure returned by reauthFunctionFromContext actually calls +// ResetRefreshToken on the real persistingTokenSource chain after a +// successful reauth. This is the integration test for the glue code that +// was broken in the initial implementation. +func TestReauthFunctionFromContextResetsTokenSource(t *testing.T) { + // Build a real resettableOAuthTokenSource → persistingTokenSource chain. + // The oauth2.Config's TokenSource will use the refresh token we give it. + // We simulate a token endpoint that returns invalid_grant for the old + // token and success for the new one. + + var reauthRefreshToken string + + // Create the auth dependencies with a fake Reauth that returns a new + // refresh token (simulating a successful browser flow). + deps := AuthDependencies{ + ResolveClient: func(string, string) (string, error) { return "default", nil }, + ReadCredentials: func(string) (config.ClientCredentials, error) { + return config.ClientCredentials{ClientID: "id", ClientSecret: "secret"}, nil + }, + OpenTokens: func() (secrets.Store, error) { + return &fakeStore{}, nil + }, + Reauth: func(ctx context.Context, email, client string, services, scopes []string, storedToken *secrets.Token) (string, error) { + reauthRefreshToken = "new-refresh-token" + return "new-refresh-token", nil + }, + } + + ctx := WithAuthDependencies(context.Background(), deps) + + // Build a real token source chain as the transport layer would. + cfg := oauth2.Config{ + ClientID: "id", + ClientSecret: "secret", + Endpoint: google.Endpoint, + Scopes: []string{"https://www.googleapis.com/auth/calendar"}, + } + + // The transport construction captures the token source; we simulate + // what tokenSourceForAccountScopesWithStoredScopeCheck does. + baseSource := newResettableOAuthTokenSource(func(t *oauth2.Token) oauth2.TokenSource { + return cfg.TokenSource(context.Background(), t) + }, &oauth2.Token{ + RefreshToken: "revoked-token", + }) + + ts := newPersistingTokenSource(baseSource, &fakeStore{}, "default", "user@example.com", secrets.Token{ + Email: "user@example.com", + RefreshToken: "revoked-token", + Scopes: []string{"https://www.googleapis.com/auth/calendar"}, + }, "calendar", nil) + + // Get the reauth closure. + reauthFn := reauthFunctionFromContext(ctx, "calendar", "user@example.com", []string{"https://www.googleapis.com/auth/calendar"}, ts) + if reauthFn == nil { + t.Fatal("expected non-nil reauth function") + } + + // Call the reauth closure. + if err := reauthFn(context.Background()); err != nil { + t.Fatalf("reauth closure: %v", err) + } + + if reauthRefreshToken != "new-refresh-token" { + t.Fatalf("Reauth was not called or returned unexpected token: %q", reauthRefreshToken) + } + + // Verify the token source was actually reset by checking the inner + // resettableOAuthTokenSource's refresh token. + persisting, ok := ts.(*persistingTokenSource) + if !ok { + t.Fatalf("expected *persistingTokenSource, got %T", ts) + } + + resettable, ok := persisting.base.(*resettableOAuthTokenSource) + if !ok { + t.Fatalf("expected *resettableOAuthTokenSource, got %T", persisting.base) + } + + if resettable.refreshToken != "new-refresh-token" { + t.Fatalf("token source was not reset: expected new-refresh-token, got %q", resettable.refreshToken) + } +} + +// TestReauthFunctionFromContextNilWhenNoDeps verifies that the closure is +// nil when auth dependencies are not in the context. +func TestReauthFunctionFromContextNilWhenNoDeps(t *testing.T) { + fn := reauthFunctionFromContext(context.Background(), "calendar", "user@example.com", []string{"scope"}, nil) + if fn != nil { + t.Fatal("expected nil reauth function when no deps in context") + } +} + +// TestReauthFunctionFromContextNilWhenNoReauthFunc verifies that the closure +// is nil when the Reauth field is not set on the dependencies. +func TestReauthFunctionFromContextNilWhenNoReauthFunc(t *testing.T) { + deps := AuthDependencies{ + ResolveClient: func(string, string) (string, error) { return "default", nil }, + ReadCredentials: func(string) (config.ClientCredentials, error) { return config.ClientCredentials{}, nil }, + OpenTokens: func() (secrets.Store, error) { return &fakeStore{}, nil }, + // Reauth is nil + } + ctx := WithAuthDependencies(context.Background(), deps) + fn := reauthFunctionFromContext(ctx, "calendar", "user@example.com", []string{"scope"}, nil) + if fn != nil { + t.Fatal("expected nil reauth function when Reauth is nil") + } +} + +// TestResettableOAuthTokenSourceResetRefreshToken verifies that +// ResetRefreshToken replaces the refresh token and rebuilds the source. +func TestResettableOAuthTokenSourceResetRefreshToken(t *testing.T) { + var tokenRequests []string + + source := newResettableOAuthTokenSource(func(t *oauth2.Token) oauth2.TokenSource { + // Track which refresh token is being used. + tokenRequests = append(tokenRequests, t.RefreshToken) + // Return a stub token source that always succeeds. + return &stubTokenSource{token: &oauth2.Token{AccessToken: "access"}} + }, &oauth2.Token{ + RefreshToken: "old-token", + }) + + // Verify initial state + if source.refreshToken != "old-token" { + t.Fatalf("expected old-token, got %q", source.refreshToken) + } + + // Reset + source.ResetRefreshToken("new-token") + + if source.refreshToken != "new-token" { + t.Fatalf("expected new-token, got %q", source.refreshToken) + } + + // Verify the source was rebuilt — calling Token() should use the new token + _, _ = source.Token() + + if len(tokenRequests) == 0 { + t.Fatal("Token() did not create a new source") + } + + if tokenRequests[len(tokenRequests)-1] != "new-token" { + t.Fatalf("expected new-token in request, got %q", tokenRequests[len(tokenRequests)-1]) + } +} + +// TestPersistingTokenSourceResetRefreshToken verifies delegation. +func TestPersistingTokenSourceResetRefreshToken(t *testing.T) { + base := newResettableOAuthTokenSource(func(t *oauth2.Token) oauth2.TokenSource { + return (&oauth2.Config{ + ClientID: "id", + ClientSecret: "secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://example.com/auth", + TokenURL: "https://example.com/token", + }, + Scopes: []string{"scope"}, + }).TokenSource(context.Background(), t) + }, &oauth2.Token{RefreshToken: "old"}) + + persisting := newPersistingTokenSource(base, &fakeStore{}, "default", "user@example.com", secrets.Token{ + Email: "user@example.com", + RefreshToken: "old", + }, "calendar", nil) + + // Reset via the persisting wrapper + persisting.(*persistingTokenSource).ResetRefreshToken("new-refresh") + + // Verify the inner source was updated + if base.refreshToken != "new-refresh" { + t.Fatalf("expected new-refresh, got %q", base.refreshToken) + } +} + +// TestReauthClosureLoadsStoredToken verifies the closure loads the stored +// token and passes it through to Reauth for scope preservation. +func TestReauthClosureLoadsStoredToken(t *testing.T) { + store := &fakeStore{ + token: &secrets.Token{ + Email: "user@example.com", + Scopes: []string{"scope1", "scope2", "scope3"}, + Services: []string{"calendar", "gmail", "drive"}, + }, + } + + var passedStoredToken *secrets.Token + + deps := AuthDependencies{ + ResolveClient: func(string, string) (string, error) { return "default", nil }, + ReadCredentials: func(string) (config.ClientCredentials, error) { + return config.ClientCredentials{ClientID: "id", ClientSecret: "secret"}, nil + }, + OpenTokens: func() (secrets.Store, error) { return store, nil }, + Reauth: func(ctx context.Context, email, client string, services, scopes []string, storedToken *secrets.Token) (string, error) { + passedStoredToken = storedToken + return "new-token", nil + }, + } + + ctx := WithAuthDependencies(context.Background(), deps) + + // Build a real token source so the reset path works + baseSource := newResettableOAuthTokenSource(func(t *oauth2.Token) oauth2.TokenSource { + return (&oauth2.Config{ + ClientID: "id", + ClientSecret: "secret", + Endpoint: google.Endpoint, + Scopes: []string{"scope"}, + }).TokenSource(context.Background(), t) + }, &oauth2.Token{RefreshToken: "old"}) + + ts := newPersistingTokenSource(baseSource, store, "default", "user@example.com", secrets.Token{ + Email: "user@example.com", + RefreshToken: "old", + }, "calendar", nil) + + reauthFn := reauthFunctionFromContext(ctx, "calendar", "user@example.com", []string{"scope1"}, ts) + if reauthFn == nil { + t.Fatal("expected non-nil reauth function") + } + + if err := reauthFn(context.Background()); err != nil { + t.Fatalf("reauth: %v", err) + } + + if passedStoredToken == nil { + t.Fatal("expected stored token to be passed to Reauth") + } + + if len(passedStoredToken.Scopes) != 3 { + t.Fatalf("expected 3 scopes from stored token, got %d: %v", len(passedStoredToken.Scopes), passedStoredToken.Scopes) + } +} + +// stubTokenSource returns a fixed token without any network calls. +type stubTokenSource struct { + token *oauth2.Token +} + +func (s *stubTokenSource) Token() (*oauth2.Token, error) { + return s.token, nil +} + +// fakeStore is a minimal secrets.Store for testing. +type fakeStore struct { + token *secrets.Token +} + +func (s *fakeStore) Keys() ([]string, error) { return nil, nil } +func (s *fakeStore) GetToken(client, email string) (secrets.Token, error) { + if s.token != nil { + return *s.token, nil + } + return secrets.Token{}, errors.New("not found") +} +func (s *fakeStore) SetToken(client, email string, tok secrets.Token) error { s.token = &tok; return nil } +func (s *fakeStore) DeleteToken(client, email string) error { return nil } +func (s *fakeStore) ListTokens() ([]secrets.Token, error) { return nil, nil } +func (s *fakeStore) GetDefaultAccount(string) (string, error) { return "", nil } +func (s *fakeStore) SetDefaultAccount(string, string) error { return nil } diff --git a/internal/googleapi/transport.go b/internal/googleapi/transport.go index 78e265f61..5afd30612 100644 --- a/internal/googleapi/transport.go +++ b/internal/googleapi/transport.go @@ -49,6 +49,11 @@ type RetryTransport struct { BaseDelay time.Duration CircuitBreaker *CircuitBreaker RefreshAuth func(context.Context) error + // Reauth is called when the OAuth refresh token is expired or revoked + // (invalid_grant). It should launch a browser-based re-authorization + // flow, persist the new refresh token, and return nil on success. + // If Reauth is nil, invalid_grant errors are surfaced without retry. + Reauth func(context.Context) error } // NewRetryTransport creates a RetryTransport with sensible defaults. @@ -82,6 +87,7 @@ func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) { retries429 := 0 retries5xx := 0 retriedAuth := false + retriedReauth := false for { // Reset body for retry @@ -99,6 +105,29 @@ func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) { resp, err = t.Base.RoundTrip(req) if err != nil { + // Detect invalid_grant from the OAuth2 transport layer. + // This happens when the stored refresh token is expired or + // revoked. If a Reauth function is available and we haven't + // already retried, attempt auto-reauth and retry the request. + if isInvalidGrantError(err) { + if t.Reauth != nil && !retriedReauth && !retryDisabled && replayable { + slog.Debug("refresh token expired or revoked, attempting auto-reauth") + + if reauthErr := t.Reauth(req.Context()); reauthErr != nil { + slog.Debug("auto-reauth failed", "err", reauthErr) + return nil, fmt.Errorf("refresh token expired or revoked: %w; re-authentication failed: %v; run 'gog auth add' to re-authorize manually", err, reauthErr) + } + + retriedReauth = true + continue + } + + // Reauth is not available (suppressed by --no-input, not + // stored OAuth, or already retried). Surface a clear, + // actionable error instead of the raw OAuth2 message. + return nil, fmt.Errorf("refresh token expired or revoked: %w; run 'gog auth add' to re-authorize", err) + } + return nil, fmt.Errorf("round trip: %w", err) } diff --git a/internal/googleauth/reauth.go b/internal/googleauth/reauth.go new file mode 100644 index 000000000..ff59bd694 --- /dev/null +++ b/internal/googleauth/reauth.go @@ -0,0 +1,224 @@ +package googleauth + +import ( + "context" + "fmt" + "log/slog" + "os" + "sort" + "strings" + "time" + + "github.com/openclaw/gogcli/internal/secrets" +) + +// ReauthOptions configures an automatic re-authorization flow triggered +// when the stored refresh token is expired or revoked. +type ReauthOptions struct { + Email string + Client string + Services []string + Scopes []string + // StoredToken, if non-nil, provides the full scope/service set from the + // original authorization. When present, its Scopes and Services are used + // for the re-authorization instead of the triggering request's narrower + // scopes, preventing silent grant narrowing. + StoredToken *secrets.Token + // OpenSecretsStore opens the token store for persisting the new token. + OpenSecretsStore func() (secrets.Store, error) + // EnsureKeychainAccess ensures the keychain is accessible for writes. + // May be nil if keychain access is not needed (e.g. file backend). + EnsureKeychainAccess func(context.Context) error + // AuthorizeFunc performs the OAuth authorization flow. If nil, + // googleauth.Authorize is used. + AuthorizeFunc func(context.Context, AuthorizeOptions) (string, error) + // FetchIdentityFunc fetches the authorized identity. If nil, + // googleauth.IdentityForRefreshToken is used. + FetchIdentityFunc func(context.Context, string, string, []string, time.Duration) (Identity, error) + // Timeout for the browser flow. If zero, defaults to 2 minutes. + Timeout time.Duration + // Stderr is where progress messages are written. If nil, os.Stderr is used. + Stderr interface { + Write([]byte) (int, error) + } +} + +// Reauth performs an automatic re-authorization when the stored refresh +// token is expired or revoked (invalid_grant). It launches a browser-based +// OAuth flow using the same client, services, and scopes as the original +// authorization, then persists the new refresh token to the secret store. +// +// It returns the new refresh token so the caller can update any in-memory +// token source that still holds the revoked token. +// +// This is the auto-reauth counterpart to `gog auth add`, designed to be +// called from the retry transport when an invalid_grant error is detected +// during an API call. +func Reauth(ctx context.Context, opts ReauthOptions) (string, error) { + if opts.Email == "" { + return "", fmt.Errorf("reauth: email is required") + } + if opts.Client == "" { + return "", fmt.Errorf("reauth: client is required") + } + if len(opts.Scopes) == 0 { + return "", fmt.Errorf("reauth: scopes are required") + } + + stderr := opts.Stderr + if stderr == nil { + stderr = os.Stderr + } + + timeout := opts.Timeout + if timeout == 0 { + timeout = 2 * time.Minute + } + + authorizeFn := opts.AuthorizeFunc + if authorizeFn == nil { + authorizeFn = Authorize + } + + fetchIdentityFn := opts.FetchIdentityFunc + if fetchIdentityFn == nil { + fetchIdentityFn = IdentityForRefreshToken + } + + if opts.EnsureKeychainAccess != nil { + if err := opts.EnsureKeychainAccess(ctx); err != nil { + return "", fmt.Errorf("reauth: keychain access: %w", err) + } + } + + // Determine the scopes and services to request. Prefer the stored + // token's full scope set to prevent silent grant narrowing (e.g. a + // calendar command narrowing a gmail+calendar+drive grant). + reauthScopes := opts.Scopes + reauthServices := opts.Services + if opts.StoredToken != nil { + if len(opts.StoredToken.Scopes) > 0 { + reauthScopes = opts.StoredToken.Scopes + } + if len(opts.StoredToken.Services) > 0 { + reauthServices = opts.StoredToken.Services + } + } + + // Convert service strings to Service types. + services := make([]Service, 0, len(reauthServices)) + for _, s := range reauthServices { + svc, err := ParseService(s) + if err != nil { + // If we can't parse the service label, use the scopes directly. + slog.Debug("reauth: could not parse service label, using scopes only", "service", s, "err", err) + continue + } + services = append(services, svc) + } + + // If we couldn't parse any services, derive them from the scopes. + if len(services) == 0 { + services = servicesFromScopes(reauthScopes) + } + + fmt.Fprintln(stderr, "Refresh token expired or revoked. Re-authorizing…") + + authorizeOpts := AuthorizeOptions{ + Services: services, + Scopes: reauthScopes, + ForceConsent: true, // Ensure Google returns a new refresh token + Timeout: timeout, + Client: opts.Client, + } + + refreshToken, err := authorizeFn(ctx, authorizeOpts) + if err != nil { + return "", fmt.Errorf("reauth: authorization failed: %w", err) + } + + // Fetch the authorized identity to verify the email matches. + identity, err := fetchIdentityFn(ctx, opts.Client, refreshToken, reauthScopes, 15*time.Second) + if err != nil { + return "", fmt.Errorf("reauth: fetch authorized identity: %w", err) + } + + authorizedEmail := identity.Email + if authorizedEmail == "" { + authorizedEmail = opts.Email + } + + // Verify the authorized account matches the expected email. + if !strings.EqualFold(strings.TrimSpace(authorizedEmail), strings.TrimSpace(opts.Email)) { + return "", fmt.Errorf("reauth: authorized as %s, expected %s", authorizedEmail, opts.Email) + } + + // Persist the new refresh token. + if opts.OpenSecretsStore == nil { + return "", fmt.Errorf("reauth: secret store opener is required") + } + + store, err := opts.OpenSecretsStore() + if err != nil { + return "", fmt.Errorf("reauth: open secret store: %w", err) + } + + serviceNames := make([]string, 0, len(services)) + for _, svc := range services { + serviceNames = append(serviceNames, string(svc)) + } + sort.Strings(serviceNames) + + if err := store.SetToken(opts.Client, authorizedEmail, secrets.Token{ + Client: opts.Client, + Subject: identity.Subject, + Email: authorizedEmail, + Services: serviceNames, + Scopes: reauthScopes, + RefreshToken: refreshToken, + }); err != nil { + return "", fmt.Errorf("reauth: persist new refresh token: %w", err) + } + + fmt.Fprintln(stderr, "Re-authorization successful. Retrying request…") + + return refreshToken, nil +} + +// servicesFromScopes attempts to derive the service list from a set of +// OAuth scopes. This is a best-effort fallback when the service label +// is not available or cannot be parsed. +func servicesFromScopes(scopes []string) []Service { + scopeSet := make(map[string]struct{}, len(scopes)) + for _, s := range scopes { + scopeSet[s] = struct{}{} + } + + var matched []Service + for _, svc := range serviceOrder { + info, ok := serviceInfoByService[svc] + if !ok || !info.user { + continue + } + + // Check if all scopes for this service are present in the scope set. + allPresent := true + for _, svcScope := range info.scopes { + if _, ok := scopeSet[svcScope]; !ok { + allPresent = false + break + } + } + + if allPresent { + matched = append(matched, svc) + } + } + + if len(matched) == 0 { + // Fallback: return empty, Authorize will use the scopes directly. + return nil + } + + return matched +} diff --git a/internal/googleauth/reauth_test.go b/internal/googleauth/reauth_test.go new file mode 100644 index 000000000..260b53d34 --- /dev/null +++ b/internal/googleauth/reauth_test.go @@ -0,0 +1,363 @@ +package googleauth + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/openclaw/gogcli/internal/config" + "github.com/openclaw/gogcli/internal/secrets" +) + +// mockSecretStore is a minimal secrets.Store for testing Reauth. +type mockSecretStore struct { + tokens map[string]secrets.Token +} + +func (s *mockSecretStore) GetToken(client string, email string) (secrets.Token, error) { + key := client + ":" + email + if tok, ok := s.tokens[key]; ok { + return tok, nil + } + return secrets.Token{}, errors.New("not found") +} + +func (s *mockSecretStore) SetToken(client string, email string, token secrets.Token) error { + key := client + ":" + email + s.tokens[key] = token + return nil +} + +func (s *mockSecretStore) DeleteToken(client string, email string) error { + key := client + ":" + email + delete(s.tokens, key) + return nil +} + + + +func (s *mockSecretStore) ListTokens() ([]secrets.Token, error) { + var out []secrets.Token + for _, tok := range s.tokens { + out = append(out, tok) + } + return out, nil +} + +func (s *mockSecretStore) Keys() ([]string, error) { + keys := make([]string, 0, len(s.tokens)) + for k := range s.tokens { + keys = append(keys, k) + } + return keys, nil +} + +func (s *mockSecretStore) GetDefaultAccount(string) (string, error) { + return "", errors.New("not implemented") +} + +func (s *mockSecretStore) SetDefaultAccount(string, string) error { + return errors.New("not implemented") +} + +func (s *mockSecretStore) Close() error { return nil } + +func TestReauthSuccess(t *testing.T) { + origRead := readClientCredentials + origEndpoint := oauthEndpoint + t.Cleanup(func() { + readClientCredentials = origRead + oauthEndpoint = origEndpoint + }) + + readClientCredentials = func(string) (config.ClientCredentials, error) { + return config.ClientCredentials{ClientID: "id", ClientSecret: "secret"}, nil + } + + store := &mockSecretStore{tokens: make(map[string]secrets.Token)} + + authorizeCalled := false + identityCalled := false + + opts := ReauthOptions{ + Email: "user@example.com", + Client: "default", + Services: []string{"calendar"}, + Scopes: []string{"https://www.googleapis.com/auth/calendar"}, + OpenSecretsStore: func() (secrets.Store, error) { + return store, nil + }, + EnsureKeychainAccess: func(context.Context) error { return nil }, + AuthorizeFunc: func(ctx context.Context, authOpts AuthorizeOptions) (string, error) { + authorizeCalled = true + if authOpts.ForceConsent != true { + t.Fatalf("expected ForceConsent=true") + } + if authOpts.Client != "default" { + t.Fatalf("expected client 'default', got %q", authOpts.Client) + } + return "new-refresh-token", nil + }, + FetchIdentityFunc: func(ctx context.Context, client string, refreshToken string, scopes []string, timeout time.Duration) (Identity, error) { + identityCalled = true + if refreshToken != "new-refresh-token" { + t.Fatalf("expected new-refresh-token, got %q", refreshToken) + } + return Identity{Subject: "sub123", Email: "user@example.com"}, nil + }, + Stderr: &bytesBuffer{}, + } + + if _, err := Reauth(context.Background(), opts); err != nil { + t.Fatalf("Reauth: %v", err) + } + + if !authorizeCalled { + t.Fatal("AuthorizeFunc was not called") + } + + if !identityCalled { + t.Fatal("FetchIdentityFunc was not called") + } + + // Verify token was persisted + tok, err := store.GetToken("default", "user@example.com") + if err != nil { + t.Fatalf("GetToken: %v", err) + } + + if tok.RefreshToken != "new-refresh-token" { + t.Fatalf("expected new-refresh-token, got %q", tok.RefreshToken) + } + + if tok.Email != "user@example.com" { + t.Fatalf("expected user@example.com, got %q", tok.Email) + } + + if tok.Subject != "sub123" { + t.Fatalf("expected sub123, got %q", tok.Subject) + } +} + +func TestReauthAuthorizeFailure(t *testing.T) { + opts := ReauthOptions{ + Email: "user@example.com", + Client: "default", + Services: []string{"calendar"}, + Scopes: []string{"https://www.googleapis.com/auth/calendar"}, + OpenSecretsStore: func() (secrets.Store, error) { + return &mockSecretStore{tokens: make(map[string]secrets.Token)}, nil + }, + EnsureKeychainAccess: func(context.Context) error { return nil }, + AuthorizeFunc: func(ctx context.Context, authOpts AuthorizeOptions) (string, error) { + return "", errors.New("user denied access") + }, + FetchIdentityFunc: func(context.Context, string, string, []string, time.Duration) (Identity, error) { + t.Fatal("FetchIdentityFunc should not be called on authorize failure") + return Identity{}, nil + }, + Stderr: &bytesBuffer{}, + } + + _, err := Reauth(context.Background(), opts) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "authorization failed") { + t.Fatalf("error should mention authorization failed: %v", err) + } +} + +func TestReauthMissingEmail(t *testing.T) { + opts := ReauthOptions{ + Client: "default", + Scopes: []string{"scope"}, + OpenSecretsStore: func() (secrets.Store, error) { + return &mockSecretStore{}, nil + }, + } + + _, err := Reauth(context.Background(), opts) + if err == nil { + t.Fatal("expected error for missing email") + } +} + +func TestReauthMissingScopes(t *testing.T) { + opts := ReauthOptions{ + Email: "user@example.com", + Client: "default", + OpenSecretsStore: func() (secrets.Store, error) { + return &mockSecretStore{}, nil + }, + } + + _, err := Reauth(context.Background(), opts) + if err == nil { + t.Fatal("expected error for missing scopes") + } +} + +func TestReauthKeychainAccessFailure(t *testing.T) { + opts := ReauthOptions{ + Email: "user@example.com", + Client: "default", + Scopes: []string{"scope"}, + OpenSecretsStore: func() (secrets.Store, error) { + return &mockSecretStore{}, nil + }, + EnsureKeychainAccess: func(context.Context) error { + return errors.New("keychain locked") + }, + } + + _, err := Reauth(context.Background(), opts) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "keychain") { + t.Fatalf("error should mention keychain: %v", err) + } +} + +func TestReauthEmailMismatch(t *testing.T) { + opts := ReauthOptions{ + Email: "user@example.com", + Client: "default", + Services: []string{"calendar"}, + Scopes: []string{"https://www.googleapis.com/auth/calendar"}, + OpenSecretsStore: func() (secrets.Store, error) { + return &mockSecretStore{tokens: make(map[string]secrets.Token)}, nil + }, + EnsureKeychainAccess: func(context.Context) error { return nil }, + AuthorizeFunc: func(ctx context.Context, authOpts AuthorizeOptions) (string, error) { + return "new-refresh-token", nil + }, + FetchIdentityFunc: func(context.Context, string, string, []string, time.Duration) (Identity, error) { + // Return a *different* email than requested + return Identity{Subject: "sub456", Email: "other@example.com"}, nil + }, + Stderr: &bytesBuffer{}, + } + + _, err := Reauth(context.Background(), opts) + if err == nil { + t.Fatal("expected error for email mismatch") + } + + if !strings.Contains(err.Error(), "authorized as other@example.com") { + t.Fatalf("error should mention email mismatch: %v", err) + } +} + +func TestReauthPreservesStoredScopes(t *testing.T) { + store := &mockSecretStore{tokens: map[string]secrets.Token{ + "default:user@example.com": { + Scopes: []string{ + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/drive", + }, + Services: []string{"calendar", "gmail", "drive"}, + }, + }} + + var requestedScopes []string + var requestedServices []string + + opts := ReauthOptions{ + Email: "user@example.com", + Client: "default", + Services: []string{"calendar"}, // narrowed — only the triggering request's service + Scopes: []string{"https://www.googleapis.com/auth/calendar"}, // narrowed + StoredToken: &secrets.Token{ + Scopes: []string{ + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/drive", + }, + Services: []string{"calendar", "gmail", "drive"}, + }, + OpenSecretsStore: func() (secrets.Store, error) { + return store, nil + }, + EnsureKeychainAccess: func(context.Context) error { return nil }, + AuthorizeFunc: func(ctx context.Context, authOpts AuthorizeOptions) (string, error) { + requestedScopes = authOpts.Scopes + requestedServices = make([]string, len(authOpts.Services)) + for i, svc := range authOpts.Services { + requestedServices[i] = string(svc) + } + return "new-refresh-token", nil + }, + FetchIdentityFunc: func(context.Context, string, string, []string, time.Duration) (Identity, error) { + return Identity{Subject: "sub123", Email: "user@example.com"}, nil + }, + Stderr: &bytesBuffer{}, + } + + if _, err := Reauth(context.Background(), opts); err != nil { + t.Fatalf("Reauth: %v", err) + } + + // Should request the stored token's full scopes, not the narrowed set + if len(requestedScopes) != 3 { + t.Fatalf("expected 3 scopes (preserved from stored token), got %d: %v", len(requestedScopes), requestedScopes) + } + + // Should request the stored token's full services + if len(requestedServices) != 3 { + t.Fatalf("expected 3 services (preserved from stored token), got %d: %v", len(requestedServices), requestedServices) + } + + // Verify persisted token has the full scope set + tok, err := store.GetToken("default", "user@example.com") + if err != nil { + t.Fatalf("GetToken: %v", err) + } + if len(tok.Scopes) != 3 { + t.Fatalf("persisted token should have 3 scopes, got %d: %v", len(tok.Scopes), tok.Scopes) + } +} + +func TestServicesFromScopes(t *testing.T) { + // Calendar scope should match calendar service + services := servicesFromScopes([]string{"https://www.googleapis.com/auth/calendar"}) + if len(services) != 1 || services[0] != ServiceCalendar { + t.Fatalf("expected [calendar], got %v", services) + } + + // Gmail scopes should match gmail service + services = servicesFromScopes([]string{ + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.settings.basic", + "https://www.googleapis.com/auth/gmail.settings.sharing", + }) + if len(services) != 1 || services[0] != ServiceGmail { + t.Fatalf("expected [gmail], got %v", services) + } + + // Unknown scopes should return empty + services = servicesFromScopes([]string{"https://unknown.example.com/scope"}) + if len(services) != 0 { + t.Fatalf("expected empty, got %v", services) + } +} + +// bytesBuffer is a minimal io.Writer for capturing stderr output in tests. +type bytesBuffer struct { + data []byte +} + +func (b *bytesBuffer) Write(p []byte) (int, error) { + b.data = append(b.data, p...) + return len(p), nil +} + +func (b *bytesBuffer) String() string { + return string(b.data) +}