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
This commit is contained in:
2026-01-16 11:35:21 +10:30
parent 7e92c7bea1
commit 71c58ddb60
6 changed files with 71 additions and 11 deletions
+6 -2
View File
@@ -24,25 +24,29 @@ export function TopBar() {
useEffect(() => { useEffect(() => {
async function fetchOrgs() { async function fetchOrgs() {
console.log('[TopBar] fetchOrgs called, isAuthenticated:', isAuthenticated);
if (!isAuthenticated) { if (!isAuthenticated) {
console.log('[TopBar] Not authenticated, skipping organizations fetch');
setOrgsLoading(false); setOrgsLoading(false);
return; return;
} }
try { try {
console.log('[TopBar] Making api.users.organizations() request');
const response = await api.users.organizations(); const response = await api.users.organizations();
console.log('[TopBar] Organizations fetched successfully:', response.organizations.length);
setOrganizations(response.organizations); setOrganizations(response.organizations);
if (response.organizations.length > 0 && !currentOrg) { if (response.organizations.length > 0 && !currentOrg) {
setCurrentOrg(response.organizations[0]); setCurrentOrg(response.organizations[0]);
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch organizations:", error); console.error("[TopBar] Failed to fetch organizations:", error);
} finally { } finally {
setOrgsLoading(false); setOrgsLoading(false);
} }
} }
fetchOrgs(); fetchOrgs();
}, [isAuthenticated]); }, [isAuthenticated, currentOrg]);
const handleLogout = () => { const handleLogout = () => {
navigate("/login"); navigate("/login");
+21 -1
View File
@@ -59,15 +59,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const login = useCallback(async (email: string, password: string, rememberMe = false): Promise<LoginResult> => { const login = useCallback(async (email: string, password: string, rememberMe = false): Promise<LoginResult> => {
console.log('[AuthContext] login() called');
const response = await api.auth.login(email, password, rememberMe); 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 TOTP is required, don't set user yet - wait for TOTP verification
if (response.requires_totp) { if (response.requires_totp) {
console.log('[AuthContext] TOTP required, returning early');
return { requiresTotp: true }; 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) { if (response.user) {
console.log('[AuthContext] Setting user state and navigating to /profile');
setUser(response.user); setUser(response.user);
navigate('/profile'); navigate('/profile');
} }
@@ -76,6 +89,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const verifyTotp = useCallback(async (code: string, isBackupCode = false) => { const verifyTotp = useCallback(async (code: string, isBackupCode = false) => {
const response = await api.totp.verify(code, isBackupCode); 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); setUser(response.user);
navigate('/profile'); navigate('/profile');
}, [navigate]); }, [navigate]);
+38 -7
View File
@@ -128,30 +128,42 @@ export const tokenManager = {
if (token && expiry) { if (token && expiry) {
const expiryDate = new Date(expiry); const expiryDate = new Date(expiry);
if (expiryDate <= new Date()) { if (expiryDate <= new Date()) {
console.log('[TokenManager] Token expired, clearing');
tokenManager.clearToken(); tokenManager.clearToken();
return null; return null;
} }
} }
if (token) {
console.log('[TokenManager] Token retrieved:', token.substring(0, 20) + '...');
} else {
console.log('[TokenManager] No token found in localStorage');
}
return token; return token;
}, },
setToken: (token: string, expiresAt?: string | null): void => { setToken: (token: string, expiresAt?: string | null): void => {
console.log('[TokenManager] Setting token, expiresAt:', expiresAt);
localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(TOKEN_KEY, token);
if (expiresAt) { if (expiresAt) {
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt); localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt);
} else { } else {
localStorage.removeItem(TOKEN_EXPIRY_KEY); localStorage.removeItem(TOKEN_EXPIRY_KEY);
} }
console.log('[TokenManager] Token set successfully');
}, },
clearToken: (): void => { clearToken: (): void => {
console.log('[TokenManager] Clearing token from localStorage');
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY); localStorage.removeItem(TOKEN_EXPIRY_KEY);
}, },
hasValidToken: (): boolean => { 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<T>(
const token = tokenManager.getToken(); const token = tokenManager.getToken();
if (token) { if (token) {
headers['Authorization'] = `Bearer ${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`, { const response = await fetch(`${config.api.baseUrl}/auth/webauthn/login/begin`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Required for session cookie
body: JSON.stringify({ email }), body: JSON.stringify({ email }),
}); });
if (!response.ok) { if (!response.ok) {
@@ -397,17 +413,32 @@ export const api = {
// Complete passkey login // Complete passkey login
completeLogin: async (assertion: Record<string, unknown>): Promise<WebAuthnLoginCompleteResponse> => { completeLogin: async (assertion: Record<string, unknown>): Promise<WebAuthnLoginCompleteResponse> => {
const response = await request<WebAuthnLoginCompleteResponse>('/auth/webauthn/login/complete', { const response = await fetch(`${config.api.baseUrl}/auth/webauthn/login/complete`, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Required for session cookie
body: JSON.stringify(assertion), body: JSON.stringify(assertion),
}, false); });
// Store token after successful passkey login const json: ApiResponse<WebAuthnLoginCompleteResponse> = await response.json();
if (response.token) {
tokenManager.setToken(response.token, response.expires_at ?? null); 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 // Rename a passkey
+1 -1
View File
@@ -129,7 +129,7 @@ export default function LoginPage() {
try { try {
// Step 1: Get login options from server // 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 // Step 2: Create assertion using browser WebAuthn API
const assertion = await createLoginAssertion(options); const assertion = await createLoginAssertion(options);
+4
View File
@@ -85,14 +85,18 @@ export default function ProfilePage() {
// Fetch organizations only when user is available // Fetch organizations only when user is available
useEffect(() => { useEffect(() => {
console.log('[ProfilePage] useEffect triggered, user:', user?.id);
if (!user) { if (!user) {
console.log('[ProfilePage] No user, skipping organizations fetch');
setOrgsLoading(false); setOrgsLoading(false);
return; return;
} }
const fetchOrgs = async () => { const fetchOrgs = async () => {
console.log('[ProfilePage] Making api.users.organizations() request');
try { try {
const response = await api.users.organizations(); const response = await api.users.organizations();
console.log('[ProfilePage] Organizations fetched successfully:', response.organizations.length);
setOrganizations(response.organizations); setOrganizations(response.organizations);
} catch (error) { } catch (error) {
if (error instanceof ApiError) { if (error instanceof ApiError) {
+1
View File
@@ -8,6 +8,7 @@ export default defineConfig(({ mode }) => ({
server: { server: {
host: "::", host: "::",
port: 8080, port: 8080,
allowedHosts: process.env.VITE_ALLOWED_HOSTS?.split(",") || ["ui.webauthn.local"],
}, },
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean), plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
resolve: { resolve: {