From 888645f28cc6382f0d8acaa0b3a240f13bc00b36 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 14:20:32 +0800 Subject: [PATCH] fix(web): recover session when tokenMeta expires before refresh token checkAuth() bailed whenever the 24h tokenMeta cookie was absent, even though the 30d refresh cookie (path=/api/auth) was still valid. Users who closed the tab for more than 24 hours saw a login screen despite having a good refresh token. Now tries /api/auth/refresh first when tokenMeta is missing, and again on a 401 from /me, before concluding the user is logged out. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/contexts/AuthContext.tsx | 48 +++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/apps/web/contexts/AuthContext.tsx b/apps/web/contexts/AuthContext.tsx index f8789a0..89f91fc 100644 --- a/apps/web/contexts/AuthContext.tsx +++ b/apps/web/contexts/AuthContext.tsx @@ -99,20 +99,52 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, refreshIn); }, [apiUrl]); + /** + * Attempt to refresh the access token using the httpOnly refresh cookie. + * Returns true on success. The refresh cookie lives 30d (path=/api/auth), + * so this works even after tokenMeta (24h) has expired. + */ + const tryRefresh = useCallback(async (): Promise => { + try { + const res = await fetch(`${apiUrl}/api/auth/refresh`, { + method: 'POST', + credentials: 'include', + }); + return res.ok; + } catch (error) { + console.error('[Auth] Refresh attempt failed:', error); + return false; + } + }, [apiUrl]); + // Check if user is authenticated const checkAuth = useCallback(async () => { - const tokenMeta = getTokenMetadata(); - - if (!tokenMeta) { - setLoading(false); - return; + // If tokenMeta is missing (access token expired while tab was closed), + // the refresh cookie may still be valid — try refresh before giving up. + if (!getTokenMetadata()) { + const refreshed = await tryRefresh(); + if (!refreshed) { + setLoading(false); + return; + } } try { - const res = await fetch(`${apiUrl}/api/auth/me`, { - credentials: 'include', // Send httpOnly cookies + let res = await fetch(`${apiUrl}/api/auth/me`, { + credentials: 'include', }); + // If /me still 401s (e.g., access token expired between checks), + // try one refresh + retry before declaring the user logged out. + if (res.status === 401) { + const refreshed = await tryRefresh(); + if (refreshed) { + res = await fetch(`${apiUrl}/api/auth/me`, { + credentials: 'include', + }); + } + } + if (res.ok) { const userData = await res.json(); Cookies.set('session', 'active', { path: '/', expires: 1 }); @@ -129,7 +161,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } finally { setLoading(false); } - }, [apiUrl, scheduleTokenRefresh]); + }, [apiUrl, scheduleTokenRefresh, tryRefresh]); // Load user on mount useEffect(() => {