diff options
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/google_tasks.go | 14 | ||||
| -rw-r--r-- | internal/api/google_tasks_oauth.go | 93 | ||||
| -rw-r--r-- | internal/api/google_tasks_oauth_test.go | 122 |
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) + } +} |
