summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/google_tasks.go14
-rw-r--r--internal/api/google_tasks_oauth.go93
-rw-r--r--internal/api/google_tasks_oauth_test.go122
3 files changed, 226 insertions, 3 deletions
diff --git a/internal/api/google_tasks.go b/internal/api/google_tasks.go
index 644f124..9188bc7 100644
--- a/internal/api/google_tasks.go
+++ b/internal/api/google_tasks.go
@@ -20,11 +20,19 @@ type GoogleTasksClient struct {
displayTZ *time.Location
}
-// NewGoogleTasksClient creates a client for Google Tasks.
+// NewGoogleTasksClient creates a client for Google Tasks authenticated via
+// the given option (e.g. option.WithHTTPClient for OAuth -- see
+// NewGoogleTasksOAuthClient in google_tasks_oauth.go). Service-account auth
+// (option.WithCredentialsFile) does NOT work for Tasks: a service account
+// has no path to a regular user's personal task lists, unlike Calendar,
+// which supports sharing a calendar with any email including a service
+// account's. Confirmed 2026-07-14 (see project_doot_google_tasks_oauth_limitation
+// memory) -- always returns exactly one list ("My Tasks") with zero items,
+// the service account's own empty default list, never the real user's.
// tasklistID can be "@default" for the primary list, or a specific list ID.
// Multiple lists can be comma-separated.
-func NewGoogleTasksClient(ctx context.Context, credentialsFile, tasklistID, timezone string) (*GoogleTasksClient, error) {
- srv, err := tasks.NewService(ctx, option.WithCredentialsFile(credentialsFile))
+func NewGoogleTasksClient(ctx context.Context, clientOpt option.ClientOption, tasklistID, timezone string) (*GoogleTasksClient, error) {
+ srv, err := tasks.NewService(ctx, clientOpt)
if err != nil {
return nil, fmt.Errorf("unable to create Tasks client: %v", err)
}
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)
+}
diff --git a/internal/api/google_tasks_oauth_test.go b/internal/api/google_tasks_oauth_test.go
new file mode 100644
index 0000000..b4ea6f3
--- /dev/null
+++ b/internal/api/google_tasks_oauth_test.go
@@ -0,0 +1,122 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "golang.org/x/oauth2"
+)
+
+// fakeTokenStore is a minimal in-memory TokenStore for tests.
+type fakeTokenStore struct {
+ tokens map[string]*oauth2.Token
+ saved []*oauth2.Token // records every SaveOAuthToken call, in order
+}
+
+func newFakeTokenStore() *fakeTokenStore {
+ return &fakeTokenStore{tokens: make(map[string]*oauth2.Token)}
+}
+
+func (f *fakeTokenStore) GetOAuthToken(source string) (*oauth2.Token, error) {
+ return f.tokens[source], nil
+}
+
+func (f *fakeTokenStore) SaveOAuthToken(source string, tok *oauth2.Token) error {
+ f.tokens[source] = tok
+ f.saved = append(f.saved, tok)
+ return nil
+}
+
+func TestDbTokenSource_NotConnected_ReturnsError(t *testing.T) {
+ store := newFakeTokenStore()
+ src := &dbTokenSource{ctx: context.Background(), store: store, source: "google_tasks", config: &oauth2.Config{}}
+
+ if _, err := src.Token(); err == nil {
+ t.Error("expected an error when no token is stored, got nil")
+ }
+}
+
+func TestDbTokenSource_NotExpired_ReturnsWithoutPersisting(t *testing.T) {
+ store := newFakeTokenStore()
+ future := time.Now().Add(time.Hour)
+ store.tokens["google_tasks"] = &oauth2.Token{
+ AccessToken: "still-valid", RefreshToken: "r1", TokenType: "Bearer", Expiry: future,
+ }
+ src := &dbTokenSource{ctx: context.Background(), store: store, source: "google_tasks", config: &oauth2.Config{}}
+
+ tok, err := src.Token()
+ if err != nil {
+ t.Fatalf("Token: %v", err)
+ }
+ if tok.AccessToken != "still-valid" {
+ t.Errorf("AccessToken = %q, want unchanged %q", tok.AccessToken, "still-valid")
+ }
+ if len(store.saved) != 0 {
+ t.Errorf("SaveOAuthToken called %d times, want 0 (nothing changed, no refresh needed)", len(store.saved))
+ }
+}
+
+func TestDbTokenSource_Refresh_PreservesOriginalRefreshTokenWhenOmitted(t *testing.T) {
+ // Simulates Google's real behavior: a refresh response often omits
+ // refresh_token entirely. Without the fix, that would overwrite the
+ // stored refresh token with "" and permanently break future refreshes.
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{
+ "access_token": "refreshed-access-token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ // no refresh_token in the response, matching Google's real behavior
+ })
+ }))
+ defer server.Close()
+
+ store := newFakeTokenStore()
+ expired := time.Now().Add(-time.Hour)
+ store.tokens["google_tasks"] = &oauth2.Token{
+ AccessToken: "stale-access-token", RefreshToken: "original-refresh-token", TokenType: "Bearer", Expiry: expired,
+ }
+
+ cfg := &oauth2.Config{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ Endpoint: oauth2.Endpoint{TokenURL: server.URL},
+ }
+ src := &dbTokenSource{ctx: context.Background(), store: store, source: "google_tasks", config: cfg}
+
+ tok, err := src.Token()
+ if err != nil {
+ t.Fatalf("Token: %v", err)
+ }
+ if tok.AccessToken != "refreshed-access-token" {
+ t.Errorf("AccessToken = %q, want %q", tok.AccessToken, "refreshed-access-token")
+ }
+ if tok.RefreshToken != "original-refresh-token" {
+ t.Errorf("RefreshToken = %q, want original preserved %q", tok.RefreshToken, "original-refresh-token")
+ }
+
+ if len(store.saved) != 1 {
+ t.Fatalf("SaveOAuthToken called %d times, want 1", len(store.saved))
+ }
+ if store.saved[0].RefreshToken != "original-refresh-token" {
+ t.Errorf("persisted RefreshToken = %q, want %q", store.saved[0].RefreshToken, "original-refresh-token")
+ }
+}
+
+func TestGoogleTasksOAuthConfig_HasTasksScopeAndEndpoint(t *testing.T) {
+ cfg := GoogleTasksOAuthConfig("id", "secret", "https://doot.terst.org/settings/google-tasks/callback")
+
+ if cfg.ClientID != "id" || cfg.ClientSecret != "secret" {
+ t.Errorf("ClientID/Secret = %q/%q, want id/secret", cfg.ClientID, cfg.ClientSecret)
+ }
+ if cfg.RedirectURL != "https://doot.terst.org/settings/google-tasks/callback" {
+ t.Errorf("RedirectURL = %q", cfg.RedirectURL)
+ }
+ if len(cfg.Scopes) != 1 || cfg.Scopes[0] != "https://www.googleapis.com/auth/tasks" {
+ t.Errorf("Scopes = %v, want [tasks scope]", cfg.Scopes)
+ }
+}