From 71c58ddb60768df87a79e90c4bd58f92658f9439 Mon Sep 17 00:00:00 2001 From: Cory Hawklvelt Date: Fri, 16 Jan 2026 11:35:21 +1030 Subject: [PATCH] fix(auth): ensure token storage before user state updates - Store authentication tokens explicitly before setting user state in login and TOTP verification flows to prevent race conditions - Add 'credentials: include' to WebAuthn endpoints for proper session cookie handling - Add comprehensive debug logging throughout authentication flow to trace token lifecycle and API requests - Update WebAuthn completeLogin to use fetch directly instead of request helper to properly handle session cookies - Add allowedHosts configuration to Vite dev server --- src/components/navigation/TopBar.tsx | 8 +++-- src/contexts/AuthContext.tsx | 22 +++++++++++++- src/lib/api.ts | 45 +++++++++++++++++++++++----- src/pages/auth/LoginPage.tsx | 2 +- src/pages/user/ProfilePage.tsx | 4 +++ vite.config.ts | 1 + 6 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/components/navigation/TopBar.tsx b/src/components/navigation/TopBar.tsx index ff69505..b83ada3 100644 --- a/src/components/navigation/TopBar.tsx +++ b/src/components/navigation/TopBar.tsx @@ -24,25 +24,29 @@ export function TopBar() { useEffect(() => { async function fetchOrgs() { + console.log('[TopBar] fetchOrgs called, isAuthenticated:', isAuthenticated); if (!isAuthenticated) { + console.log('[TopBar] Not authenticated, skipping organizations fetch'); setOrgsLoading(false); return; } try { + console.log('[TopBar] Making api.users.organizations() request'); const response = await api.users.organizations(); + console.log('[TopBar] Organizations fetched successfully:', response.organizations.length); setOrganizations(response.organizations); if (response.organizations.length > 0 && !currentOrg) { setCurrentOrg(response.organizations[0]); } } catch (error) { - console.error("Failed to fetch organizations:", error); + console.error("[TopBar] Failed to fetch organizations:", error); } finally { setOrgsLoading(false); } } fetchOrgs(); - }, [isAuthenticated]); + }, [isAuthenticated, currentOrg]); const handleLogout = () => { navigate("/login"); diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 4611838..106155d 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -59,15 +59,28 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const login = useCallback(async (email: string, password: string, rememberMe = false): Promise => { + console.log('[AuthContext] login() called'); const response = await api.auth.login(email, password, rememberMe); + console.log('[AuthContext] login response:', { requires_totp: response.requires_totp, hasToken: !!response.token, hasUser: !!response.user }); // If TOTP is required, don't set user yet - wait for TOTP verification if (response.requires_totp) { + console.log('[AuthContext] TOTP required, returning early'); return { requiresTotp: true }; } - // Login complete, set user and navigate + // Login complete: store token explicitly before setting user state + // This ensures the token is available for any subsequent API calls + // (e.g., when navigate('/profile') triggers refreshUser()) + if (response.token) { + console.log('[AuthContext] Storing token in localStorage'); + tokenManager.setToken(response.token, response.expires_at ?? null); + console.log('[AuthContext] Token stored, verifying:', tokenManager.getToken()?.substring(0, 20) + '...'); + } + + // Set user and navigate if (response.user) { + console.log('[AuthContext] Setting user state and navigating to /profile'); setUser(response.user); navigate('/profile'); } @@ -76,6 +89,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { const verifyTotp = useCallback(async (code: string, isBackupCode = false) => { const response = await api.totp.verify(code, isBackupCode); + + // Store token explicitly before setting user state + // This ensures the token is available for any subsequent API calls + if (response.token) { + tokenManager.setToken(response.token, response.expires_at ?? null); + } + setUser(response.user); navigate('/profile'); }, [navigate]); diff --git a/src/lib/api.ts b/src/lib/api.ts index dd14f5a..47dbcdb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -128,30 +128,42 @@ export const tokenManager = { if (token && expiry) { const expiryDate = new Date(expiry); if (expiryDate <= new Date()) { + console.log('[TokenManager] Token expired, clearing'); tokenManager.clearToken(); return null; } } + if (token) { + console.log('[TokenManager] Token retrieved:', token.substring(0, 20) + '...'); + } else { + console.log('[TokenManager] No token found in localStorage'); + } + return token; }, setToken: (token: string, expiresAt?: string | null): void => { + console.log('[TokenManager] Setting token, expiresAt:', expiresAt); localStorage.setItem(TOKEN_KEY, token); if (expiresAt) { localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt); } else { localStorage.removeItem(TOKEN_EXPIRY_KEY); } + console.log('[TokenManager] Token set successfully'); }, clearToken: (): void => { + console.log('[TokenManager] Clearing token from localStorage'); localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_EXPIRY_KEY); }, hasValidToken: (): boolean => { - return tokenManager.getToken() !== null; + const hasToken = tokenManager.getToken() !== null; + console.log('[TokenManager] hasValidToken:', hasToken); + return hasToken; }, }; @@ -193,6 +205,9 @@ async function request( const token = tokenManager.getToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; + console.log('[API] Added Authorization header for endpoint:', endpoint); + } else { + console.log('[API] WARNING: No token available for authenticated endpoint:', endpoint); } } @@ -380,6 +395,7 @@ export const api = { const response = await fetch(`${config.api.baseUrl}/auth/webauthn/login/begin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'include', // Required for session cookie body: JSON.stringify({ email }), }); if (!response.ok) { @@ -397,17 +413,32 @@ export const api = { // Complete passkey login completeLogin: async (assertion: Record): Promise => { - const response = await request('/auth/webauthn/login/complete', { + const response = await fetch(`${config.api.baseUrl}/auth/webauthn/login/complete`, { method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // Required for session cookie body: JSON.stringify(assertion), - }, false); + }); - // Store token after successful passkey login - if (response.token) { - tokenManager.setToken(response.token, response.expires_at ?? null); + const json: ApiResponse = await response.json(); + + if (!json.success) { + throw new ApiError( + json.message || 'Authentication failed', + json.code, + json.error?.type || 'AUTHENTICATION_ERROR', + json.error?.details || {} + ); } - return response; + // Store token after successful passkey login + if (json.data?.token) { + tokenManager.setToken(json.data.token, json.data.expires_at ?? null); + } + + return json.data as WebAuthnLoginCompleteResponse; }, // Rename a passkey diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 57071d1..a0f0b4a 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -129,7 +129,7 @@ export default function LoginPage() { try { // Step 1: Get login options from server - const options = await api.webauthn.beginLogin(emailToUse) as unknown as WebAuthnLoginOptions; + const options = await api.webauthn.beginLogin(emailToUse) as WebAuthnLoginOptions; // Step 2: Create assertion using browser WebAuthn API const assertion = await createLoginAssertion(options); diff --git a/src/pages/user/ProfilePage.tsx b/src/pages/user/ProfilePage.tsx index 19c1333..9860777 100644 --- a/src/pages/user/ProfilePage.tsx +++ b/src/pages/user/ProfilePage.tsx @@ -85,14 +85,18 @@ export default function ProfilePage() { // Fetch organizations only when user is available useEffect(() => { + console.log('[ProfilePage] useEffect triggered, user:', user?.id); if (!user) { + console.log('[ProfilePage] No user, skipping organizations fetch'); setOrgsLoading(false); return; } const fetchOrgs = async () => { + console.log('[ProfilePage] Making api.users.organizations() request'); try { const response = await api.users.organizations(); + console.log('[ProfilePage] Organizations fetched successfully:', response.organizations.length); setOrganizations(response.organizations); } catch (error) { if (error instanceof ApiError) { diff --git a/vite.config.ts b/vite.config.ts index da25c6d..9c261c7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig(({ mode }) => ({ server: { host: "::", port: 8080, + allowedHosts: process.env.VITE_ALLOWED_HOSTS?.split(",") || ["ui.webauthn.local"], }, plugins: [react(), mode === "development" && componentTagger()].filter(Boolean), resolve: {