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) }