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:
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user