mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-17 07:58:08 +01:00
🔧 feat: Implement custom logout redirect handling and enhance OpenID auto-redirect logic
This commit is contained in:
parent
a2f953460b
commit
17b0f35f93
6 changed files with 82 additions and 127 deletions
|
|
@ -22,13 +22,6 @@ const oauthHandler = async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await setAuthTokens(req.user._id, res);
|
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);
|
res.redirect(domains.client);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error in setting authentication tokens:', err);
|
logger.error('Error in setting authentication tokens:', err);
|
||||||
|
|
@ -40,7 +33,7 @@ router.get('/error', (req, res) => {
|
||||||
logger.error('Error in OAuth authentication:', { message: req.session.messages.pop() });
|
logger.error('Error in OAuth authentication:', { message: req.session.messages.pop() });
|
||||||
|
|
||||||
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
|
// 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`);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,7 @@ export type TAuthContext = {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
error: string | undefined;
|
error: string | undefined;
|
||||||
login: (data: t.TLoginUser) => void;
|
login: (data: t.TLoginUser) => void;
|
||||||
logout: () => void;
|
logout: (redirect?: string) => void;
|
||||||
setError: React.Dispatch<React.SetStateAction<string | undefined>>;
|
setError: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||||
roles?: Record<string, t.TRole | null | undefined>;
|
roles?: Record<string, t.TRole | null | undefined>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,75 @@
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAuthContext } from '~/hooks/AuthContext';
|
import { useAuthContext } from '~/hooks/AuthContext';
|
||||||
import type { TLoginLayoutContext } from '~/common';
|
import type { TLoginLayoutContext } from '~/common';
|
||||||
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
|
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
|
||||||
import { getLoginError, shouldRedirectToOpenID, clearOpenIDRedirectFlag, getCookie } from '~/utils';
|
import { getLoginError } from '~/utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import LoginForm from './LoginForm';
|
import LoginForm from './LoginForm';
|
||||||
|
import SocialButton from '~/components/Auth/SocialButton';
|
||||||
|
import { OpenIDIcon } from '~/components';
|
||||||
|
|
||||||
function Login() {
|
function Login() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { error, setError, login } = useAuthContext();
|
const { error, setError, login } = useAuthContext();
|
||||||
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
|
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
|
||||||
const redirectAttemptedRef = useRef(false);
|
|
||||||
|
|
||||||
// Auto-redirect to OpenID provider if enabled
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
// This is controlled by the OPENID_AUTO_REDIRECT environment variable
|
// Determine if auto-redirect should be disabled based on the URL parameter
|
||||||
// When enabled, users will be automatically redirected to the OpenID provider
|
const disableAutoRedirect = searchParams.get('redirect') === 'false';
|
||||||
// without seeing the login form at all
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
// Check for URL parameters that indicate a failed auth attempt
|
if (disableAutoRedirect) {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
setIsAutoRedirectDisabled(true);
|
||||||
const authFailed = urlParams.get('auth_failed') === '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
|
// Determine whether we should auto-redirect to OpenID.
|
||||||
if (
|
const shouldAutoRedirect =
|
||||||
shouldRedirectToOpenID({
|
startupConfig?.openidLoginEnabled &&
|
||||||
redirectAttempted: redirectAttemptedRef.current,
|
startupConfig?.openidAutoRedirect &&
|
||||||
openidLoginEnabled: startupConfig?.openidLoginEnabled,
|
startupConfig?.serverDomain &&
|
||||||
openidAutoRedirect: startupConfig?.openidAutoRedirect,
|
!isAutoRedirectDisabled;
|
||||||
serverDomain: startupConfig?.serverDomain,
|
|
||||||
authFailed
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
// Mark that we've attempted to redirect in this component instance
|
|
||||||
redirectAttemptedRef.current = true;
|
|
||||||
|
|
||||||
// Log and redirect
|
useEffect(() => {
|
||||||
|
if (shouldAutoRedirect) {
|
||||||
console.log('Auto-redirecting to OpenID provider...');
|
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)
|
// Render fallback UI if auto-redirect is active.
|
||||||
useEffect(() => {
|
if (shouldAutoRedirect) {
|
||||||
const successfulLogin = getCookie('successful_login');
|
return (
|
||||||
if (successfulLogin) {
|
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||||
// Clear the redirect flag in localStorage
|
<p className="text-lg font-semibold">Redirecting to OpenID provider, please wait...</p>
|
||||||
clearOpenIDRedirectFlag();
|
<div className="mt-4">
|
||||||
|
<SocialButton
|
||||||
// Clear the cookie since we've processed it
|
key="openid"
|
||||||
document.cookie = 'successful_login=; Max-Age=0; path=/;';
|
enabled={startupConfig.openidLoginEnabled}
|
||||||
}
|
serverDomain={startupConfig.serverDomain}
|
||||||
}, []);
|
oauthPath="openid"
|
||||||
|
Icon={() =>
|
||||||
|
startupConfig.openidImageUrl ? (
|
||||||
|
<img src={startupConfig.openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<OpenIDIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={startupConfig.openidLabel}
|
||||||
|
id="openid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
useContext,
|
useContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
createContext,
|
createContext,
|
||||||
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
|
|
@ -35,6 +36,8 @@ const AuthContextProvider = ({
|
||||||
const [token, setToken] = useState<string | undefined>(undefined);
|
const [token, setToken] = useState<string | undefined>(undefined);
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||||
|
const logoutRedirectRef = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
const { data: userRole = null } = useGetRole(SystemRoles.USER, {
|
const { data: userRole = null } = useGetRole(SystemRoles.USER, {
|
||||||
enabled: !!(isAuthenticated && (user?.role ?? '')),
|
enabled: !!(isAuthenticated && (user?.role ?? '')),
|
||||||
});
|
});
|
||||||
|
|
@ -52,16 +55,17 @@ const AuthContextProvider = ({
|
||||||
//@ts-ignore - ok for token to be undefined initially
|
//@ts-ignore - ok for token to be undefined initially
|
||||||
setTokenHeader(token);
|
setTokenHeader(token);
|
||||||
setIsAuthenticated(isAuthenticated);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (redirect.startsWith('http://') || redirect.startsWith('https://')) {
|
if (finalRedirect.startsWith('http://') || finalRedirect.startsWith('https://')) {
|
||||||
// For external links, use window.location
|
window.location.href = finalRedirect;
|
||||||
window.location.href = redirect;
|
|
||||||
// Or if you want to open in a new tab:
|
|
||||||
// window.open(redirect, '_blank');
|
|
||||||
} else {
|
} else {
|
||||||
navigate(redirect, { replace: true });
|
navigate(finalRedirect, { replace: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigate, setUser],
|
[navigate, setUser],
|
||||||
|
|
@ -106,7 +110,16 @@ const AuthContextProvider = ({
|
||||||
});
|
});
|
||||||
const refreshToken = useRefreshTokenMutation();
|
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 userQuery = useGetUserQuery({ enabled: !!(token ?? '') });
|
||||||
|
|
||||||
const login = (data: t.TLoginUser) => {
|
const login = (data: t.TLoginUser) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
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 type { ContextType } from '~/common';
|
||||||
import {
|
import {
|
||||||
AgentsMapContext,
|
AgentsMapContext,
|
||||||
|
|
@ -15,7 +15,6 @@ import { Nav, MobileNav } from '~/components/Nav';
|
||||||
import { Banner } from '~/components/Banners';
|
import { Banner } from '~/components/Banners';
|
||||||
|
|
||||||
export default function Root() {
|
export default function Root() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [showTerms, setShowTerms] = useState(false);
|
const [showTerms, setShowTerms] = useState(false);
|
||||||
const [bannerHeight, setBannerHeight] = useState(0);
|
const [bannerHeight, setBannerHeight] = useState(0);
|
||||||
const [navVisible, setNavVisible] = useState(() => {
|
const [navVisible, setNavVisible] = useState(() => {
|
||||||
|
|
@ -44,10 +43,10 @@ export default function Root() {
|
||||||
setShowTerms(false);
|
setShowTerms(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pass the desired redirect parameter to logout
|
||||||
const handleDeclineTerms = () => {
|
const handleDeclineTerms = () => {
|
||||||
setShowTerms(false);
|
setShowTerms(false);
|
||||||
logout();
|
logout('/login?redirect=false');
|
||||||
navigate('/login');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { LocalStorageKeys, TConversation } from 'librechat-data-provider';
|
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() {
|
export function getLocalStorageItems() {
|
||||||
const items = {
|
const items = {
|
||||||
lastSelectedModel: localStorage.getItem(LocalStorageKeys.LAST_MODEL) ?? '',
|
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) {
|
export function clearLocalStorage(skipFirst?: boolean) {
|
||||||
const keys = Object.keys(localStorage);
|
const keys = Object.keys(localStorage);
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue