summaryrefslogtreecommitdiff
path: root/internal/store/oauth_tokens.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-20 17:44:02 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-20 17:44:02 +0000
commit6431cad59bda3da4bafb58fdf91f6a946e42af43 (patch)
tree1c5b6dc9ebbe3e9b34119b3c80812ba28770bb1c /internal/store/oauth_tokens.go
parent464f9e09c6cde9a2fe66a18500736206ae233d0a (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/oauth_tokens.go')
-rw-r--r--internal/store/oauth_tokens.go48
1 files changed, 47 insertions, 1 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)