From 17b0f35f934654c3dedaabaef6916c8d72d099ef Mon Sep 17 00:00:00 2001 From: Ruben Talstra Date: Mon, 10 Mar 2025 11:52:36 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20feat:=20Implement=20custom=20log?= =?UTF-8?q?out=20redirect=20handling=20and=20enhance=20OpenID=20auto-redir?= =?UTF-8?q?ect=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/routes/oauth.js | 11 +--- client/src/common/types.ts | 2 +- client/src/components/Auth/Login.tsx | 92 +++++++++++++++++----------- client/src/hooks/AuthContext.tsx | 29 ++++++--- client/src/routes/Root.tsx | 7 +-- client/src/utils/localStorage.ts | 68 -------------------- 6 files changed, 82 insertions(+), 127 deletions(-) diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 0a3ca9a249..9006b25c5b 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -22,13 +22,6 @@ 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); @@ -38,9 +31,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() }); - + // Redirect to login page with auth_failed parameter to prevent infinite redirect loops - res.redirect(`${domains.client}/login?auth_failed=true`); + res.redirect(`${domains.client}/login?redirect=false`); }); /** diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 380ec573b8..7b332503d5 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -399,7 +399,7 @@ export type TAuthContext = { isAuthenticated: boolean; error: string | undefined; login: (data: t.TLoginUser) => void; - logout: () => void; + logout: (redirect?: string) => void; setError: React.Dispatch>; roles?: Record; }; diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index 311f40a9e2..cbf0a8df96 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -1,57 +1,75 @@ -import { useOutletContext } from 'react-router-dom'; -import { useEffect, useRef } from 'react'; +import { useOutletContext, useSearchParams } from 'react-router-dom'; +import { useEffect, useState } from 'react'; import { useAuthContext } from '~/hooks/AuthContext'; import type { TLoginLayoutContext } from '~/common'; import { ErrorMessage } from '~/components/Auth/ErrorMessage'; -import { getLoginError, shouldRedirectToOpenID, clearOpenIDRedirectFlag, getCookie } from '~/utils'; +import { getLoginError } from '~/utils'; import { useLocalize } from '~/hooks'; import LoginForm from './LoginForm'; +import SocialButton from '~/components/Auth/SocialButton'; +import { OpenIDIcon } from '~/components'; function Login() { const localize = useLocalize(); const { error, setError, login } = useAuthContext(); const { startupConfig } = useOutletContext(); - const redirectAttemptedRef = useRef(false); - // Auto-redirect to OpenID provider if enabled - // This is controlled by the OPENID_AUTO_REDIRECT environment variable - // When enabled, users will be automatically redirected to the OpenID provider - // without seeing the login form at all + const [searchParams, setSearchParams] = useSearchParams(); + // Determine if auto-redirect should be disabled based on the URL parameter + const disableAutoRedirect = searchParams.get('redirect') === 'false'; + + // Persist the disable flag locally so that once detected, auto-redirect stays disabled. + const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect); + + // Once the disable flag is detected, update local state and remove the parameter from the URL. useEffect(() => { - // Check for URL parameters that indicate a failed auth attempt - const urlParams = new URLSearchParams(window.location.search); - const authFailed = urlParams.get('auth_failed') === 'true'; + if (disableAutoRedirect) { + setIsAutoRedirectDisabled(true); + const newParams = new URLSearchParams(searchParams); + newParams.delete('redirect'); + setSearchParams(newParams, { replace: true }); + } + }, [disableAutoRedirect, searchParams, setSearchParams]); - // Use the utility function to determine if we should redirect - if ( - shouldRedirectToOpenID({ - redirectAttempted: redirectAttemptedRef.current, - openidLoginEnabled: startupConfig?.openidLoginEnabled, - openidAutoRedirect: startupConfig?.openidAutoRedirect, - serverDomain: startupConfig?.serverDomain, - authFailed - }) - ) { - // Mark that we've attempted to redirect in this component instance - redirectAttemptedRef.current = true; + // Determine whether we should auto-redirect to OpenID. + const shouldAutoRedirect = + startupConfig?.openidLoginEnabled && + startupConfig?.openidAutoRedirect && + startupConfig?.serverDomain && + !isAutoRedirectDisabled; - // Log and redirect + useEffect(() => { + if (shouldAutoRedirect) { console.log('Auto-redirecting to OpenID provider...'); - window.location.href = `${startupConfig?.serverDomain}/oauth/openid`; + window.location.href = `${startupConfig.serverDomain}/oauth/openid`; } - }, [startupConfig]); + }, [shouldAutoRedirect, 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=/;'; - } - }, []); + // Render fallback UI if auto-redirect is active. + if (shouldAutoRedirect) { + return ( +
+

