diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-08-16 00:03:51 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-08-16 00:03:51 +0000 |
| commit | 3660486153a16760d2b980e546bbbd29408fb8d4 (patch) | |
| tree | e29199d88b7365eca0438e41ac23fa0bd533f7c2 /internal/handlers/google_oauth.go | |
| parent | b55cfbbd433bed6035dfa228ee700e2cca060ca4 (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/google_oauth.go')
| -rw-r--r-- | internal/handlers/google_oauth.go | 110 |
1 files changed, 110 insertions, 0 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) +} |
