diff --git a/client/hooks/use-foundation-auth.ts b/client/hooks/use-foundation-auth.ts index 93641346..d97b9a15 100644 --- a/client/hooks/use-foundation-auth.ts +++ b/client/hooks/use-foundation-auth.ts @@ -1,24 +1,25 @@ /** * useFoundationAuth Hook - * - * Manages Foundation OAuth flow including: - * - Detecting authorization code in URL - * - Exchanging code for token - * - Syncing user profile from Foundation - * - Handling session establishment + * + * Handles Foundation OAuth callback: + * - Detects authorization code in URL + * - Validates state token (CSRF protection) + * - Exchanges code for access token + * - Syncs user profile from Foundation + * - Establishes session */ import { useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { - getFoundationAuthCode, - hasFoundationAuthCode, - getFoundationAccessToken, - getAuthUserId, - clearFoundationAuth, - fetchUserProfileFromFoundation, - syncFoundationProfileToLocal, -} from "@/lib/foundation-auth"; + getAuthorizationCode, + getStateFromUrl, + hasAuthorizationCode, + validateState, + exchangeCodeForToken, + clearOAuthStorage, + getStoredRedirectTo, +} from "@/lib/foundation-oauth"; import { aethexToast } from "@/lib/aethex-toast"; interface UseFoundationAuthReturn { @@ -26,6 +27,10 @@ interface UseFoundationAuthReturn { error: string | null; } +/** + * Hook that processes Foundation OAuth callback + * Should be called in your main App component to catch oauth redirects + */ export function useFoundationAuth(): UseFoundationAuthReturn { const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); @@ -33,74 +38,84 @@ export function useFoundationAuth(): UseFoundationAuthReturn { const location = useLocation(); useEffect(() => { - // Check if we just received an auth code from Foundation - if (!hasFoundationAuthCode()) { + // Check if we have an authorization code in the URL + if (!hasAuthorizationCode()) { return; } setIsProcessing(true); setError(null); - const processFoundationAuth = async () => { + const processFoundationCallback = async () => { try { - const code = getFoundationAuthCode(); + // Get authorization code and state from URL + const code = getAuthorizationCode(); + const urlState = getStateFromUrl(); + if (!code) { - throw new Error("No authorization code found"); + throw new Error("No authorization code found in URL"); } - // Exchange code for token with backend - const API_BASE = import.meta.env.VITE_API_BASE || ""; - const response = await fetch(`${API_BASE}/api/auth/exchange-token`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ code }), - credentials: "include", // Include cookies - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error || "Failed to exchange authorization code", - ); + // Validate state token (CSRF protection) + if (!validateState(urlState)) { + console.warn("[Foundation Auth] State validation failed"); + throw new Error("Invalid state token - possible CSRF attack"); } - const data = await response.json(); + console.log("[Foundation Auth] Processing OAuth callback with code"); - if (!data.accessToken || !data.user) { - throw new Error("Invalid response from token exchange"); + // Exchange authorization code for access token + try { + const tokenData = await exchangeCodeForToken(code); + + if (!tokenData.accessToken || !tokenData.user) { + throw new Error("Invalid response from token exchange"); + } + + console.log("[Foundation Auth] Token exchange successful for user:", tokenData.user.id); + + // Clear auth parameters from URL and storage + const url = new URL(window.location.href); + url.searchParams.delete("code"); + url.searchParams.delete("state"); + window.history.replaceState({}, "", url.toString()); + + clearOAuthStorage(); + + // Show success message + aethexToast.success({ + title: "Authenticated", + description: `Welcome back, ${tokenData.user.username || tokenData.user.email}!`, + }); + + // Determine redirect destination + const storedRedirect = getStoredRedirectTo(); + const redirectTo = storedRedirect || "/dashboard"; + + // Redirect to dashboard or stored destination + navigate(redirectTo, { replace: true }); + } catch (exchangeError) { + const message = exchangeError instanceof Error + ? exchangeError.message + : "Failed to exchange authorization code"; + + console.error("[Foundation Auth] Token exchange failed:", exchangeError); + + throw new Error(message); } - - // Sync Foundation user profile to local database - await syncFoundationProfileToLocal(data.user); - - // Clear auth code from URL - const url = new URL(window.location.href); - url.searchParams.delete("code"); - url.searchParams.delete("state"); - window.history.replaceState({}, "", url.toString()); - - aethexToast.success({ - title: "Authenticated", - description: "Successfully authenticated with Foundation", - }); - - // Redirect to dashboard or stored redirect - const redirectTo = sessionStorage.getItem("auth_redirect_to") || "/dashboard"; - sessionStorage.removeItem("auth_redirect_to"); - navigate(redirectTo, { replace: true }); } catch (err) { const message = err instanceof Error ? err.message : "Authentication failed"; setError(message); + console.error("[Foundation Auth] Callback processing error:", err); + aethexToast.error({ title: "Authentication failed", description: message, }); - // Clear auth state on error - clearFoundationAuth(); + // Clear OAuth storage on error + clearOAuthStorage(); // Redirect back to login after a delay setTimeout(() => { @@ -111,7 +126,7 @@ export function useFoundationAuth(): UseFoundationAuthReturn { } }; - processFoundationAuth(); + processFoundationCallback(); }, [location.search, navigate]); return { @@ -121,28 +136,35 @@ export function useFoundationAuth(): UseFoundationAuthReturn { } /** - * Check if user has active Foundation authentication + * Hook to check current Foundation authentication status */ export function useFoundationAuthStatus(): { isAuthenticated: boolean; userId: string | null; - accessToken: string | null; } { const [status, setStatus] = useState({ isAuthenticated: false, userId: null as string | null, - accessToken: null as string | null, }); useEffect(() => { - const token = getFoundationAccessToken(); - const userId = getAuthUserId(); + // Check for foundation_access_token cookie + const cookies = document.cookie.split(";").map((c) => c.trim()); + const tokenCookie = cookies.find((c) => c.startsWith("foundation_access_token=")); + const userCookie = cookies.find((c) => c.startsWith("auth_user_id=")); - setStatus({ - isAuthenticated: !!token && !!userId, - userId, - accessToken: token, - }); + if (tokenCookie && userCookie) { + const userId = userCookie.split("=")[1]; + setStatus({ + isAuthenticated: true, + userId, + }); + } else { + setStatus({ + isAuthenticated: false, + userId: null, + }); + } }, []); return status;