From 3660486153a16760d2b980e546bbbd29408fb8d4 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 16 Aug 2026 00:03:51 +0000 Subject: Replace Google Tasks service-account auth with real OAuth Service-account auth structurally cannot see a regular user's personal task lists (no equivalent of Calendar's per-item sharing model) -- confirmed via GetTaskLists returning exactly the service account's own empty "My Tasks" list, never the real user's three lists. Zero rows were ever cached in production as a result. Adds a standard 3-legged OAuth flow: /settings/google-tasks/connect redirects to Google's consent screen (AccessTypeOffline+ApprovalForce so a refresh_token is always issued), /callback exchanges the code and persists the token (new oauth_tokens table), /disconnect clears it. GoogleTasksClient now takes an option.ClientOption instead of a credentials file path; NewGoogleTasksOAuthClient wraps it with a dbTokenSource that reloads/refreshes from the DB on each access-token expiry and re-persists -- carefully preserving the original refresh_token when Google's refresh response omits one (it usually does), which would otherwise silently and permanently break future refreshes. Settings page shows connection status and a Connect/Disconnect button. Calendar keeps using service-account auth (that one actually works). Requires a one-time manual step: create an OAuth 2.0 Client ID in Google Cloud Console and set GOOGLE_OAUTH_CLIENT_ID/SECRET in .env -- documented in .env.example. --- internal/api/google_tasks.go | 14 ++- internal/api/google_tasks_oauth.go | 93 ++++++++++++++++++ internal/api/google_tasks_oauth_test.go | 122 ++++++++++++++++++++++++ internal/config/config.go | 25 ++++- internal/config/config_test.go | 12 ++- internal/handlers/google_oauth.go | 110 ++++++++++++++++++++++ internal/handlers/google_oauth_test.go | 161 ++++++++++++++++++++++++++++++++ internal/handlers/handlers.go | 48 +++++----- internal/handlers/handlers_test.go | 2 + internal/handlers/settings.go | 34 ++++--- internal/store/oauth_tokens.go | 62 ++++++++++++ internal/store/oauth_tokens_test.go | 121 ++++++++++++++++++++++++ 12 files changed, 757 insertions(+), 47 deletions(-) create mode 100644 internal/api/google_tasks_oauth.go create mode 100644 internal/api/google_tasks_oauth_test.go create mode 100644 internal/handlers/google_oauth.go create mode 100644 internal/handlers/google_oauth_test.go create mode 100644 internal/store/oauth_tokens.go create mode 100644 internal/store/oauth_tokens_test.go (limited to 'internal') 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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index e6bc19a..7a80897 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,8 +18,18 @@ type Config struct { GoogleCredentialsFile string GoogleCalendarID string - // Google Tasks - GoogleTasksListID string + // Google Tasks (real 3-legged OAuth -- service-account auth cannot see a + // user's personal task lists, see NewGoogleTasksClient's doc comment) + GoogleTasksListID string + GoogleOAuthClientID string + GoogleOAuthClientSecret string + // GoogleOAuthRedirectURL must exactly match an "Authorized redirect URI" + // on the OAuth 2.0 Client ID in Google Cloud Console -- kept as its own + // explicit var rather than derived from WebAuthnOrigin (which isn't set + // in production; passkeys aren't configured either) so there's exactly + // one place that has to match Console, not two things that have to + // agree with each other. + GoogleOAuthRedirectURL string // Paths DatabasePath string @@ -66,7 +76,10 @@ func Load() (*Config, error) { GoogleCalendarID: getEnvWithDefault("GOOGLE_CALENDAR_ID", "primary"), // Google Tasks - GoogleTasksListID: getEnvWithDefault("GOOGLE_TASKS_LIST_ID", "@default"), + GoogleTasksListID: getEnvWithDefault("GOOGLE_TASKS_LIST_ID", "@default"), + GoogleOAuthClientID: os.Getenv("GOOGLE_OAUTH_CLIENT_ID"), + GoogleOAuthClientSecret: os.Getenv("GOOGLE_OAUTH_CLIENT_SECRET"), + GoogleOAuthRedirectURL: getEnvWithDefault("GOOGLE_OAUTH_REDIRECT_URL", "https://doot.terst.org/settings/google-tasks/callback"), // Paths DatabasePath: getEnvWithDefault("DATABASE_PATH", "./dashboard.db"), @@ -140,9 +153,11 @@ func (c *Config) HasGoogleCalendar() bool { return c.GoogleCredentialsFile != "" } -// HasGoogleTasks checks if Google Tasks is configured +// HasGoogleTasks checks if Google Tasks OAuth is configured (client ID and +// secret from Google Cloud Console) -- doesn't mean a user has actually +// connected yet, that's the oauth_tokens row, checked separately. func (c *Config) HasGoogleTasks() bool { - return c.GoogleCredentialsFile != "" && c.GoogleTasksListID != "" + return c.GoogleOAuthClientID != "" && c.GoogleOAuthClientSecret != "" } // getEnvWithDefault returns environment variable value or default if not set diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b436e93..60277f4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -48,11 +48,13 @@ func TestConfigValidate(t *testing.T) { func TestConfigHasMethods(t *testing.T) { cfg := Config{ - PlanToEatAPIKey: "pte-key", - TrelloAPIKey: "trello-key", - TrelloToken: "trello-token", - GoogleCredentialsFile: "/path/to/creds.json", - GoogleTasksListID: "@default", + PlanToEatAPIKey: "pte-key", + TrelloAPIKey: "trello-key", + TrelloToken: "trello-token", + GoogleCredentialsFile: "/path/to/creds.json", + GoogleTasksListID: "@default", + GoogleOAuthClientID: "client-id", + GoogleOAuthClientSecret: "client-secret", } if !cfg.HasPlanToEat() { diff --git a/internal/handlers/google_oauth.go b/internal/handlers/google_oauth.go new file mode 100644 index 0000000..18fd255 --- /dev/null +++ b/internal/handlers/google_oauth.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "crypto/rand" + "encoding/base64" + "log" + "net/http" + + "golang.org/x/oauth2" + + "task-dashboard/internal/api" + "task-dashboard/internal/store" +) + +const sessionKeyGoogleOAuthState = "google_oauth_state" + +func randomState() (string, error) { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// HandleGoogleTasksOAuthStart redirects the browser to Google's consent +// screen. GET (not POST) is correct here even under CSRF-protected routes -- +// this is a plain navigable link the same way Google's own OAuth flow always +// is, and RequireAuth already gates the whole route group, so only an +// already-logged-in doot user can start it. +func (h *Handler) HandleGoogleTasksOAuthStart(w http.ResponseWriter, r *http.Request) { + if h.googleTasksOAuthConfig == nil { + http.Error(w, "Google Tasks OAuth is not configured (missing GOOGLE_OAUTH_CLIENT_ID/SECRET)", http.StatusServiceUnavailable) + return + } + + state, err := randomState() + if err != nil { + http.Error(w, "Failed to start OAuth flow", http.StatusInternalServerError) + return + } + h.sessions.Put(r.Context(), sessionKeyGoogleOAuthState, state) + + // AccessTypeOffline + ApprovalForce: without both, Google only returns a + // refresh_token on a user's very first-ever consent for this client -- + // a reconnect (e.g. after revoking access) would otherwise silently get + // an access-token-only response that can't be persisted/refreshed. + url := h.googleTasksOAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce) + http.Redirect(w, r, url, http.StatusFound) +} + +// HandleGoogleTasksOAuthCallback receives Google's redirect after consent, +// exchanges the code for a token, and persists it. +func (h *Handler) HandleGoogleTasksOAuthCallback(w http.ResponseWriter, r *http.Request) { + if h.googleTasksOAuthConfig == nil { + http.Error(w, "Google Tasks OAuth is not configured", http.StatusServiceUnavailable) + return + } + + if errParam := r.URL.Query().Get("error"); errParam != "" { + http.Redirect(w, r, "/settings?google_tasks_error="+errParam, http.StatusFound) + return + } + + wantState := h.sessions.GetString(r.Context(), sessionKeyGoogleOAuthState) + gotState := r.URL.Query().Get("state") + if wantState == "" || gotState != wantState { + http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest) + return + } + h.sessions.Remove(r.Context(), sessionKeyGoogleOAuthState) + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "Missing code", http.StatusBadRequest) + return + } + + tok, err := h.googleTasksOAuthConfig.Exchange(r.Context(), code) + if err != nil { + log.Printf("Google Tasks OAuth exchange failed: %v", err) + http.Redirect(w, r, "/settings?google_tasks_error=exchange_failed", http.StatusFound) + return + } + if tok.RefreshToken == "" { + // Shouldn't happen given AccessTypeOffline+ApprovalForce, but a + // token with no refresh_token would silently stop working in ~1hr. + log.Printf("Google Tasks OAuth exchange returned no refresh_token") + http.Redirect(w, r, "/settings?google_tasks_error=no_refresh_token", http.StatusFound) + return + } + + if err := h.store.SaveOAuthToken(api.GoogleTasksOAuthSource, tok); err != nil { + log.Printf("Failed to save Google Tasks OAuth token: %v", err) + http.Error(w, "Failed to save token", http.StatusInternalServerError) + return + } + + _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks) + http.Redirect(w, r, "/settings?google_tasks=connected", http.StatusFound) +} + +// HandleGoogleTasksOAuthDisconnect removes the stored token. +func (h *Handler) HandleGoogleTasksOAuthDisconnect(w http.ResponseWriter, r *http.Request) { + if err := h.store.DeleteOAuthToken(api.GoogleTasksOAuthSource); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to disconnect", err) + return + } + _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks) + http.Redirect(w, r, "/settings?google_tasks=disconnected", http.StatusFound) +} diff --git a/internal/handlers/google_oauth_test.go b/internal/handlers/google_oauth_test.go new file mode 100644 index 0000000..c01bbc4 --- /dev/null +++ b/internal/handlers/google_oauth_test.go @@ -0,0 +1,161 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/oauth2" + + "task-dashboard/internal/api" +) + +func withSession(t *testing.T, h *Handler, req *http.Request) *http.Request { + t.Helper() + ctx, err := h.sessions.Load(req.Context(), "") + if err != nil { + t.Fatal(err) + } + return req.WithContext(ctx) +} + +func TestHandleGoogleTasksOAuthStart_NotConfigured_Returns503(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + req := withSession(t, h, httptest.NewRequest("GET", "/settings/google-tasks/connect", nil)) + w := httptest.NewRecorder() + h.HandleGoogleTasksOAuthStart(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", w.Code) + } +} + +func TestHandleGoogleTasksOAuthStart_RedirectsToGoogleAndStoresState(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + h.googleTasksOAuthConfig = api.GoogleTasksOAuthConfig("client-id", "client-secret", "https://doot.terst.org/settings/google-tasks/callback") + + req := withSession(t, h, httptest.NewRequest("GET", "/settings/google-tasks/connect", nil)) + w := httptest.NewRecorder() + h.HandleGoogleTasksOAuthStart(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", w.Code) + } + loc := w.Header().Get("Location") + if loc == "" { + t.Fatal("no Location header set") + } + + state := h.sessions.GetString(req.Context(), sessionKeyGoogleOAuthState) + if state == "" { + t.Error("expected a state value to be stored in session") + } +} + +func TestHandleGoogleTasksOAuthCallback_StateMismatch_Returns400(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + h.googleTasksOAuthConfig = api.GoogleTasksOAuthConfig("client-id", "client-secret", "https://doot.terst.org/settings/google-tasks/callback") + + req := httptest.NewRequest("GET", "/settings/google-tasks/callback?state=wrong&code=abc", nil) + req = withSession(t, h, req) + h.sessions.Put(req.Context(), sessionKeyGoogleOAuthState, "expected-state") + + w := httptest.NewRecorder() + h.HandleGoogleTasksOAuthCallback(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + if tok, _ := h.store.GetOAuthToken(api.GoogleTasksOAuthSource); tok != nil { + t.Error("no token should have been saved on state mismatch") + } +} + +func TestHandleGoogleTasksOAuthCallback_GoogleReturnedError_RedirectsWithError(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + h.googleTasksOAuthConfig = api.GoogleTasksOAuthConfig("client-id", "client-secret", "https://doot.terst.org/settings/google-tasks/callback") + + req := withSession(t, h, httptest.NewRequest("GET", "/settings/google-tasks/callback?error=access_denied", nil)) + w := httptest.NewRecorder() + h.HandleGoogleTasksOAuthCallback(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", w.Code) + } + loc := w.Header().Get("Location") + if loc != "/settings?google_tasks_error=access_denied" { + t.Errorf("Location = %q", loc) + } +} + +func TestHandleGoogleTasksOAuthDisconnect_RemovesStoredToken(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + if err := h.store.SaveOAuthToken(api.GoogleTasksOAuthSource, &oauth2.Token{AccessToken: "a", RefreshToken: "r"}); err != nil { + t.Fatal(err) + } + + req := withSession(t, h, httptest.NewRequest("POST", "/settings/google-tasks/disconnect", nil)) + w := httptest.NewRecorder() + h.HandleGoogleTasksOAuthDisconnect(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", w.Code) + } + tok, err := h.store.GetOAuthToken(api.GoogleTasksOAuthSource) + if err != nil { + t.Fatal(err) + } + if tok != nil { + t.Error("token should be gone after disconnect") + } +} + +func TestHandleSettingsPage_ReflectsGoogleTasksConnectionState(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + h.googleTasksOAuthConfig = api.GoogleTasksOAuthConfig("client-id", "client-secret", "https://doot.terst.org/settings/google-tasks/callback") + + req := withSession(t, h, httptest.NewRequest("GET", "/settings", nil)) + w := httptest.NewRecorder() + h.HandleSettingsPage(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String()) + } + mock := h.renderer.(*MockRenderer) + data := mock.Calls[len(mock.Calls)-1].Data + type settingsData struct { + GoogleTasksOAuthReady bool + GoogleTasksConnected bool + } + jsonBytes, _ := json.Marshal(data) + var got settingsData + _ = json.Unmarshal(jsonBytes, &got) + if !got.GoogleTasksOAuthReady { + t.Error("GoogleTasksOAuthReady = false, want true (config was set)") + } + if got.GoogleTasksConnected { + t.Error("GoogleTasksConnected = true, want false (no token saved)") + } + + if err := h.store.SaveOAuthToken(api.GoogleTasksOAuthSource, &oauth2.Token{AccessToken: "a", RefreshToken: "r"}); err != nil { + t.Fatal(err) + } + w2 := httptest.NewRecorder() + h.HandleSettingsPage(w2, req) + data2 := mock.Calls[len(mock.Calls)-1].Data + jsonBytes2, _ := json.Marshal(data2) + var got2 settingsData + _ = json.Unmarshal(jsonBytes2, &got2) + if !got2.GoogleTasksConnected { + t.Error("GoogleTasksConnected = false, want true after saving a token") + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index aedbd11..fc66f84 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -15,7 +15,9 @@ import ( "sync" "time" + "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" + "golang.org/x/oauth2" "task-dashboard/internal/api" "task-dashboard/internal/auth" @@ -33,20 +35,22 @@ func newID() string { // Handler holds dependencies for HTTP handlers type Handler struct { - store *store.Store - trelloClient api.TrelloAPI - planToEatClient api.PlanToEatAPI - googleCalendarClient api.GoogleCalendarAPI - googleTasksClient api.GoogleTasksAPI - claudomatorClient api.ClaudomatorClient - config *config.Config - renderer Renderer - BuildVersion string - WebAuthnEnabled bool + store *store.Store + trelloClient api.TrelloAPI + planToEatClient api.PlanToEatAPI + googleCalendarClient api.GoogleCalendarAPI + googleTasksClient api.GoogleTasksAPI + googleTasksOAuthConfig *oauth2.Config + claudomatorClient api.ClaudomatorClient + config *config.Config + renderer Renderer + sessions *scs.SessionManager + BuildVersion string + WebAuthnEnabled bool } // New creates a new Handler instance -func New(s *store.Store, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, googleTasks api.GoogleTasksAPI, claudomator api.ClaudomatorClient, cfg *config.Config, buildVersion string, webAuthnEnabled bool) *Handler { +func New(s *store.Store, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, googleTasks api.GoogleTasksAPI, googleTasksOAuthConfig *oauth2.Config, claudomator api.ClaudomatorClient, cfg *config.Config, sessions *scs.SessionManager, buildVersion string, webAuthnEnabled bool) *Handler { // Template functions funcMap := template.FuncMap{ "subtract": func(a, b int) int { return a - b }, @@ -125,16 +129,18 @@ func New(s *store.Store, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googl } return &Handler{ - store: s, - trelloClient: trello, - planToEatClient: planToEat, - googleCalendarClient: googleCalendar, - googleTasksClient: googleTasks, - claudomatorClient: claudomator, - config: cfg, - renderer: NewTemplateRenderer(tmpl), - BuildVersion: buildVersion, - WebAuthnEnabled: webAuthnEnabled, + store: s, + trelloClient: trello, + planToEatClient: planToEat, + googleCalendarClient: googleCalendar, + googleTasksClient: googleTasks, + googleTasksOAuthConfig: googleTasksOAuthConfig, + claudomatorClient: claudomator, + config: cfg, + renderer: NewTemplateRenderer(tmpl), + sessions: sessions, + BuildVersion: buildVersion, + WebAuthnEnabled: webAuthnEnabled, } } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 7b18cf1..93afba5 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" "task-dashboard/internal/config" @@ -80,6 +81,7 @@ func setupTestHandler(t *testing.T) (*Handler, func()) { store: db, renderer: newTestRenderer(), config: &config.Config{CacheTTLMinutes: 5}, + sessions: scs.New(), } return h, cleanup diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index 9ab832c..d140aaf 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" + "task-dashboard/internal/api" "task-dashboard/internal/auth" "task-dashboard/internal/models" "task-dashboard/internal/store" @@ -21,20 +22,28 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { bySource[cfg.Source] = append(bySource[cfg.Source], cfg) } + googleTasksToken, _ := h.store.GetOAuthToken(api.GoogleTasksOAuthSource) + data := struct { - Configs map[string][]models.SourceConfig - Sources []string - SyncLog []store.SyncLogEntry - Agents []models.Agent - CSRFToken string - WebAuthnEnabled bool + Configs map[string][]models.SourceConfig + Sources []string + SyncLog []store.SyncLogEntry + Agents []models.Agent + CSRFToken string + WebAuthnEnabled bool + GoogleTasksOAuthReady bool + GoogleTasksConnected bool + GoogleTasksError string }{ - Configs: bySource, - Sources: []string{"trello", "gcal", "gtasks"}, - SyncLog: syncLog, - Agents: agents, - CSRFToken: auth.GetCSRFTokenFromContext(r.Context()), - WebAuthnEnabled: h.WebAuthnEnabled, + Configs: bySource, + Sources: []string{"trello", "gcal", "gtasks"}, + SyncLog: syncLog, + Agents: agents, + CSRFToken: auth.GetCSRFTokenFromContext(r.Context()), + WebAuthnEnabled: h.WebAuthnEnabled, + GoogleTasksOAuthReady: h.googleTasksOAuthConfig != nil, + GoogleTasksConnected: googleTasksToken != nil, + GoogleTasksError: r.URL.Query().Get("google_tasks_error"), } if err := h.renderer.Render(w, "settings.html", data); err != nil { @@ -148,4 +157,3 @@ func (h *Handler) HandleToggleSourceConfig(w http.ResponseWriter, r *http.Reques w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]bool{"enabled": enabled}) } - diff --git a/internal/store/oauth_tokens.go b/internal/store/oauth_tokens.go new file mode 100644 index 0000000..12df515 --- /dev/null +++ b/internal/store/oauth_tokens.go @@ -0,0 +1,62 @@ +package store + +import ( + "database/sql" + "time" + + "golang.org/x/oauth2" +) + +// GetOAuthToken returns the stored token for a source (e.g. "google_tasks"), +// or nil if none has been saved yet (integration not connected). +func (s *Store) GetOAuthToken(source string) (*oauth2.Token, error) { + var accessToken, refreshToken, tokenType string + var expiry sql.NullTime + err := s.db.QueryRow(` + SELECT access_token, refresh_token, token_type, expiry FROM oauth_tokens WHERE source = ? + `, source).Scan(&accessToken, &refreshToken, &tokenType, &expiry) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + tok := &oauth2.Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + TokenType: tokenType, + } + if expiry.Valid { + tok.Expiry = expiry.Time + } + return tok, nil +} + +// SaveOAuthToken upserts the token for a source. A refreshed token from +// oauth2.TokenSource sometimes omits RefreshToken (Google only returns it +// on the initial grant) -- callers must pass the original refresh token +// through if the new one is empty, since ON CONFLICT here always +// overwrites rather than preserving the old value. +func (s *Store) SaveOAuthToken(source string, tok *oauth2.Token) error { + var expiry *time.Time + if !tok.Expiry.IsZero() { + expiry = &tok.Expiry + } + _, err := s.db.Exec(` + INSERT INTO oauth_tokens (source, access_token, refresh_token, token_type, expiry, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(source) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + token_type = excluded.token_type, + expiry = excluded.expiry, + updated_at = CURRENT_TIMESTAMP + `, source, tok.AccessToken, tok.RefreshToken, tok.TokenType, expiry) + return err +} + +// DeleteOAuthToken removes a source's stored token (disconnect). +func (s *Store) DeleteOAuthToken(source string) error { + _, err := s.db.Exec(`DELETE FROM oauth_tokens WHERE source = ?`, source) + return err +} diff --git a/internal/store/oauth_tokens_test.go b/internal/store/oauth_tokens_test.go new file mode 100644 index 0000000..20c4a79 --- /dev/null +++ b/internal/store/oauth_tokens_test.go @@ -0,0 +1,121 @@ +package store + +import ( + "database/sql" + "path/filepath" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + "golang.org/x/oauth2" +) + +func newOAuthTokensTestStore(t *testing.T) *Store { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if _, err := db.Exec(` + CREATE TABLE oauth_tokens ( + source TEXT PRIMARY KEY, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + token_type TEXT NOT NULL DEFAULT 'Bearer', + expiry DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + t.Fatal(err) + } + return &Store{db: db} +} + +func TestGetOAuthToken_NotConnected_ReturnsNil(t *testing.T) { + s := newOAuthTokensTestStore(t) + + tok, err := s.GetOAuthToken("google_tasks") + if err != nil { + t.Fatalf("GetOAuthToken: %v", err) + } + if tok != nil { + t.Errorf("tok = %+v, want nil", tok) + } +} + +func TestSaveOAuthToken_ThenGet_RoundTrips(t *testing.T) { + s := newOAuthTokensTestStore(t) + + expiry := time.Now().Add(time.Hour).Truncate(time.Second) + in := &oauth2.Token{ + AccessToken: "access-1", + RefreshToken: "refresh-1", + TokenType: "Bearer", + Expiry: expiry, + } + if err := s.SaveOAuthToken("google_tasks", in); err != nil { + t.Fatalf("SaveOAuthToken: %v", err) + } + + got, err := s.GetOAuthToken("google_tasks") + if err != nil { + t.Fatalf("GetOAuthToken: %v", err) + } + if got == nil { + t.Fatal("got nil, want a token") + } + if got.AccessToken != "access-1" || got.RefreshToken != "refresh-1" || got.TokenType != "Bearer" { + t.Errorf("got = %+v, want access-1/refresh-1/Bearer", got) + } + if !got.Expiry.Equal(expiry) { + t.Errorf("Expiry = %v, want %v", got.Expiry, expiry) + } +} + +func TestSaveOAuthToken_Upserts(t *testing.T) { + s := newOAuthTokensTestStore(t) + + if err := s.SaveOAuthToken("google_tasks", &oauth2.Token{AccessToken: "old", RefreshToken: "refresh-1"}); err != nil { + t.Fatal(err) + } + if err := s.SaveOAuthToken("google_tasks", &oauth2.Token{AccessToken: "new", RefreshToken: "refresh-2"}); err != nil { + t.Fatal(err) + } + + got, err := s.GetOAuthToken("google_tasks") + if err != nil { + t.Fatal(err) + } + if got.AccessToken != "new" || got.RefreshToken != "refresh-2" { + t.Errorf("got = %+v, want access token updated to new/refresh-2", got) + } + + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM oauth_tokens`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Errorf("row count = %d, want 1 (upsert, not insert)", count) + } +} + +func TestDeleteOAuthToken_RemovesRow(t *testing.T) { + s := newOAuthTokensTestStore(t) + + if err := s.SaveOAuthToken("google_tasks", &oauth2.Token{AccessToken: "a", RefreshToken: "r"}); err != nil { + t.Fatal(err) + } + if err := s.DeleteOAuthToken("google_tasks"); err != nil { + t.Fatalf("DeleteOAuthToken: %v", err) + } + + got, err := s.GetOAuthToken("google_tasks") + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Errorf("got = %+v, want nil after delete", got) + } +} -- cgit v1.2.3