From ec7155a3259de5556ef5e2d12a6e068f5c09016c Mon Sep 17 00:00:00 2001 From: Nehan Ahmed Date: Tue, 14 Jul 2026 13:17:05 +0500 Subject: [PATCH] feat: cross-origin session bridge between web and dashboard apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a token relay mechanism for sharing Supabase auth sessions across different origins (port 5173 web → port 5174 dashboard). Flow: 1. Dashboard redirects to web login with ?redirectTo= param 2. After login (email/password or OAuth), web extracts session tokens 3. Web redirects to dashboard's /auth/callback#access_token=...&refresh_token=...&redirectTo=... 4. Dashboard AuthCallback calls supabase.auth.setSession() to persist tokens into dashboard's own localStorage 5. User is redirected to the intended page Security: - Tokens passed via URL fragment (never sent to servers) - window.location.replace() prevents token URL from entering browser history - Hash cleared immediately after extraction - Referrer-Policy: no-referrer on dashboard index.html --- apps/dashboard/index.html | 1 + apps/dashboard/src/App.tsx | 4 +- apps/dashboard/src/hooks/use-auth.tsx | 19 +++++-- apps/dashboard/src/pages/AuthCallback.tsx | 69 +++++++++++++++++++++++ apps/web/src/hooks/use-auth.tsx | 43 ++++++++++++-- apps/web/src/lib/config.ts | 1 + apps/web/src/pages/AuthCallback.tsx | 11 +++- 7 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 apps/dashboard/src/pages/AuthCallback.tsx diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html index 8d83a20..ed97a39 100644 --- a/apps/dashboard/index.html +++ b/apps/dashboard/index.html @@ -5,6 +5,7 @@ Dashboard — Linkify +
diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 92a099f..5e8c11c 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1,9 +1,11 @@ import { Routes, Route } from "react-router-dom" +import AuthCallback from "@/pages/AuthCallback" export default function App() { return ( - Dashboard} /> + } /> + Dashboard} /> ) } diff --git a/apps/dashboard/src/hooks/use-auth.tsx b/apps/dashboard/src/hooks/use-auth.tsx index 1df3a1f..6996e47 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 } from "react-router-dom" +import { useNavigate, useLocation } from "react-router-dom" import type { Session, User } from "@supabase/supabase-js" import { supabase } from "@/lib/supabase" import { fetchMe } from "@/lib/api" @@ -26,8 +26,11 @@ const APP_URL = import.meta.env.VITE_APP_URL ?? "http://localhost:5173" const AuthContext = createContext(undefined) +const PUBLIC_PATHS = ["/auth/callback"] + export function AuthProvider({ children }: { children: ReactNode }) { const navigate = useNavigate() + const location = useLocation() const [state, setState] = useState({ user: null, session: null, @@ -45,9 +48,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []) const redirectToLogin = useCallback(() => { - const currentPath = window.location.pathname + window.location.search - window.location.href = `${APP_URL}/login?redirectTo=${encodeURIComponent(currentPath)}` - }, []) + const currentPath = location.pathname + location.search + window.location.replace(`${APP_URL}/login?redirectTo=${encodeURIComponent(currentPath)}`) + }, [location.pathname, location.search]) useEffect(() => { supabase.auth.getSession().then(({ data: { session } }) => { @@ -83,6 +86,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [fetchProfile]) + useEffect(() => { + if (state.isLoading) return + if (state.session) return + if (PUBLIC_PATHS.includes(location.pathname)) return + + redirectToLogin() + }, [state.isLoading, state.session, location.pathname, redirectToLogin]) + const signOut = useCallback(async () => { await supabase.auth.signOut() setState({ diff --git a/apps/dashboard/src/pages/AuthCallback.tsx b/apps/dashboard/src/pages/AuthCallback.tsx new file mode 100644 index 0000000..9ba7927 --- /dev/null +++ b/apps/dashboard/src/pages/AuthCallback.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react" +import { useNavigate } from "react-router-dom" +import { supabase } from "@/lib/supabase" + +export default function AuthCallback() { + const navigate = useNavigate() + const [error, setError] = useState(null) + + useEffect(() => { + async function handleCallback() { + const hash = window.location.hash.slice(1) + if (!hash) { + setError("No authentication data received") + return + } + + const params = new URLSearchParams(hash) + const accessToken = params.get("access_token") + const refreshToken = params.get("refresh_token") + const redirectTo = params.get("redirectTo") || "/overview" + + if (!accessToken || !refreshToken) { + setError("Invalid authentication data") + return + } + + window.location.hash = "" + + const { error: setSessionError } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }) + + if (setSessionError) { + setError(setSessionError.message) + return + } + + navigate(redirectTo, { replace: true }) + } + + handleCallback() + }, [navigate]) + + if (error) { + return ( +
+
+ {error} +
+ + Back to sign in + +
+ ) + } + + return ( +
+ +

+ Signing you in... +

+
+ ) +} diff --git a/apps/web/src/hooks/use-auth.tsx b/apps/web/src/hooks/use-auth.tsx index d6e9e13..d9efce1 100644 --- a/apps/web/src/hooks/use-auth.tsx +++ b/apps/web/src/hooks/use-auth.tsx @@ -10,6 +10,7 @@ import { useNavigate } from "react-router-dom" import type { Session, User } from "@supabase/supabase-js" import { supabase } from "@/lib/supabase" import { fetchMe } from "@/lib/api" +import { DASHBOARD_URL } from "@/lib/config" interface AuthState { user: User | null @@ -32,6 +33,21 @@ interface AuthContextValue extends AuthState { const AuthContext = createContext(undefined) +function getRedirectTo(): string | null { + const params = new URLSearchParams(window.location.search) + return params.get("redirectTo") +} + +function relaySessionToDashboard() { + supabase.auth.getSession().then(({ data: { session } }) => { + if (!session) return + const { access_token, refresh_token } = session + const redirectTo = getRedirectTo() || "/overview" + const hash = `#access_token=${encodeURIComponent(access_token)}&refresh_token=${encodeURIComponent(refresh_token)}&redirectTo=${encodeURIComponent(redirectTo)}` + window.location.replace(`${DASHBOARD_URL}/auth/callback${hash}`) + }) +} + export function AuthProvider({ children }: { children: ReactNode }) { const navigate = useNavigate() const [state, setState] = useState({ @@ -97,7 +113,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { error: "Please verify your email before signing in. Check your inbox." } } - navigate("/") + const redirectTo = getRedirectTo() + if (redirectTo) { + relaySessionToDashboard() + } else { + navigate("/") + } return { error: null } }, [navigate] @@ -105,11 +126,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signUp = useCallback( async (email: string, password: string) => { + const redirectTo = getRedirectTo() const { error, data } = await supabase.auth.signUp({ email, password, options: { - emailRedirectTo: `${window.location.origin}/auth/callback`, + emailRedirectTo: redirectTo + ? `${window.location.origin}/auth/callback?redirectTo=${encodeURIComponent(redirectTo)}` + : `${window.location.origin}/auth/callback`, }, }) if (error) return { error: error.message, needsEmailVerification: false } @@ -119,7 +143,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { data.session === null if (!needsEmailVerification && data.session) { - navigate("/") + if (redirectTo) { + relaySessionToDashboard() + } else { + navigate("/") + } } return { error: null, needsEmailVerification } @@ -140,11 +168,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signInWithProvider = useCallback( async (provider: "google" | "github") => { try { + const redirectTo = getRedirectTo() + const callbackUrl = redirectTo + ? `${window.location.origin}/auth/callback?redirectTo=${encodeURIComponent(redirectTo)}` + : `${window.location.origin}/auth/callback` + const { error } = await supabase.auth.signInWithOAuth({ provider, - options: { - redirectTo: `${window.location.origin}/auth/callback`, - }, + options: { redirectTo: callbackUrl }, }) if (error) return { error: error.message } } catch { diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts index a6729c9..4c3d377 100644 --- a/apps/web/src/lib/config.ts +++ b/apps/web/src/lib/config.ts @@ -1 +1,2 @@ export const APP_URL = import.meta.env.VITE_APP_URL ?? "http://localhost:5173" +export const DASHBOARD_URL = import.meta.env.VITE_DASHBOARD_URL ?? "http://localhost:5174" diff --git a/apps/web/src/pages/AuthCallback.tsx b/apps/web/src/pages/AuthCallback.tsx index 4dd8f2e..d3b132e 100644 --- a/apps/web/src/pages/AuthCallback.tsx +++ b/apps/web/src/pages/AuthCallback.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react" import { useNavigate } from "react-router-dom" import { supabase } from "@/lib/supabase" +import { DASHBOARD_URL } from "@/lib/config" export default function AuthCallback() { const navigate = useNavigate() @@ -12,6 +13,7 @@ export default function AuthCallback() { const code = params.get("code") const errorParam = params.get("error") const errorDescription = params.get("error_description") + const redirectTo = params.get("redirectTo") if (errorParam) { setError(errorDescription ?? "Authentication failed") @@ -28,8 +30,15 @@ export default function AuthCallback() { } const { data: { session } } = await supabase.auth.getSession() + if (session) { - navigate("/", { replace: true }) + if (redirectTo) { + const { access_token, refresh_token } = session + const hash = `#access_token=${encodeURIComponent(access_token)}&refresh_token=${encodeURIComponent(refresh_token)}&redirectTo=${encodeURIComponent(redirectTo)}` + window.location.replace(`${DASHBOARD_URL}/auth/callback${hash}`) + } else { + navigate("/", { replace: true }) + } } }