summaryrefslogtreecommitdiff
path: root/internal/api/google_tasks_oauth.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/google_tasks_oauth.go')
-rw-r--r--internal/api/google_tasks_oauth.go93
1 files changed, 93 insertions, 0 deletions
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)
+}