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) }