mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-13 04:45:19 +00:00
feat: add popup for re-authentication on Entra proxy timeout
This commit is contained in:
parent
2f04cc8a64
commit
f440e9bfbe
2 changed files with 402 additions and 9 deletions
276
src/lib/utils/fetchWithTokenRefresh.ts
Normal file
276
src/lib/utils/fetchWithTokenRefresh.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue