From 3660486153a16760d2b980e546bbbd29408fb8d4 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 16 Aug 2026 00:03:51 +0000 Subject: Replace Google Tasks service-account auth with real OAuth Service-account auth structurally cannot see a regular user's personal task lists (no equivalent of Calendar's per-item sharing model) -- confirmed via GetTaskLists returning exactly the service account's own empty "My Tasks" list, never the real user's three lists. Zero rows were ever cached in production as a result. Adds a standard 3-legged OAuth flow: /settings/google-tasks/connect redirects to Google's consent screen (AccessTypeOffline+ApprovalForce so a refresh_token is always issued), /callback exchanges the code and persists the token (new oauth_tokens table), /disconnect clears it. GoogleTasksClient now takes an option.ClientOption instead of a credentials file path; NewGoogleTasksOAuthClient wraps it with a dbTokenSource that reloads/refreshes from the DB on each access-token expiry and re-persists -- carefully preserving the original refresh_token when Google's refresh response omits one (it usually does), which would otherwise silently and permanently break future refreshes. Settings page shows connection status and a Connect/Disconnect button. Calendar keeps using service-account auth (that one actually works). Requires a one-time manual step: create an OAuth 2.0 Client ID in Google Cloud Console and set GOOGLE_OAUTH_CLIENT_ID/SECRET in .env -- documented in .env.example. --- internal/api/google_tasks_oauth.go | 93 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 internal/api/google_tasks_oauth.go (limited to 'internal/api/google_tasks_oauth.go') diff --git a/internal/api/google_tasks_oauth.go b/internal/api/google_tasks_oauth.go new file mode 100644 index 0000000..1f5f8c5 --- /dev/null +++ b/internal/api/google_tasks_oauth.go @@ -0,0 +1,93 @@ +package api + +import ( + "context" + "fmt" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/option" + "google.golang.org/api/tasks/v1" +) + +// TokenStore is the persistence doot's store package satisfies (defined +// here, not imported from internal/store, so this package doesn't take a +// dependency on the store package -- api clients otherwise never touch the +// DB directly). +type TokenStore interface { + GetOAuthToken(source string) (*oauth2.Token, error) + SaveOAuthToken(source string, tok *oauth2.Token) error +} + +// GoogleTasksOAuthSource is the key GoogleTasksClient's token is stored +// under in oauth_tokens -- shared between the connect/callback handlers and +// the client constructor so they always agree on which row they mean. +const GoogleTasksOAuthSource = "google_tasks" + +// GoogleTasksOAuthConfig builds the oauth2.Config for the Google Tasks +// consent flow. redirectURL must exactly match an "Authorized redirect URI" +// configured on the OAuth 2.0 Client ID in Google Cloud Console. +func GoogleTasksOAuthConfig(clientID, clientSecret, redirectURL string) *oauth2.Config { + return &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: []string{tasks.TasksScope}, + Endpoint: google.Endpoint, + } +} + +// dbTokenSource implements oauth2.TokenSource by reading the current token +// from the store on every call and persisting it back if the underlying +// oauth2.Config.TokenSource refreshed it. oauth2.NewClient wraps whatever +// source it's given in its own in-memory oauth2.ReuseTokenSource, so this +// only actually runs once per access-token lifetime (~1hr), not per API +// call -- cheap enough to just hit the DB each time rather than add a +// second layer of in-process caching. +type dbTokenSource struct { + ctx context.Context + store TokenStore + source string + config *oauth2.Config +} + +func (d *dbTokenSource) Token() (*oauth2.Token, error) { + current, err := d.store.GetOAuthToken(d.source) + if err != nil { + return nil, fmt.Errorf("loading stored token: %w", err) + } + if current == nil || current.RefreshToken == "" { + return nil, fmt.Errorf("google tasks not connected -- visit /settings and connect it") + } + + refreshed, err := d.config.TokenSource(d.ctx, current).Token() + if err != nil { + return nil, fmt.Errorf("refreshing token: %w", err) + } + + if refreshed.AccessToken != current.AccessToken { + // Google only returns RefreshToken on the initial grant -- a + // refresh response often omits it, which would otherwise + // overwrite the stored refresh token with an empty string and + // permanently break future refreshes. + if refreshed.RefreshToken == "" { + refreshed.RefreshToken = current.RefreshToken + } + if err := d.store.SaveOAuthToken(d.source, refreshed); err != nil { + return nil, fmt.Errorf("persisting refreshed token: %w", err) + } + } + + return refreshed, nil +} + +// NewGoogleTasksOAuthClient builds a GoogleTasksClient backed by a stored +// user-consent OAuth token instead of service-account credentials. Safe to +// call even before the user has connected anything -- construction always +// succeeds; API calls fail with the dbTokenSource error above until a +// token exists (see HandleGoogleTasksOAuthCallback). +func NewGoogleTasksOAuthClient(ctx context.Context, oauthConfig *oauth2.Config, tokenStore TokenStore, tasklistID, timezone string) (*GoogleTasksClient, error) { + src := &dbTokenSource{ctx: ctx, store: tokenStore, source: GoogleTasksOAuthSource, config: oauthConfig} + httpClient := oauth2.NewClient(ctx, src) + return NewGoogleTasksClient(ctx, option.WithHTTPClient(httpClient), tasklistID, timezone) +} -- cgit v1.2.3