summaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-08-16 00:03:51 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-08-16 00:03:51 +0000
commit3660486153a16760d2b980e546bbbd29408fb8d4 (patch)
treee29199d88b7365eca0438e41ac23fa0bd533f7c2 /internal/handlers
parentb55cfbbd433bed6035dfa228ee700e2cca060ca4 (diff)
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.
Diffstat (limited to 'internal/handlers')
-rw-r--r--internal/handlers/google_oauth.go110
-rw-r--r--internal/handlers/google_oauth_test.go161
-rw-r--r--internal/handlers/handlers.go48
-rw-r--r--internal/handlers/handlers_test.go2
-rw-r--r--internal/handlers/settings.go34
5 files changed, 321 insertions, 34 deletions
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})
}
-