From 9ee4e278ca76b33b0a5a6ba866ac34b41be3b59e Mon Sep 17 00:00:00 2001 From: Nehan Ahmed Date: Tue, 14 Jul 2026 14:36:08 +0500 Subject: [PATCH] fix: infinite redirect loop between web and dashboard apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three interacting failures causing an infinite redirect loop with exponentially nested redirectTo parameters: 1. Navbar Dashboard button linked to APP_URL (port 5173) instead of DASHBOARD_URL (port 5174) — fixed to use DASHBOARD_URL from config 2. All redirect validation functions (getSafeRedirect/getRedirectTo/ isSafeRedirectPath) only checked startsWith('/') and !startsWith('//') but accepted auth pages like /login, /signup, /forgot-password, /reset-password, /auth/callback — added an AUTH_PATHS blocklist that rejects these as redirect targets 3. Nesting of redirectTo query params caused encoding amplification on each hop — added query string stripping when a redirect target contains its own ?redirectTo= param Additionally, added a WEB_LOGIN_URL guard in the dashboard that detects when VITE_APP_URL resolves to the dashboard's own origin (port 5174) instead of the web app (port 5173), preventing self-referencing navigation that causes an infinite page reload. --- apps/dashboard/src/hooks/use-auth.tsx | 19 ++++--- apps/dashboard/src/pages/AuthCallback.tsx | 38 ++++++++++--- .../src/components/auth/AuthRouteGuard.tsx | 53 +++++++++++++++++-- apps/web/src/components/landing/Navbar.tsx | 26 ++++++--- apps/web/src/hooks/use-auth.tsx | 45 +++++++++++----- apps/web/src/pages/AuthCallback.tsx | 22 +++++++- 6 files changed, 164 insertions(+), 39 deletions(-) diff --git a/apps/dashboard/src/hooks/use-auth.tsx b/apps/dashboard/src/hooks/use-auth.tsx index 6996e47..4b50f87 100644 --- a/apps/dashboard/src/hooks/use-auth.tsx +++ b/apps/dashboard/src/hooks/use-auth.tsx @@ -6,7 +6,7 @@ import { useCallback, type ReactNode, } from "react" -import { useNavigate, useLocation } from "react-router-dom" +import { useLocation } from "react-router-dom" import type { Session, User } from "@supabase/supabase-js" import { supabase } from "@/lib/supabase" import { fetchMe } from "@/lib/api" @@ -23,13 +23,17 @@ interface AuthContextValue extends AuthState { } const APP_URL = import.meta.env.VITE_APP_URL ?? "http://localhost:5173" +const WEB_LOGIN_URL = APP_URL && !APP_URL.startsWith(window.location.origin) + ? APP_URL + : "http://localhost:5173" const AuthContext = createContext(undefined) -const PUBLIC_PATHS = ["/auth/callback"] +function isPublicPath(pathname: string): boolean { + return pathname === "/auth/callback" || pathname.startsWith("/auth/callback/") +} export function AuthProvider({ children }: { children: ReactNode }) { - const navigate = useNavigate() const location = useLocation() const [state, setState] = useState({ user: null, @@ -48,8 +52,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []) const redirectToLogin = useCallback(() => { - const currentPath = location.pathname + location.search - window.location.replace(`${APP_URL}/login?redirectTo=${encodeURIComponent(currentPath)}`) + const params = new URLSearchParams(location.search) + params.delete("redirectTo") + const cleanSearch = params.toString() + const currentPath = location.pathname + (cleanSearch ? `?${cleanSearch}` : "") + window.location.replace(`${WEB_LOGIN_URL}/login?redirectTo=${encodeURIComponent(currentPath)}`) }, [location.pathname, location.search]) useEffect(() => { @@ -89,7 +96,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { if (state.isLoading) return if (state.session) return - if (PUBLIC_PATHS.includes(location.pathname)) return + if (isPublicPath(location.pathname)) return redirectToLogin() }, [state.isLoading, state.session, location.pathname, redirectToLogin]) diff --git a/apps/dashboard/src/pages/AuthCallback.tsx b/apps/dashboard/src/pages/AuthCallback.tsx index 9ba7927..4344ee6 100644 --- a/apps/dashboard/src/pages/AuthCallback.tsx +++ b/apps/dashboard/src/pages/AuthCallback.tsx @@ -1,9 +1,33 @@ import { useEffect, useState } from "react" -import { useNavigate } from "react-router-dom" import { supabase } from "@/lib/supabase" +const APP_URL = import.meta.env.VITE_APP_URL ?? "http://localhost:5173" +const WEB_LOGIN_URL = APP_URL && !APP_URL.startsWith(window.location.origin) + ? APP_URL + : "http://localhost:5173" +const DEFAULT_REDIRECT = "/overview" + +const AUTH_PATHS = new Set(["/login", "/signup", "/forgot-password", "/reset-password", "/auth/callback"]) + +function sanitizeRedirectPath(hashRedirect: string | null): string { + if (!hashRedirect) return DEFAULT_REDIRECT + if (!hashRedirect.startsWith("/")) return DEFAULT_REDIRECT + if (hashRedirect.startsWith("//")) return DEFAULT_REDIRECT + + const questionIndex = hashRedirect.indexOf("?") + if (questionIndex !== -1) { + const searchParams = new URLSearchParams(hashRedirect.slice(questionIndex)) + if (searchParams.has("redirectTo")) { + hashRedirect = hashRedirect.slice(0, questionIndex) + } + } + + if (AUTH_PATHS.has(hashRedirect)) return DEFAULT_REDIRECT + + return hashRedirect +} + export default function AuthCallback() { - const navigate = useNavigate() const [error, setError] = useState(null) useEffect(() => { @@ -17,14 +41,14 @@ export default function AuthCallback() { const params = new URLSearchParams(hash) const accessToken = params.get("access_token") const refreshToken = params.get("refresh_token") - const redirectTo = params.get("redirectTo") || "/overview" + const redirectTo = sanitizeRedirectPath(params.get("redirectTo")) if (!accessToken || !refreshToken) { setError("Invalid authentication data") return } - window.location.hash = "" + history.replaceState(null, "", window.location.pathname) const { error: setSessionError } = await supabase.auth.setSession({ access_token: accessToken, @@ -36,11 +60,11 @@ export default function AuthCallback() { return } - navigate(redirectTo, { replace: true }) + window.location.replace(redirectTo) } handleCallback() - }, [navigate]) + }, []) if (error) { return ( @@ -49,7 +73,7 @@ export default function AuthCallback() { {error} Back to sign in diff --git a/apps/web/src/components/auth/AuthRouteGuard.tsx b/apps/web/src/components/auth/AuthRouteGuard.tsx index 7d6ecf7..3790a4f 100644 --- a/apps/web/src/components/auth/AuthRouteGuard.tsx +++ b/apps/web/src/components/auth/AuthRouteGuard.tsx @@ -1,11 +1,54 @@ -import { type ReactNode } from "react" -import { Navigate } from "react-router-dom" +import { useEffect, useState, type ReactNode } from "react" +import { Navigate, useSearchParams } from "react-router-dom" import { useAuth } from "@/hooks/use-auth" +import { supabase } from "@/lib/supabase" +import { DASHBOARD_URL } from "@/lib/config" + +const AUTH_PATHS = new Set(["/login", "/signup", "/forgot-password", "/reset-password", "/auth/callback"]) + +function isSafeRedirectPath(raw: string | null): boolean { + if (!raw) return false + if (!raw.startsWith("/")) return false + if (raw.startsWith("//")) return false + + const questionIndex = raw.indexOf("?") + if (questionIndex !== -1) { + const searchParams = new URLSearchParams(raw.slice(questionIndex)) + if (searchParams.has("redirectTo")) { + raw = raw.slice(0, questionIndex) + } + } + + if (AUTH_PATHS.has(raw)) return false + + return true +} export default function AuthRouteGuard({ children }: { children: ReactNode }) { const { user, isLoading } = useAuth() + const [searchParams] = useSearchParams() + const [isRedirecting, setIsRedirecting] = useState(false) + + useEffect(() => { + if (isLoading || !user) return + + const redirectTo = searchParams.get("redirectTo") + if (!isSafeRedirectPath(redirectTo)) return + + const target = redirectTo as string + setIsRedirecting(true) + supabase.auth.getSession().then(({ data: { session } }) => { + if (!session) { + window.location.href = "/" + return + } + const { access_token, refresh_token } = session + const hash = `#access_token=${encodeURIComponent(access_token)}&refresh_token=${encodeURIComponent(refresh_token)}&redirectTo=${encodeURIComponent(target)}` + window.location.replace(`${DASHBOARD_URL}/auth/callback${hash}`) + }) + }, [isLoading, user, searchParams]) - if (isLoading) { + if (isLoading || isRedirecting) { return (
@@ -14,6 +57,10 @@ export default function AuthRouteGuard({ children }: { children: ReactNode }) { } if (user) { + const redirectTo = searchParams.get("redirectTo") + if (isSafeRedirectPath(redirectTo)) { + return null + } return } diff --git a/apps/web/src/components/landing/Navbar.tsx b/apps/web/src/components/landing/Navbar.tsx index 442c97a..0adf0a7 100644 --- a/apps/web/src/components/landing/Navbar.tsx +++ b/apps/web/src/components/landing/Navbar.tsx @@ -4,9 +4,9 @@ import { Menu } from "lucide-react" import { Button } from "@/components/ui/button" import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { cn } from "@/lib/utils" +import { supabase } from "@/lib/supabase" import { useAuth } from "@/hooks/use-auth" - -const APP_URL = import.meta.env.VITE_APP_URL ?? "http://localhost:3000" +import { DASHBOARD_URL } from "@/lib/config" const navLinks = [ { label: "Features", href: "#features" }, @@ -55,9 +55,14 @@ export default function Navbar() {
{isLoading ? null : user ? ( - + <> + + + ) : ( <> + <> + + + ) : ( <>