diff options
Diffstat (limited to 'internal/handlers')
| -rw-r--r-- | internal/handlers/google_oauth.go | 110 | ||||
| -rw-r--r-- | internal/handlers/google_oauth_test.go | 161 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 48 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 2 | ||||
| -rw-r--r-- | internal/handlers/settings.go | 34 |
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}) } - |
