diff --git a/src/lib/utils/fetchWithTokenRefresh.ts b/src/lib/utils/fetchWithTokenRefresh.ts new file mode 100644 index 0000000000..77944df82f --- /dev/null +++ b/src/lib/utils/fetchWithTokenRefresh.ts @@ -0,0 +1,276 @@ +import { toast } from 'svelte-sonner'; +import { user } from '$lib/stores'; +import { userSignOut } from '$lib/apis/auths'; + +// Track redirecting state to prevent multiple redirects +let isRedirecting = false; + +// Allow custom handler to be set from layout.svelte +let sessionExpiryHandler: ((message?: string) => void) | null = null; + +/** + * Set the handler for session expiry + * @param handler The function to call when a session expiry is detected + */ +export function setSessionExpiryHandler(handler: (message?: string) => void) { + sessionExpiryHandler = handler; + console.info('[Cursor] Session expiry handler set'); +} + +/** + * Handle session expiry - either use the custom handler or fallback to forceLogoutAndRedirect + */ +function handleSessionExpiry(message?: string) { + if (sessionExpiryHandler) { + console.info('[Cursor] Using custom session expiry handler'); + sessionExpiryHandler(message); + } else { + console.info('[Cursor] No custom handler, using forceLogoutAndRedirect'); + forceLogoutAndRedirect(message); + } +} + +/** + * Set up the global fetch interceptor to catch 401 Unauthorized responses + * and redirect to login page when token expires + */ +export function setupFetchInterceptor() { + // Store the original fetch function + const originalFetch = window.fetch; + + // Get Microsoft Proxy domain if present + const msProxyDomain = window.location.hostname.indexOf('msappproxy.net') >= 0 ? + window.location.hostname : null; + + // Replace the global fetch with our interceptor + window.fetch = async function(input, init) { + // Modify request to prevent automatic redirect following + // This allows us to detect 302 redirects from Microsoft Entra + const modifiedInit = { + ...init, + // Don't follow redirects automatically so we can detect them + redirect: 'manual' as RequestRedirect + }; + + try { + // Call the original fetch function with modified init + const response = await originalFetch.apply(this, [input, modifiedInit]); + + // Get the request URL for logging + const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : 'unknown'; + + // Clone the response so we can check it without disturbing the original + const responseClone = response.clone(); + + console.info('setupFetchInterceptor responseClone', { + status: responseClone.status, + type: responseClone.type, + url: responseClone.url, + ok: responseClone.ok, + requestUrl + }); + + // Check if the URL itself looks like an auth endpoint + // This helps catch cases where the URL has already changed to an auth endpoint + if (responseClone.url && ( + responseClone.url.indexOf('/oauth2/') >= 0 || + responseClone.url.indexOf('/auth/') >= 0 || + responseClone.url.indexOf('login.microsoftonline.com') >= 0 || + responseClone.url.indexOf('login.microsoft.com') >= 0 + )) { + console.info('AUTH URL DETECTED IN RESPONSE - FORCING LOGOUT', { + requestUrl, + responseUrl: responseClone.url + }); + handleSessionExpiry('Your session has expired'); + } + + // Check status without waiting for json parsing + if (responseClone.status === 401) { + console.info('401 UNAUTHORIZED DETECTED - FORCING LOGOUT', { + url: typeof input === 'string' ? input : input instanceof URL ? input.toString() : 'unknown' + }); + + // Force logout and redirect immediately + handleSessionExpiry('Your session has expired'); + } + + // Check for 302 redirects from proxy - now we can catch these! + // 'opaqueredirect' is the response type for cross-origin redirects when using manual redirect mode + if (responseClone.status === 302 || responseClone.status === 307 || responseClone.type === 'opaqueredirect') { + const location = responseClone.headers.get('location'); + + console.info('REDIRECT DETECTED', { + requestUrl, + responseType: responseClone.type, + status: responseClone.status, + location: location || 'No location header (opaque redirect)' + }); + + // For opaque redirects, we won't have a location header to check + // So we need to make an educated guess based on circumstances + if (responseClone.type === 'opaqueredirect') { + // If we're on Microsoft Proxy, it's very likely this is an auth redirect + if (msProxyDomain) { + console.info('OPAQUE REDIRECT ON MICROSOFT PROXY - FORCING LOGOUT'); + handleSessionExpiry('Your proxy session has expired'); + // Still return response to allow normal error handling + } + } + // For normal redirects, we can check the location + else if (location && + (location.indexOf('login.microsoftonline.com') >= 0 || + location.indexOf('login.microsoft.com') >= 0)) { + console.info('MICROSOFT ENTRA REDIRECT DETECTED - FORCING LOGOUT'); + handleSessionExpiry('Your proxy session has expired'); + } + } + + // Return the original response to preserve normal fetch behavior + // The API methods will still get their original response + return response; + } catch (error) { + // Check if this is a CORS error from a redirect to Microsoft login + const err = error as Error & { status?: number; redirectUrl?: string; detail?: string }; + + // Log the error for debugging + console.info('FETCH ERROR:', err); + + // Some endpoints return serialized errors with redirectUrl that point to auth endpoints + if (err && typeof err === 'object') { + // Check common auth-related fields in error responses + if ( + (err.redirectUrl && ( + err.redirectUrl.indexOf('login.microsoft') >= 0 || + err.redirectUrl.indexOf('/oauth2/') >= 0 + )) || + (err.message && ( + err.message.indexOf('authentication') >= 0 || + err.message.indexOf('unauthorized') >= 0 || + err.message.indexOf('login') >= 0 + )) + ) { + console.info('AUTH-RELATED ERROR FIELDS DETECTED - FORCING LOGOUT', err); + handleSessionExpiry('Your session has expired'); + } + } + + // Check if this is an auth-related error + if (err?.status === 401 || (typeof err?.message === 'string' && err.message.indexOf('unauthorized') >= 0)) { + console.info('AUTH ERROR DETECTED - FORCING LOGOUT', err); + handleSessionExpiry(); + } + + // Rethrow the error to preserve original behavior + throw error; + } + }; + + // Also set up a global AJAX error handler for APIs that might not use fetch + window.addEventListener('unhandledrejection', function(event) { + const reason = event?.reason as Error & { + status?: number; + detail?: string; + message?: string; + }; + + // Look for authentication errors in unhandled promise rejections + if (reason?.status === 401 || + (reason?.detail && typeof reason.detail === 'string' && + (reason.detail.indexOf('not authenticated') >= 0 || reason.detail.indexOf('token') >= 0 || reason.detail.indexOf('auth') >= 0))) { + console.info('AUTH ERROR IN UNHANDLED REJECTION - FORCING LOGOUT', reason); + handleSessionExpiry(); + return; + } + + // Check for CORS errors which might indicate Microsoft redirect + if (reason instanceof TypeError && + reason.message && + (reason.message.indexOf('CORS') >= 0 || reason.message.indexOf('Failed to fetch') >= 0)) { + console.info('CORS ERROR IN UNHANDLED REJECTION - POSSIBLY MS REDIRECT', reason); + + // If we're on Microsoft proxy domain, assume this is an auth error + if (window.location.hostname.indexOf('msappproxy.net') >= 0) { + handleSessionExpiry('Your proxy session has expired'); + } + } + + // Also check for specific error messages in the reason object at any level + const reasonStr = JSON.stringify(reason); + if (reasonStr.indexOf('unauthorized') >= 0 || reasonStr.indexOf('authentication') >= 0 || reasonStr.indexOf('not logged in') >= 0) { + console.info('AUTH-RELATED TEXT IN UNHANDLED REJECTION - FORCING LOGOUT', reason); + handleSessionExpiry(); + } + }); + + console.info('Token expiration interceptor installed successfully'); +} + +/** + * Forces a logout and redirects the user to the login page. + * This is used when a token is detected as expired. + * Also calls userSignOut to properly expire the JWT token on the backend + * and get the correct redirect URL. + * + * @param customMessage Optional custom message to display during logout + */ +export async function forceLogoutAndRedirect(customMessage?: string) { + // Prevent multiple redirects + if (isRedirecting) return; + isRedirecting = true; + + console.info('🔐 SESSION EXPIRED! FORCING LOGOUT AND PAGE RELOAD 🔐'); + + try { + // Clear user data locally + user.set(undefined); + + // Remove token from localStorage + localStorage.removeItem('token'); + + // Try to call userSignOut to properly expire the server-side session + // and get the redirect URL from the backend + let redirectUrl: string | undefined; + try { + const res = await userSignOut(); + // Handle the custom response type which includes redirect_url + const customRes = res as unknown as { redirect_url?: string }; + if (customRes?.redirect_url) { + redirectUrl = customRes.redirect_url; + } + } catch (error) { + console.error('Error signing out from server:', error); + // Continue with client-side logout even if server logout fails + } + + // Show notification + toast.error(customMessage || 'Your session has expired. Logging you out...', { + duration: 3000 + }); + + // Use redirect URL from server if available, otherwise default to auth page + if (redirectUrl) { + window.location.href = redirectUrl; + } else { + // Force navigation to login page with hard reload + // const currentUrl = encodeURIComponent(window.location.pathname + window.location.search); + // window.location.href = `/auth?redirect=${currentUrl}&expired=true&t=${Date.now()}`; + window.location.reload(); + } + + // As a backup, force reload after a short delay if redirect doesn't happen + setTimeout(() => { + window.location.reload(); + }, 1000); + + } catch (e) { + console.error('Error during forced logout:', e); + // Last resort: force reload + window.location.reload(); + } finally { + // Reset redirecting flag after a delay + setTimeout(() => { + isRedirecting = false; + }, 5000); + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c4a8b6c5b2..6aabc88a35 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,12 +3,17 @@ import { spring } from 'svelte/motion'; import PyodideWorker from '$lib/workers/pyodide.worker?worker'; import { Toaster, toast } from 'svelte-sonner'; + import { + setupFetchInterceptor, + forceLogoutAndRedirect, + setSessionExpiryHandler + } from '$lib/utils/fetchWithTokenRefresh'; let loadingProgress = spring(0, { stiffness: 0.05 }); - import { onMount, tick, setContext, onDestroy } from 'svelte'; + import { onMount, tick, setContext } from 'svelte'; import { config, user, @@ -73,6 +78,8 @@ return false; }; + import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; + // handle frontend updates (https://svelte.dev/docs/kit/configuration#version) beforeNavigate(async ({ willUnload, to }) => { if (updated.current && !willUnload && to?.url) { @@ -84,6 +91,7 @@ setContext('i18n', i18n); const bc = new BroadcastChannel('active-tab-channel'); + const sessionBC = new BroadcastChannel('session-refresh-channel'); let loaded = false; let tokenTimer = null; @@ -94,6 +102,8 @@ const BREAKPOINT = 768; + let showReauthDialog = false; + const setupSocket = async (enableWebsocket) => { const _socket = io(`${WEBUI_BASE_URL}` || undefined, { reconnection: true, @@ -578,15 +588,58 @@ return; } - if (now >= exp - TOKEN_EXPIRY_BUFFER) { - const res = await userSignOut(); - user.set(null); - localStorage.removeItem('token'); - - location.href = res?.redirect_url ?? '/auth'; + // Check if token is expiring soon with the fixed 30-second buffer + if (now >= (exp - TOKEN_EXPIRY_BUFFER)) { + handleSessionExpiryWithCoordination(); } }; + // Add a function to refresh the user session without reloading + async function refreshSessionUser() { + console.info('[Cursor] Refreshing session user data without reload'); + if (localStorage.token) { + try { + const sessionUser = await getSessionUser(localStorage.token); + if (sessionUser) { + console.info('[Cursor] Got valid session user:', sessionUser.username || sessionUser.email); + await user.set(sessionUser); + console.info('[Cursor] Updated user store'); + await config.set(await getBackendConfig()); + + // Re-establish socket connection with new token + if ($socket) { + console.info('[Cursor] Emitting user-join with new token'); + $socket.emit('user-join', { auth: { token: sessionUser.token } }); + } + + console.info('[Cursor] Session refreshed successfully'); + // Show a toast notification that the session was refreshed + toast.success('Your session has been refreshed', { + duration: 3000 + }); + + // Hide the reauth dialog if it's showing + showReauthDialog = false; + + return true; + } else { + console.warn('[Cursor] Got null session user'); + } + } catch (error) { + console.error('[Cursor] Failed to refresh session user:', error); + } + } else { + console.warn('[Cursor] No token available for refresh'); + } + return false; + } + + // Update the forceLogoutAndRedirect function to broadcast when re-authentication is complete + function handleSessionExpiryWithCoordination() { + console.info('[Cursor] Session expiry detected, showing dialog'); + showReauthDialog = true; + } + onMount(async () => { let touchstartY = 0; @@ -617,6 +670,12 @@ location.reload(); } }); + // Set up the session expiry handler first, before setting up the fetch interceptor + console.info('[Cursor] Setting up session expiry handler...'); + setSessionExpiryHandler(handleSessionExpiryWithCoordination); + + console.info('[Cursor] Setting up fetch interceptor...'); + setupFetchInterceptor(); if (typeof window !== 'undefined') { if (window.applyTheme) { @@ -744,17 +803,25 @@ if (localStorage.token) { // Get Session User Info const sessionUser = await getSessionUser(localStorage.token).catch((error) => { - toast.error(`${error}`); + console.error('Session user fetch failed:', error); + toast.error(`Authentication error: ${error}`); + + // Clear token on auth error + localStorage.removeItem('token'); return null; }); if (sessionUser) { await user.set(sessionUser); await config.set(await getBackendConfig()); + + // Run check immediately + checkTokenExpiry(); } else { // Redirect Invalid Session User to /auth Page localStorage.removeItem('token'); - await goto(`/auth?redirect=${encodedUrl}`); + // Use window.location for a complete page reload + window.location.href = `/?redirect=${encodedUrl}&invalid=true`; } } else { // Don't redirect if we're already on the auth page @@ -801,6 +868,43 @@ loaded = true; } + // Listen for session refresh broadcasts from other tabs + sessionBC.onmessage = async (event) => { + if (event.data === 'session-refreshed') { + await refreshSessionUser(); + } + }; + + // Update the storage event listener to handle token changes + window.addEventListener('storage', async function(event) { + if (event.key === 'token') { + if (event.newValue === null) { + // Token was removed in another tab + console.log('[Cursor] TOKEN REMOVED IN ANOTHER TAB'); + // Instead of reloading, show the reauth dialog + handleSessionExpiryWithCoordination(); + } else if (event.oldValue === null && event.newValue) { + // Token was added in another tab (user logged in again) + console.log('[Cursor] TOKEN ADDED IN ANOTHER TAB - REFRESHING SESSION'); + + // Broadcast to other tabs that they should refresh too + // This helps ensure all tabs get the message + const sessionBC = new BroadcastChannel('session-refresh-channel'); + sessionBC.postMessage('session-refreshed'); + sessionBC.close(); + } else if (event.oldValue !== event.newValue) { + // Token was changed in another tab + console.log('[Cursor] TOKEN CHANGED IN ANOTHER TAB - REFRESHING SESSION'); + + // Broadcast to other tabs that they should refresh too + // This helps ensure all tabs get the message + const sessionBC = new BroadcastChannel('session-refresh-channel'); + sessionBC.postMessage('session-refreshed'); + sessionBC.close(); + } + } + }); + return () => { window.removeEventListener('resize', onResize); }; @@ -854,3 +958,16 @@ position="top-right" closeButton /> + + (showReauthDialog = false)} + on:confirm={() => { + forceLogoutAndRedirect('Reloading for re-authentication...'); + showReauthDialog = false; + }} +/>