Skip to content

Commit 476aede

Browse files
hughdbrownclaude
andcommitted
Guard auto-reauth against transient errors and non-interactive sessions
- Only trigger token deletion + reauth on explicit invalid_grant errors from Google's OAuth2 endpoint, not on transient network/filesystem failures - Detect non-interactive sessions (cron, CI, scripts) via TTY check and fail fast with a clear error instead of hanging on browser OAuth flow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e5022f2 commit 476aede

1 file changed

Lines changed: 30 additions & 5 deletions

File tree

cmd/msgvault/cmd/root.go

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"path/filepath"
1010

11+
"github.com/mattn/go-isatty"
1112
"github.com/spf13/cobra"
1213
"github.com/wesm/msgvault/internal/config"
1314
"github.com/wesm/msgvault/internal/oauth"
@@ -145,11 +146,26 @@ func wrapOAuthError(err error) error {
145146
return err
146147
}
147148

149+
// isAuthInvalidError returns true if the error indicates the OAuth token is
150+
// permanently invalid (expired or revoked), as opposed to a transient failure
151+
// like a network error or context cancellation.
152+
func isAuthInvalidError(err error) bool {
153+
var retrieveErr *oauth2.RetrieveError
154+
if errors.As(err, &retrieveErr) {
155+
// Google returns "invalid_grant" when refresh tokens are expired or revoked
156+
return retrieveErr.ErrorCode == "invalid_grant"
157+
}
158+
return false
159+
}
160+
148161
// getTokenSourceWithReauth tries to get a token source for the given email.
149-
// If the token exists but is expired/revoked, it automatically deletes the old
150-
// token and re-initiates the OAuth browser flow.
151-
// NOTE: This function requires a browser for re-authorization. Only use from
152-
// interactive CLI commands (sync, sync-full, verify), not from daemon mode (serve).
162+
// If the token exists but is expired/revoked (invalid_grant), it automatically
163+
// deletes the old token and re-initiates the OAuth browser flow.
164+
// Transient errors (network, context cancellation) are returned as-is without
165+
// deleting the token.
166+
// NOTE: This function requires a browser and an interactive terminal for
167+
// re-authorization. Only use from interactive CLI commands (sync, sync-full,
168+
// verify), not from daemon mode (serve).
153169
func getTokenSourceWithReauth(ctx context.Context, oauthMgr *oauth.Manager, email string) (oauth2.TokenSource, error) {
154170
tokenSource, err := oauthMgr.TokenSource(ctx, email)
155171
if err == nil {
@@ -161,7 +177,16 @@ func getTokenSourceWithReauth(ctx context.Context, oauthMgr *oauth.Manager, emai
161177
return nil, fmt.Errorf("get token source: %w (run 'add-account %s' first)", err, email)
162178
}
163179

164-
// Token exists but failed (expired/revoked) — auto re-authorize
180+
// Token exists but failed — only auto-reauth for auth-invalid errors
181+
if !isAuthInvalidError(err) {
182+
return nil, fmt.Errorf("get token source for %s: %w", email, err)
183+
}
184+
185+
// Non-interactive session cannot open a browser for reauth
186+
if !isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd()) {
187+
return nil, fmt.Errorf("token for %s is expired or revoked, but cannot re-authorize in a non-interactive session (run 'add-account --force %s' from a terminal)", email, email)
188+
}
189+
165190
fmt.Printf("Token for %s is expired or revoked. Re-authorizing...\n", email)
166191

167192
if delErr := oauthMgr.DeleteToken(email); delErr != nil {

0 commit comments

Comments
 (0)