package api import ( "context" "fmt" "strings" "time" "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 // 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 // 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 { store TokenStore source string config *oauth2.Config } // tokenRefreshTimeout bounds a single refresh round-trip to Google. const tokenRefreshTimeout = 30 * time.Second 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") } // Deliberately NOT a context captured at construction time. main.go builds // the client with a short-lived init context and cancels it the moment // construction returns; reusing that here meant the first refresh after the // startup access token aged out — roughly an hour in — failed with // "context canceled", and so did every one after it. Google Tasks was // broken from that point until a restart, which bought another hour. // // A token refresh is not logically part of whichever request happened to // trigger it, so it gets its own background context with its own bound. ctx, cancel := context.WithTimeout(context.Background(), tokenRefreshTimeout) defer cancel() 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) } 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{store: tokenStore, source: GoogleTasksOAuthSource, config: oauthConfig} // context.Background(), not ctx: oauth2.NewClient binds the context it is // given to the returned client's transport for its whole lifetime, and ctx // here is the caller's short-lived init context. The per-call context still // applies — it comes from the tasks service methods themselves. httpClient := oauth2.NewClient(context.Background(), src) return NewGoogleTasksClient(ctx, option.WithHTTPClient(httpClient), tasklistID, timezone) }