summaryrefslogtreecommitdiff
path: root/internal/api/google_tasks_oauth.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/api/google_tasks_oauth.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/api/google_tasks_oauth.go')
-rw-r--r--internal/api/google_tasks_oauth.go25
1 files changed, 25 insertions, 0 deletions
diff --git a/internal/api/google_tasks_oauth.go b/internal/api/google_tasks_oauth.go
index 6edba21..c6f816c 100644
--- a/internal/api/google_tasks_oauth.go
+++ b/internal/api/google_tasks_oauth.go
@@ -3,6 +3,7 @@ package api
import (
"context"
"fmt"
+ "strings"
"time"
"golang.org/x/oauth2"
@@ -18,6 +19,21 @@ import (
type TokenStore interface {
GetOAuthToken(source string) (*oauth2.Token, error)
SaveOAuthToken(source string, tok *oauth2.Token) error
+ // RecordOAuthTokenError persists why a refresh failed, so the settings page
+ // can distinguish "connected" from "has a row but the grant is dead".
+ RecordOAuthTokenError(source string, message string) error
+}
+
+// NeedsReconnect reports whether an error means the stored grant is finished
+// and only a fresh consent will fix it.
+//
+// Google answers invalid_grant when a refresh token has been revoked,
+// superseded, or has simply aged out — which for an OAuth client still in
+// "Testing" publishing status happens after seven days. Retrying cannot help;
+// the user has to reconnect. Any other error (a network blip, a 5xx) is worth
+// retrying and must not push the user through consent needlessly.
+func NeedsReconnect(err error) bool {
+ return err != nil && strings.Contains(err.Error(), "invalid_grant")
}
// GoogleTasksOAuthSource is the key GoogleTasksClient's token is stored
@@ -77,6 +93,15 @@ func (d *dbTokenSource) Token() (*oauth2.Token, error) {
refreshed, err := d.config.TokenSource(ctx, current).Token()
if err != nil {
+ // Record it: a failed refresh is invisible otherwise, and an
+ // invalid_grant in particular means no amount of waiting will fix it.
+ msg := err.Error()
+ if NeedsReconnect(err) {
+ msg = "Google rejected the saved authorisation (invalid_grant) — reconnect to continue. " +
+ "If this recurs weekly, the OAuth client is still in Testing publishing status, " +
+ "which expires refresh tokens after 7 days."
+ }
+ _ = d.store.RecordOAuthTokenError(d.source, msg)
return nil, fmt.Errorf("refreshing token: %w", err)
}