Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
18 changes: 18 additions & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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{
Expand Down
58 changes: 58 additions & 0 deletions internal/googleapi/auth_dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"sync"

"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
Expand All @@ -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
Expand All @@ -36,6 +69,8 @@ type AuthDependencies struct {
Mode AuthMode
ADCTokenSource ADCTokenSourceFunc
ServiceAccountTokenSource ServiceAccountTokenSourceFunc
Reauth ReauthFunc
ReauthCoordinator *ReauthCoordinator
}

var (
Expand Down Expand Up @@ -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
}
Loading