diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64854a0c5..2bf4914be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: - name: Enable Corepack (pnpm) run: | corepack enable - corepack prepare pnpm@11.20.0 --activate + corepack prepare pnpm@11.21.0 --activate - name: Install dependencies run: pnpm -C internal/tracking/worker install --frozen-lockfile - name: Lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed8a337a..e0c1c1b6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 0.35.1 - Unreleased -- No unreleased changes. +- Auth: offer one-time re-authorization for expired or revoked stored OAuth refresh tokens after interactive confirmation, while preserving non-interactive recovery guidance. (#973) — thanks @inamiy. +- Dependencies: update Kong, Cloudflare Workers types, and pnpm to their latest releases. ## 0.35.0 - 2026-08-09 diff --git a/go.mod b/go.mod index 628356fa8..c5d314362 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( cloud.google.com/go/pubsub/v2 v2.6.1 filippo.io/age v1.3.1 github.com/99designs/keyring v1.2.2 - github.com/alecthomas/kong v1.16.0 + github.com/alecthomas/kong v1.16.1 github.com/mark3labs/mcp-go v0.57.0 github.com/muesli/termenv v0.16.0 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 73ea6126f..cdef0e460 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTB github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v1.16.0 h1:g92/kUxBcdcTPOM79yE63viJgtcp5dNyrB3/O2cjYT4= -github.com/alecthomas/kong v1.16.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E= +github.com/alecthomas/kong v1.16.1/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 296579979..3036d4982 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -2,16 +2,34 @@ package cmd import ( "context" + "errors" "fmt" + "io" + "os" "strings" "time" "github.com/openclaw/gogcli/internal/app" "github.com/openclaw/gogcli/internal/config" "github.com/openclaw/gogcli/internal/googleauth" + "github.com/openclaw/gogcli/internal/input" "github.com/openclaw/gogcli/internal/secrets" ) +func confirmReauthorization(ctx context.Context, email string) (bool, error) { + prompt := fmt.Sprintf("Refresh token for %s expired or was revoked. Re-authorize now? [y/N]: ", strings.TrimSpace(email)) + line, err := input.PromptLineFrom(ctx, prompt, stdinReader(ctx)) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) { + return false, nil + } + return false, fmt.Errorf("read confirmation: %w", err) + } + + answer := strings.ToLower(strings.TrimSpace(line)) + return answer == "y" || answer == "yes", nil +} + func openAuthSecretsStore(ctx context.Context) (secrets.Store, error) { if runtime, ok := app.FromContext(ctx); ok && runtime.Auth.OpenSecretsStore != nil { return runtime.Auth.OpenSecretsStore() diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6891e1c85..60a8a2f11 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,26 @@ 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) (secrets.Token, error) { + opts := googleauth.ReauthOptions{ + Email: email, + Client: client, + Services: services, + Scopes: scopes, + StoredToken: storedToken, + EnsureKeychainAccess: ensureKeychainAccessIfNeeded, + AuthorizeFunc: authorizeGoogleAccount, + FetchIdentityFunc: fetchAuthIdentity, + Confirm: confirmReauthorization, + Stderr: runtimeIO.Err, + } + return googleauth.Reauth(ctx, opts) + } + authDependencies := googleapi.AuthDependencies{ ResolveClient: resolveClient, ReadCredentials: readCredentials, @@ -256,6 +279,8 @@ func executeWithRuntime(args []string, runtime *app.Runtime) (err error) { Mode: cli.authMode, ADCTokenSource: googleapi.DefaultADCTokenSource, ServiceAccountTokenSource: googleapi.DefaultServiceAccountTokenSource, + Reauth: reauthFn, + ReauthCoordinator: googleapi.NewReauthCoordinator(), } 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..272f62a5e 100644 --- a/internal/googleapi/auth_dependencies.go +++ b/internal/googleapi/auth_dependencies.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" "golang.org/x/oauth2" "golang.org/x/oauth2/google" @@ -27,6 +28,38 @@ type ( ServiceAccountTokenSourceFunc func(context.Context, []byte, string, []string) (oauth2.TokenSource, error) ) +// ReauthCoordinator serializes interactive recovery across every Google +// service client created for one command invocation. +type ReauthCoordinator struct { + mu sync.Mutex +} + +func NewReauthCoordinator() *ReauthCoordinator { + return &ReauthCoordinator{} +} + +func (c *ReauthCoordinator) run(fn func() error) error { + if c == nil { + return fn() + } + + c.mu.Lock() + defer c.mu.Unlock() + + return fn() +} + +// 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 replacement token metadata. The token-source owner persists it +// and swaps the in-memory refresh token as one serialized operation. +type ReauthFunc func(ctx context.Context, email string, client string, services []string, scopes []string, storedToken *secrets.Token) (secrets.Token, error) + type AuthDependencies struct { ResolveClient authclient.ClientResolver ReadCredentials authclient.CredentialsReader @@ -36,6 +69,8 @@ type AuthDependencies struct { Mode AuthMode ADCTokenSource ADCTokenSourceFunc ServiceAccountTokenSource ServiceAccountTokenSourceFunc + Reauth ReauthFunc + ReauthCoordinator *ReauthCoordinator } var ( @@ -183,3 +218,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..77b265070 --- /dev/null +++ b/internal/googleapi/auto_reauth_test.go @@ -0,0 +1,504 @@ +package googleapi + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "golang.org/x/oauth2" +) + +var ( + errAutoReauthNetworkTimeout = errors.New("network timeout") + errAutoReauthUntypedInvalidGrant = errors.New(`oauth2: "invalid_grant" "Bad Request"`) + errAutoReauthBrowserNotOpen = errors.New("browser did not open") +) + +func TestIsInvalidGrantError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error", + err: nil, + want: false, + }, + { + name: "unrelated error", + err: errAutoReauthNetworkTimeout, + want: false, + }, + { + name: `invalid_grant with "Token has been expired or revoked"`, + err: invalidGrantError(), + want: true, + }, + { + name: "untyped invalid_grant text", + err: errAutoReauthUntypedInvalidGrant, + want: false, + }, + { + name: "wrapped invalid_grant", + err: fmt.Errorf("refresh access token: %w", invalidGrantError()), + 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 invalidGrantError() error { + return &oauth2.RetrieveError{ + ErrorCode: "invalid_grant", + ErrorDescription: "Token has been expired or revoked.", + } +} + +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, invalidGrantError() + 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, invalidGrantError() + }) + + rt := &RetryTransport{ + Base: base, + Reauth: func(context.Context) error { + return errAutoReauthBrowserNotOpen + }, + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + + resp, err := rt.RoundTrip(req) + if resp != nil { + _ = resp.Body.Close() + } + + 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, invalidGrantError() + }) + + 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) + } + + resp, err := rt.RoundTrip(req) + if resp != nil { + _ = resp.Body.Close() + } + + 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, invalidGrantError() + }) + + 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) + } + + resp, err := rt.RoundTrip(req) + if resp != nil { + _ = resp.Body.Close() + } + + 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, invalidGrantError() + }) + + 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 + + resp, err := rt.RoundTrip(req) + if resp != nil { + _ = resp.Body.Close() + } + + 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, invalidGrantError() + }) + + 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) + } + + resp, err := rt.RoundTrip(req) + if resp != nil { + _ = resp.Body.Close() + } + + 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, invalidGrantError() + 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, errUnexpectedRequestBody + } + }) + + 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, fmt.Errorf("get OAuth token: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + + resp, err := w.base.RoundTrip(req) + if err != nil { + return nil, fmt.Errorf("base round trip: %w", err) + } + + return resp, nil +} diff --git a/internal/googleapi/client.go b/internal/googleapi/client.go index 101626bed..c95234fe7 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 storedOAuth *persistingTokenSource 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. + storedOAuth, _ = 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 storedOAuth != nil && !NoInputFromContext(ctx) { + if reauthFn := reauthFunctionFromContext(ctx, serviceLabel, email, scopes, storedOAuth); reauthFn != nil { + retryTransport.Reauth = reauthFn + } + } + return readOnlyTransportFromContext(ctx, retryTransport), nil } @@ -298,3 +316,32 @@ 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 *persistingTokenSource) func(context.Context) error { + dependencies, ok := authDependenciesFromContext(ctx) + if !ok || dependencies.Reauth == nil { + return nil + } + + // 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 { + return dependencies.ReauthCoordinator.run(func() error { + return ts.Reauthorize(ctx, func(ctx context.Context, storedToken secrets.Token) (secrets.Token, error) { + return dependencies.Reauth(ctx, email, ts.client, services, scopes, &storedToken) + }) + }) + } +} diff --git a/internal/googleapi/client_auth.go b/internal/googleapi/client_auth.go index 3729e7dfb..fa3bf41d2 100644 --- a/internal/googleapi/client_auth.go +++ b/internal/googleapi/client_auth.go @@ -42,8 +42,24 @@ type forceRefreshTokenSource interface { var ( errBaseTokenSourceCannotForceRefresh = errors.New("base token source cannot force refresh") errBaseTokenSourceReturnedNilToken = errors.New("base token source returned nil token") + errBaseTokenSourceCannotReplaceToken = errors.New("base token source cannot replace refresh token") + errReauthEmptyRefreshToken = errors.New("reauthorization returned an empty refresh token") + errReauthAccountMismatch = errors.New("reauthorization returned a different account") ) +// 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 + } + + var retrieveErr *oauth2.RetrieveError + + return errors.As(err, &retrieveErr) && strings.EqualFold(strings.TrimSpace(retrieveErr.ErrorCode), "invalid_grant") +} + type resettableOAuthTokenSource struct { mu sync.Mutex source oauth2.TokenSource @@ -95,6 +111,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 +176,75 @@ func (p *persistingTokenSource) ForceRefresh(ctx context.Context) error { return err } +// Reauthorize serializes account recovery with ordinary token refreshes. A +// second caller rechecks the token after taking the lock, so concurrent API +// requests cannot launch duplicate browser flows for the same revoked token. +func (p *persistingTokenSource) Reauthorize( + ctx context.Context, + reauthorize func(context.Context, secrets.Token) (secrets.Token, error), +) error { + p.mu.Lock() + defer p.mu.Unlock() + + // Another request may have repaired the shared source while this request + // was waiting. In that case the token succeeds now and no browser is needed. + if t, err := p.base.Token(); err == nil { + _, persistErr := p.persistTokenLocked(t) + return persistErr + } else if !isInvalidGrantError(err) { + return fmt.Errorf("recheck refresh token before reauthorization: %w", err) + } + + resetter, ok := p.base.(*resettableOAuthTokenSource) + if !ok { + return errBaseTokenSourceCannotReplaceToken + } + + // A different service client may have completed reauthorization while this + // source waited on the command-wide coordinator. Adopt that stored token + // before deciding another browser flow is necessary. + if latest, storeErr := p.store.GetToken(p.client, p.email); storeErr == nil { + latestRefreshToken := strings.TrimSpace(latest.RefreshToken) + if latestRefreshToken != "" && latestRefreshToken != strings.TrimSpace(p.tok.RefreshToken) { + resetter.ResetRefreshToken(latestRefreshToken) + p.tok = latest + + if t, tokenErr := p.base.Token(); tokenErr == nil { + _, persistErr := p.persistTokenLocked(t) + return persistErr + } else if !isInvalidGrantError(tokenErr) { + return fmt.Errorf("use concurrently reauthorized token: %w", tokenErr) + } + } + } + + updated, err := reauthorize(ctx, p.tok) + if err != nil { + return err + } + + updated.RefreshToken = strings.TrimSpace(updated.RefreshToken) + if updated.RefreshToken == "" { + return errReauthEmptyRefreshToken + } + + persistEmail := strings.TrimSpace(p.email) + if updatedEmail := strings.TrimSpace(updated.Email); updatedEmail != "" && !strings.EqualFold(updatedEmail, persistEmail) { + return fmt.Errorf("%w: got %s, expected %s", errReauthAccountMismatch, updatedEmail, persistEmail) + } + updated.Client = p.client + updated.Email = persistEmail + + if err := p.store.SetToken(p.client, persistEmail, updated); err != nil { + return fmt.Errorf("persist reauthorized token: %w", err) + } + + resetter.ResetRefreshToken(updated.RefreshToken) + p.tok = updated + + return nil +} + 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..42c3c05c7 --- /dev/null +++ b/internal/googleapi/reauth_glue_test.go @@ -0,0 +1,249 @@ +package googleapi + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + + "golang.org/x/oauth2" + + "github.com/openclaw/gogcli/internal/config" + "github.com/openclaw/gogcli/internal/secrets" +) + +var ( + errReauthTestMissingRefreshToken = errors.New("missing refresh token") + errReauthTestTokenNotFound = errors.New("token not found") +) + +func TestReauthFunctionFromContextPersistsAndResetsTokenSource(t *testing.T) { + store := &fakeStore{token: &secrets.Token{ + Email: "user@example.com", + RefreshToken: "revoked-token", + Scopes: []string{"scope"}, + }} + base := reauthTestTokenSource() + persisting := newPersistingTokenSource(base, store, "default", "user@example.com", *store.token, "calendar", nil).(*persistingTokenSource) + + 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(context.Context, string, string, []string, []string, *secrets.Token) (secrets.Token, error) { + return secrets.Token{ + Email: "user@example.com", + RefreshToken: "new-refresh-token", + Scopes: []string{"scope"}, + }, nil + }, + } + ctx := WithAuthDependencies(context.Background(), deps) + + reauthFn := reauthFunctionFromContext(ctx, "calendar", "user@example.com", []string{"scope"}, persisting) + if reauthFn == nil { + t.Fatal("expected non-nil reauth function") + } + + if err := reauthFn(context.Background()); err != nil { + t.Fatalf("reauth closure: %v", err) + } + + if got := base.refreshToken; got != "new-refresh-token" { + t.Fatalf("in-memory refresh token = %q, want new-refresh-token", got) + } + + if got := store.token.RefreshToken; got != "new-refresh-token" { + t.Fatalf("stored refresh token = %q, want new-refresh-token", got) + } + + // A normal refresh response commonly omits RefreshToken. Persisting its + // access-token metadata must retain the replacement refresh token. + if _, err := persisting.Token(); err != nil { + t.Fatalf("post-reauth token: %v", err) + } + + if got := store.token.RefreshToken; got != "new-refresh-token" { + t.Fatalf("stored refresh token after refresh = %q, want new-refresh-token", got) + } +} + +func TestPersistingTokenSourceReauthorizeCoalescesConcurrentCalls(t *testing.T) { + store := &fakeStore{token: &secrets.Token{Email: "user@example.com", RefreshToken: "revoked-token"}} + original := *store.token + firstBase := reauthTestTokenSource() + secondBase := reauthTestTokenSource() + first := newPersistingTokenSource( + firstBase, + store, + "default", + "user@example.com", + original, + "calendar", + nil, + ).(*persistingTokenSource) + second := newPersistingTokenSource( + secondBase, + store, + "default", + "user@example.com", + original, + "calendar", + nil, + ).(*persistingTokenSource) + + started := make(chan struct{}) + release := make(chan struct{}) + var mu sync.Mutex + calls := 0 + deps := AuthDependencies{ + ReauthCoordinator: NewReauthCoordinator(), + Reauth: func(ctx context.Context, _ string, _ string, _ []string, _ []string, stored *secrets.Token) (secrets.Token, error) { + mu.Lock() + calls++ + + if calls == 1 { + close(started) + } + + mu.Unlock() + <-release + + if err := ctx.Err(); err != nil { + return secrets.Token{}, fmt.Errorf("reauthorization context: %w", err) + } + + updated := *stored + updated.RefreshToken = "new-refresh-token" + + return updated, nil + }, + } + ctx := WithAuthDependencies(context.Background(), deps) + firstReauth := reauthFunctionFromContext(ctx, "calendar", "user@example.com", []string{"scope"}, first) + secondReauth := reauthFunctionFromContext(ctx, "drive", "user@example.com", []string{"scope"}, second) + + errs := make(chan error, 2) + go func() { errs <- firstReauth(context.Background()) }() + + <-started + + go func() { errs <- secondReauth(context.Background()) }() + + close(release) + + for range 2 { + if err := <-errs; err != nil { + t.Fatalf("reauthorize: %v", err) + } + } + + mu.Lock() + defer mu.Unlock() + + if calls != 1 { + t.Fatalf("browser reauthorization calls = %d, want 1", calls) + } + + if got := firstBase.refreshToken; got != "new-refresh-token" { + t.Fatalf("first source refresh token = %q, want new-refresh-token", got) + } + + if got := secondBase.refreshToken; got != "new-refresh-token" { + t.Fatalf("second source refresh token = %q, want new-refresh-token", got) + } +} + +func TestReauthClosureLoadsStoredToken(t *testing.T) { + store := &fakeStore{token: &secrets.Token{ + Email: "user@example.com", + RefreshToken: "revoked-token", + Scopes: []string{"scope1", "scope2", "scope3"}, + Services: []string{"calendar", "gmail", "drive"}, + }} + persisting := newPersistingTokenSource( + reauthTestTokenSource(), + store, + "default", + "user@example.com", + *store.token, + "calendar", + nil, + ).(*persistingTokenSource) + + var passed *secrets.Token + deps := AuthDependencies{ + Reauth: func(_ context.Context, _, _ string, _, _ []string, stored *secrets.Token) (secrets.Token, error) { + snapshot := *stored + passed = &snapshot + snapshot.RefreshToken = "new-refresh-token" + + return snapshot, nil + }, + } + fn := reauthFunctionFromContext(WithAuthDependencies(context.Background(), deps), "calendar", "user@example.com", []string{"scope1"}, persisting) + + if err := fn(context.Background()); err != nil { + t.Fatalf("reauth: %v", err) + } + + if passed == nil || len(passed.Scopes) != 3 { + t.Fatalf("stored token scopes = %#v, want 3 preserved scopes", passed) + } +} + +func TestReauthFunctionFromContextUnavailable(t *testing.T) { + if fn := reauthFunctionFromContext(context.Background(), "calendar", "user@example.com", []string{"scope"}, nil); fn != nil { + t.Fatal("expected nil without auth dependencies") + } + + ctx := WithAuthDependencies(context.Background(), AuthDependencies{}) + if fn := reauthFunctionFromContext(ctx, "calendar", "user@example.com", []string{"scope"}, nil); fn != nil { + t.Fatal("expected nil without reauth dependency") + } +} + +func reauthTestTokenSource() *resettableOAuthTokenSource { + return newResettableOAuthTokenSource(func(token *oauth2.Token) oauth2.TokenSource { + refreshToken := token.RefreshToken + + return tokenSourceFunc(func() (*oauth2.Token, error) { + if refreshToken == "revoked-token" { + return nil, &oauth2.RetrieveError{ErrorCode: "invalid_grant"} + } + + if refreshToken == "" { + return nil, errReauthTestMissingRefreshToken + } + + return &oauth2.Token{AccessToken: "fresh-access-token"}, nil + }) + }, &oauth2.Token{RefreshToken: "revoked-token"}) +} + +type fakeStore struct { + token *secrets.Token +} + +func (s *fakeStore) Keys() ([]string, error) { return nil, nil } +func (s *fakeStore) GetToken(string, string) (secrets.Token, error) { + if s.token == nil { + return secrets.Token{}, errReauthTestTokenNotFound + } + + return *s.token, nil +} + +func (s *fakeStore) SetToken(_ string, _ string, token secrets.Token) error { + tokenCopy := token + s.token = &tokenCopy + + return nil +} +func (s *fakeStore) DeleteToken(string, 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..481e4a39a 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,31 @@ 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: %w; 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..4c37193a4 --- /dev/null +++ b/internal/googleauth/reauth.go @@ -0,0 +1,248 @@ +package googleauth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "sort" + "strings" + "time" + + "github.com/openclaw/gogcli/internal/secrets" +) + +var ( + errReauthEmailRequired = errors.New("reauth: email is required") + errReauthClientRequired = errors.New("reauth: client is required") + errReauthScopesRequired = errors.New("reauth: scopes are required") + errReauthConfirmationRequired = errors.New("reauth: confirmation callback is required") + errReauthCancelled = errors.New("reauth: cancelled") + errReauthIdentityEmailMissing = errors.New("reauth: authorized identity did not include an email") + errReauthAuthorizedAsMismatch = errors.New("reauth: authorized account does not match expected account") +) + +// 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 + // 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) + // Confirm obtains explicit user consent before opening a browser. Auto- + // reauthorization fails closed when no confirmation callback is provided. + Confirm func(context.Context, string) (bool, 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 and returns replacement token metadata to the caller. The +// token-source owner is responsible for persistence and the in-memory swap. +// +// It returns replacement token metadata so the caller can persist it and +// 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) (secrets.Token, error) { + if opts.Email == "" { + return secrets.Token{}, errReauthEmailRequired + } + + if opts.Client == "" { + return secrets.Token{}, errReauthClientRequired + } + + if len(opts.Scopes) == 0 { + return secrets.Token{}, errReauthScopesRequired + } + + 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.Confirm == nil { + return secrets.Token{}, errReauthConfirmationRequired + } + + confirmed, err := opts.Confirm(ctx, opts.Email) + if err != nil { + return secrets.Token{}, fmt.Errorf("reauth: confirmation: %w", err) + } + + if !confirmed { + return secrets.Token{}, errReauthCancelled + } + + if opts.EnsureKeychainAccess != nil { + if keychainErr := opts.EnsureKeychainAccess(ctx); keychainErr != nil { + return secrets.Token{}, fmt.Errorf("reauth: keychain access: %w", keychainErr) + } + } + + // 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, parseErr := ParseService(s) + if parseErr != 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", parseErr) + + 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, "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 secrets.Token{}, 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 secrets.Token{}, fmt.Errorf("reauth: fetch authorized identity: %w", err) + } + + authorizedEmail := strings.TrimSpace(identity.Email) + if authorizedEmail == "" { + return secrets.Token{}, errReauthIdentityEmailMissing + } + + // Verify the authorized account matches the expected email. + if !strings.EqualFold(strings.TrimSpace(authorizedEmail), strings.TrimSpace(opts.Email)) { + return secrets.Token{}, fmt.Errorf("%w: authorized as %s, expected %s", errReauthAuthorizedAsMismatch, authorizedEmail, opts.Email) + } + + serviceNames := make([]string, 0, len(services)) + for _, svc := range services { + serviceNames = append(serviceNames, string(svc)) + } + + sort.Strings(serviceNames) + + updated := secrets.Token{ + Client: opts.Client, + Subject: identity.Subject, + Email: authorizedEmail, + Services: serviceNames, + Scopes: reauthScopes, + CreatedAt: time.Now().UTC(), + RefreshToken: refreshToken, + } + + fmt.Fprintln(stderr, "Re-authorization successful. Retrying request…") + + return updated, 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..6372e87cd --- /dev/null +++ b/internal/googleauth/reauth_test.go @@ -0,0 +1,319 @@ +package googleauth + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/openclaw/gogcli/internal/secrets" +) + +var ( + errReauthTestUserDenied = errors.New("user denied access") + errReauthTestKeychainLocked = errors.New("keychain locked") +) + +func TestReauthSuccess(t *testing.T) { + authorizeCalled := false + identityCalled := false + + opts := ReauthOptions{ + Email: "user@example.com", + Client: "default", + Services: []string{"calendar"}, + Scopes: []string{"https://www.googleapis.com/auth/calendar"}, + EnsureKeychainAccess: func(context.Context) error { return nil }, + Confirm: func(context.Context, string) (bool, error) { return true, 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{}, + } + + tok, err := Reauth(context.Background(), opts) + if err != nil { + t.Fatalf("Reauth: %v", err) + } + + if !authorizeCalled { + t.Fatal("AuthorizeFunc was not called") + } + + if !identityCalled { + t.Fatal("FetchIdentityFunc was not called") + } + + 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"}, + EnsureKeychainAccess: func(context.Context) error { return nil }, + Confirm: func(context.Context, string) (bool, error) { return true, nil }, + AuthorizeFunc: func(ctx context.Context, authOpts AuthorizeOptions) (string, error) { + return "", errReauthTestUserDenied + }, + 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 TestReauthRequiresConfirmation(t *testing.T) { + opts := ReauthOptions{ + Email: "user@example.com", + Client: "default", + Scopes: []string{"scope"}, + } + if _, err := Reauth(context.Background(), opts); err == nil || !strings.Contains(err.Error(), "confirmation callback is required") { + t.Fatalf("Reauth() error = %v, want missing confirmation error", err) + } + + authorized := false + opts.Confirm = func(context.Context, string) (bool, error) { return false, nil } + + opts.AuthorizeFunc = func(context.Context, AuthorizeOptions) (string, error) { + authorized = true + return "token", nil + } + if _, err := Reauth(context.Background(), opts); err == nil || !strings.Contains(err.Error(), "cancelled") { + t.Fatalf("Reauth() error = %v, want cancellation", err) + } + + if authorized { + t.Fatal("authorization started after confirmation was declined") + } +} + +func TestReauthMissingEmail(t *testing.T) { + opts := ReauthOptions{ + Client: "default", + Scopes: []string{"scope"}, + } + + _, 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", + } + + _, 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"}, + Confirm: func(context.Context, string) (bool, error) { return true, nil }, + EnsureKeychainAccess: func(context.Context) error { + return errReauthTestKeychainLocked + }, + } + + _, 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"}, + EnsureKeychainAccess: func(context.Context) error { return nil }, + Confirm: func(context.Context, string) (bool, error) { return true, 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 TestReauthRejectsIdentityWithoutEmail(t *testing.T) { + opts := ReauthOptions{ + Email: "user@example.com", + Client: "default", + Services: []string{"calendar"}, + Scopes: []string{"https://www.googleapis.com/auth/calendar"}, + Confirm: func(context.Context, string) (bool, error) { return true, nil }, + AuthorizeFunc: func(context.Context, AuthorizeOptions) (string, error) { + return "new-refresh-token", nil + }, + FetchIdentityFunc: func(context.Context, string, string, []string, time.Duration) (Identity, error) { + return Identity{Subject: "sub123"}, nil + }, + Stderr: &bytesBuffer{}, + } + + if _, err := Reauth(context.Background(), opts); err == nil || !strings.Contains(err.Error(), "did not include an email") { + t.Fatalf("Reauth() error = %v, want missing email error", err) + } +} + +func TestReauthPreservesStoredScopes(t *testing.T) { + 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"}, + }, + EnsureKeychainAccess: func(context.Context) error { return nil }, + Confirm: func(context.Context, string) (bool, error) { return true, 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{}, + } + + tok, err := Reauth(context.Background(), opts) + if 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) + } + + if len(tok.Scopes) != 3 { + t.Fatalf("returned 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) +} diff --git a/internal/tracking/worker/package.json b/internal/tracking/worker/package.json index 4f9fcb1bf..b83f53a94 100644 --- a/internal/tracking/worker/package.json +++ b/internal/tracking/worker/package.json @@ -13,7 +13,7 @@ "test": "vitest run" }, "devDependencies": { - "@cloudflare/workers-types": "^5.20260808.1", + "@cloudflare/workers-types": "^5.20260809.1", "@typescript/native": "npm:typescript@^7.0.2", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", @@ -22,5 +22,5 @@ "vitest": "^4.1.10", "wrangler": "^4.120.0" }, - "packageManager": "pnpm@11.20.0" + "packageManager": "pnpm@11.21.0" } diff --git a/internal/tracking/worker/pnpm-lock.yaml b/internal/tracking/worker/pnpm-lock.yaml index d3fdc0be0..be780e900 100644 --- a/internal/tracking/worker/pnpm-lock.yaml +++ b/internal/tracking/worker/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: .: devDependencies: '@cloudflare/workers-types': - specifier: ^5.20260808.1 - version: 5.20260808.1 + specifier: ^5.20260809.1 + version: 5.20260809.1 '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 @@ -38,7 +38,7 @@ importers: version: 4.1.10(vite@8.2.0(esbuild@0.28.1)) wrangler: specifier: ^4.120.0 - version: 4.120.0(@cloudflare/workers-types@5.20260808.1) + version: 4.120.0(@cloudflare/workers-types@5.20260809.1) packages: @@ -85,8 +85,8 @@ packages: cpu: [x64] os: [win32] - '@cloudflare/workers-types@5.20260808.1': - resolution: {integrity: sha512-DN7G9SMyeOq031YhQexoExFAK78ms74cFiFF1teDlTK4+LjHgIc5Z8VbvXQtcCIP//o38btxzxW0b9MGPA83CA==} + '@cloudflare/workers-types@5.20260809.1': + resolution: {integrity: sha512-sBM+0I5lCY9LgTnorn/N2UyrA6KVbUzj9tncxwH8v6sH8tVeAyJj+i0z3xXWY4l4fd1oDcwt8CGf50vGwVISYQ==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -1409,7 +1409,7 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260801.1': optional: true - '@cloudflare/workers-types@5.20260808.1': {} + '@cloudflare/workers-types@5.20260809.1': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -2318,7 +2318,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260801.1 '@cloudflare/workerd-windows-64': 1.20260801.1 - wrangler@4.120.0(@cloudflare/workers-types@5.20260808.1): + wrangler@4.120.0(@cloudflare/workers-types@5.20260809.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260801.1) @@ -2329,7 +2329,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260801.1 optionalDependencies: - '@cloudflare/workers-types': 5.20260808.1 + '@cloudflare/workers-types': 5.20260809.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil