diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 046370798b..0a3ca9a249 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -22,6 +22,13 @@ const oauthHandler = async (req, res) => { return; } await setAuthTokens(req.user._id, res); + + // On successful login, let's clear any openid redirect flags + res.cookie('successful_login', 'true', { + maxAge: 1000, // very short-lived, just for client-side detection + httpOnly: false // client needs to read this + }); + res.redirect(domains.client); } catch (err) { logger.error('Error in setting authentication tokens:', err); @@ -31,7 +38,9 @@ const oauthHandler = async (req, res) => { router.get('/error', (req, res) => { // A single error message is pushed by passport when authentication fails. logger.error('Error in OAuth authentication:', { message: req.session.messages.pop() }); - res.redirect(`${domains.client}/login`); + + // Redirect to login page with auth_failed parameter to prevent infinite redirect loops + res.redirect(`${domains.client}/login?auth_failed=true`); }); /** diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index db68bd7d42..311f40a9e2 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'; import { useAuthContext } from '~/hooks/AuthContext'; import type { TLoginLayoutContext } from '~/common'; import { ErrorMessage } from '~/components/Auth/ErrorMessage'; -import { getLoginError } from '~/utils'; +import { getLoginError, shouldRedirectToOpenID, clearOpenIDRedirectFlag, getCookie } from '~/utils'; import { useLocalize } from '~/hooks'; import LoginForm from './LoginForm'; @@ -18,22 +18,40 @@ function Login() { // When enabled, users will be automatically redirected to the OpenID provider // without seeing the login form at all useEffect(() => { - // Simple check if redirect is needed and not yet attempted + // Check for URL parameters that indicate a failed auth attempt + const urlParams = new URLSearchParams(window.location.search); + const authFailed = urlParams.get('auth_failed') === 'true'; + + // Use the utility function to determine if we should redirect if ( - !redirectAttemptedRef.current && - startupConfig?.openidLoginEnabled && - startupConfig?.openidAutoRedirect && - startupConfig?.serverDomain + shouldRedirectToOpenID({ + redirectAttempted: redirectAttemptedRef.current, + openidLoginEnabled: startupConfig?.openidLoginEnabled, + openidAutoRedirect: startupConfig?.openidAutoRedirect, + serverDomain: startupConfig?.serverDomain, + authFailed + }) ) { - // Mark that we've attempted to redirect + // Mark that we've attempted to redirect in this component instance redirectAttemptedRef.current = true; // Log and redirect console.log('Auto-redirecting to OpenID provider...'); - window.location.href = `${startupConfig.serverDomain}/oauth/openid`; + window.location.href = `${startupConfig?.serverDomain}/oauth/openid`; } }, [startupConfig]); + // Clear the redirect flag after successful login (when the cookie is present) + useEffect(() => { + const successfulLogin = getCookie('successful_login'); + if (successfulLogin) { + // Clear the redirect flag in localStorage + clearOpenIDRedirectFlag(); + + // Clear the cookie since we've processed it + document.cookie = 'successful_login=; Max-Age=0; path=/;'; + } + }, []); return ( <> diff --git a/client/src/utils/localStorage.ts b/client/src/utils/localStorage.ts index 3c44551b21..6975d2a9d6 100644 --- a/client/src/utils/localStorage.ts +++ b/client/src/utils/localStorage.ts @@ -1,5 +1,10 @@ import { LocalStorageKeys, TConversation } from 'librechat-data-provider'; +// Key for tracking OpenID redirect attempts +export const OPENID_REDIRECT_KEY = 'openid_redirect_attempted'; +// Cooldown period in milliseconds (5 minutes) +export const OPENID_REDIRECT_COOLDOWN = 5 * 60 * 1000; + export function getLocalStorageItems() { const items = { lastSelectedModel: localStorage.getItem(LocalStorageKeys.LAST_MODEL) ?? '', @@ -24,6 +29,69 @@ export function getLocalStorageItems() { }; } +/** + * Handles the OpenID redirect logic to prevent infinite redirect loops + * @param conditions Object containing conditions that must be met for redirect + * @returns Boolean indicating whether to proceed with the redirect + */ +export function shouldRedirectToOpenID({ + redirectAttempted, + openidLoginEnabled, + openidAutoRedirect, + serverDomain, + authFailed = false, +}: { + redirectAttempted: boolean; + openidLoginEnabled?: boolean; + openidAutoRedirect?: boolean; + serverDomain?: string; + authFailed?: boolean; +}): boolean { + // Get timestamp of last redirect attempt from localStorage + const lastRedirectAttempt = localStorage.getItem(OPENID_REDIRECT_KEY); + const currentTime = Date.now(); + + // Only redirect if all conditions are met + if ( + !redirectAttempted && + openidLoginEnabled && + openidAutoRedirect && + serverDomain && + !authFailed && + (!lastRedirectAttempt || currentTime - parseInt(lastRedirectAttempt, 10) > OPENID_REDIRECT_COOLDOWN) + ) { + // Store the current timestamp in localStorage + localStorage.setItem(OPENID_REDIRECT_KEY, currentTime.toString()); + return true; + } + + return false; +} + +/** + * Clears the OpenID redirect tracking flag + */ +export function clearOpenIDRedirectFlag(): void { + localStorage.removeItem(OPENID_REDIRECT_KEY); +} + +/** + * Gets a cookie value by name + * @param name The name of the cookie + * @returns The cookie value or null if not found + */ +export function getCookie(name: string): string | null { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) { + const part = parts.pop(); + if (part) { + return part.split(';').shift() || null; + } + } + return null; +} + export function clearLocalStorage(skipFirst?: boolean) { const keys = Object.keys(localStorage); keys.forEach((key) => {