diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-20 17:44:02 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-20 17:44:02 +0000 |
| commit | 6431cad59bda3da4bafb58fdf91f6a946e42af43 (patch) | |
| tree | 1c5b6dc9ebbe3e9b34119b3c80812ba28770bb1c /internal/store | |
| parent | 464f9e09c6cde9a2fe66a18500736206ae233d0a (diff) | |
Surface a dead OAuth grant instead of reporting it as connected
With the cancelled-context bug fixed, the refresh actually reaches Google and
the real state of the Google Tasks grant becomes visible: it answers
invalid_grant. The stored refresh token has been dead since roughly 2026-08-16.
The earlier bug had been masking this — the refresh never got far enough to be
rejected.
Settings treated the presence of a token row as "Connected", so it reported
Google Tasks as connected for a month while every fetch failed. A refresh
failure is now persisted (migration 035 adds last_error/last_error_at to
oauth_tokens) and cleared on the next success, and settings reports "connected"
only when there is no recorded failure.
invalid_grant is classified specially, because it is the one failure retrying
cannot fix: the message tells the user to reconnect, and notes that a weekly
recurrence means the OAuth client is still in Testing publishing status, where
Google expires refresh tokens after seven days. Other errors — a network blip,
a 5xx — are left as transient rather than pushing the user through consent
needlessly.
Reconnecting at /settings is still a manual step; nothing here can perform
consent on the user's behalf.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/store')
| -rw-r--r-- | internal/store/oauth_tokens.go | 48 | ||||
| -rw-r--r-- | internal/store/oauth_tokens_test.go | 4 |
2 files changed, 50 insertions, 2 deletions
diff --git a/internal/store/oauth_tokens.go b/internal/store/oauth_tokens.go index 12df515..d216269 100644 --- a/internal/store/oauth_tokens.go +++ b/internal/store/oauth_tokens.go @@ -50,11 +50,57 @@ func (s *Store) SaveOAuthToken(source string, tok *oauth2.Token) error { refresh_token = excluded.refresh_token, token_type = excluded.token_type, expiry = excluded.expiry, - updated_at = CURRENT_TIMESTAMP + updated_at = CURRENT_TIMESTAMP, + -- A successful save means the grant works again; drop any recorded + -- failure so settings stops telling the user to reconnect. + last_error = NULL, + last_error_at = NULL `, source, tok.AccessToken, tok.RefreshToken, tok.TokenType, expiry) return err } +// RecordOAuthTokenError stores why a token stopped working. +// +// A stored row used to mean "connected" on its own, so a dead refresh token +// showed as connected while every background fetch failed. Persisting the +// reason is what lets settings say "reconnect" instead. +func (s *Store) RecordOAuthTokenError(source string, message string) error { + _, err := s.db.Exec(` + UPDATE oauth_tokens + SET last_error = ?, last_error_at = CURRENT_TIMESTAMP + WHERE source = ? + `, message, source) + return err +} + +// OAuthTokenStatus is a stored grant plus whether it is currently working. +type OAuthTokenStatus struct { + Connected bool + LastError string + LastErrorAt *time.Time +} + +// GetOAuthTokenStatus reports whether a source is usable, not merely present. +func (s *Store) GetOAuthTokenStatus(source string) (OAuthTokenStatus, error) { + var lastError sql.NullString + var lastErrorAt sql.NullTime + err := s.db.QueryRow( + `SELECT COALESCE(last_error, ''), last_error_at FROM oauth_tokens WHERE source = ?`, + source).Scan(&lastError, &lastErrorAt) + if err == sql.ErrNoRows { + return OAuthTokenStatus{}, nil + } + if err != nil { + return OAuthTokenStatus{}, err + } + st := OAuthTokenStatus{Connected: true, LastError: lastError.String} + if lastErrorAt.Valid { + t := lastErrorAt.Time + st.LastErrorAt = &t + } + return st, nil +} + // DeleteOAuthToken removes a source's stored token (disconnect). func (s *Store) DeleteOAuthToken(source string) error { _, err := s.db.Exec(`DELETE FROM oauth_tokens WHERE source = ?`, source) diff --git a/internal/store/oauth_tokens_test.go b/internal/store/oauth_tokens_test.go index 20c4a79..435a757 100644 --- a/internal/store/oauth_tokens_test.go +++ b/internal/store/oauth_tokens_test.go @@ -25,7 +25,9 @@ func newOAuthTokensTestStore(t *testing.T) *Store { refresh_token TEXT NOT NULL, token_type TEXT NOT NULL DEFAULT 'Bearer', expiry DATETIME, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_error TEXT, + last_error_at DATETIME ) `); err != nil { t.Fatal(err) |
