summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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
-rw-r--r--internal/config/config.go25
-rw-r--r--internal/config/config_test.go12
-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
-rw-r--r--internal/store/oauth_tokens.go62
-rw-r--r--internal/store/oauth_tokens_test.go121
12 files changed, 757 insertions, 47 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)
+ }
+}
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)
+ }
+}