Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
92 changes: 92 additions & 0 deletions docs/auto-reauth-issue-draft.md
Original file line number Diff line number Diff line change
@@ -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)
23 changes: 23 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,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,
Expand All @@ -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{
Expand Down
35 changes: 35 additions & 0 deletions internal/googleapi/auth_dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,6 +47,7 @@ type AuthDependencies struct {
Mode AuthMode
ADCTokenSource ADCTokenSourceFunc
ServiceAccountTokenSource ServiceAccountTokenSourceFunc
Reauth ReauthFunc
}

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