Redirecting to OpenID provider, please wait...

+
+ + startupConfig.openidImageUrl ? ( + OpenID Logo + ) : ( + + ) + } + label={startupConfig.openidLabel} + id="openid" + /> +
+
+ ); + } return ( <> diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index 2828a1bc5b..e21d19ebf1 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -6,6 +6,7 @@ import { useContext, useCallback, createContext, + useRef, } from 'react'; import { useNavigate } from 'react-router-dom'; import { useRecoilState } from 'recoil'; @@ -35,6 +36,8 @@ const AuthContextProvider = ({ const [token, setToken] = useState(undefined); const [error, setError] = useState(undefined); const [isAuthenticated, setIsAuthenticated] = useState(false); + const logoutRedirectRef = useRef(undefined); + const { data: userRole = null } = useGetRole(SystemRoles.USER, { enabled: !!(isAuthenticated && (user?.role ?? '')), }); @@ -52,16 +55,17 @@ const AuthContextProvider = ({ //@ts-ignore - ok for token to be undefined initially setTokenHeader(token); setIsAuthenticated(isAuthenticated); - if (redirect == null) { + // Use a custom redirect if set + const finalRedirect = logoutRedirectRef.current || redirect; + // Clear the stored redirect + logoutRedirectRef.current = undefined; + if (finalRedirect == null) { return; } - if (redirect.startsWith('http://') || redirect.startsWith('https://')) { - // For external links, use window.location - window.location.href = redirect; - // Or if you want to open in a new tab: - // window.open(redirect, '_blank'); + if (finalRedirect.startsWith('http://') || finalRedirect.startsWith('https://')) { + window.location.href = finalRedirect; } else { - navigate(redirect, { replace: true }); + navigate(finalRedirect, { replace: true }); } }, [navigate, setUser], @@ -106,7 +110,16 @@ const AuthContextProvider = ({ }); const refreshToken = useRefreshTokenMutation(); - const logout = useCallback(() => logoutUser.mutate(undefined), [logoutUser]); + const logout = useCallback( + (redirect?: string) => { + if (redirect) { + logoutRedirectRef.current = redirect; + } + logoutUser.mutate(undefined); + }, + [logoutUser], + ); + const userQuery = useGetUserQuery({ enabled: !!(token ?? '') }); const login = (data: t.TLoginUser) => { diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index a7d999ae45..da02b7c4c2 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Outlet, useNavigate } from 'react-router-dom'; +import { Outlet } from 'react-router-dom'; import type { ContextType } from '~/common'; import { AgentsMapContext, @@ -15,7 +15,6 @@ import { Nav, MobileNav } from '~/components/Nav'; import { Banner } from '~/components/Banners'; export default function Root() { - const navigate = useNavigate(); const [showTerms, setShowTerms] = useState(false); const [bannerHeight, setBannerHeight] = useState(0); const [navVisible, setNavVisible] = useState(() => { @@ -44,10 +43,10 @@ export default function Root() { setShowTerms(false); }; + // Pass the desired redirect parameter to logout const handleDeclineTerms = () => { setShowTerms(false); - logout(); - navigate('/login'); + logout('/login?redirect=false'); }; if (!isAuthenticated) { diff --git a/client/src/utils/localStorage.ts b/client/src/utils/localStorage.ts index 6975d2a9d6..3c44551b21 100644 --- a/client/src/utils/localStorage.ts +++ b/client/src/utils/localStorage.ts @@ -1,10 +1,5 @@ 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) ?? '', @@ -29,69 +24,6 @@ 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) => {