summaryrefslogtreecommitdiff
path: root/internal/handlers/google_oauth.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers/google_oauth.go')
-rw-r--r--internal/handlers/google_oauth.go110
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)
+}