diff options
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) +} |
