feat: add popup for re-authentication on Entra proxy timeout

This commit is contained in:
LoiTra 2025-07-15 11:44:12 +07:00 committed by loitragg
parent 2f04cc8a64
commit f440e9bfbe
No known key found for this signature in database
GPG key ID: 96292BAF3E28CFF5
2 changed files with 402 additions and 9 deletions

View file

@ -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);
}
}

View file

@ -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
/>
<ConfirmDialog
title="Session Expired"
message="Your session has expired. Click 'Re-authenticate' to reload the page and sign in again, or click 'Cancel' to close this dialog and save your unsaved work before manually reloading."
show={showReauthDialog}
cancelLabel="Cancel"
confirmLabel="Re-authenticate"
on:cancel={() => (showReauthDialog = false)}
on:confirm={() => {
forceLogoutAndRedirect('Reloading for re-authentication...');
showReauthDialog = false;
}}
/>