mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🔒feat: Enable OpenID Auto-Redirect (#6066)
* added feature for oidc auto redirection * Added Cooldown logic for OIDC auto redirect for failed login attempts * 🔧 feat: Implement custom logout redirect handling and enhance OpenID auto-redirect logic * 🔧 refactor: Update getLoginError to use TranslationKeys for improved type safety * 🔧 feat: Localize redirect message to OpenID provider in Login component --------- Co-authored-by: Ruben Talstra <RubenTalstra1211@outlook.com>
This commit is contained in:
parent
09abce063f
commit
f95d5aaf4d
11 changed files with 102 additions and 17 deletions
|
|
@ -432,6 +432,9 @@ OPENID_NAME_CLAIM=
|
|||
|
||||
OPENID_BUTTON_LABEL=
|
||||
OPENID_IMAGE_URL=
|
||||
# Set to true to automatically redirect to the OpenID provider when a user visits the login page
|
||||
# This will bypass the login form completely for users, only use this if OpenID is your only authentication method
|
||||
OPENID_AUTO_REDIRECT=false
|
||||
|
||||
# LDAP
|
||||
LDAP_URL=
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ afterEach(() => {
|
|||
delete process.env.OPENID_ISSUER;
|
||||
delete process.env.OPENID_SESSION_SECRET;
|
||||
delete process.env.OPENID_BUTTON_LABEL;
|
||||
delete process.env.OPENID_AUTO_REDIRECT;
|
||||
delete process.env.OPENID_AUTH_URL;
|
||||
delete process.env.GITHUB_CLIENT_ID;
|
||||
delete process.env.GITHUB_CLIENT_SECRET;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ router.get('/', async function (req, res) {
|
|||
!!process.env.OPENID_SESSION_SECRET,
|
||||
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
|
||||
openidImageUrl: process.env.OPENID_IMAGE_URL,
|
||||
openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT),
|
||||
serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080',
|
||||
emailLoginEnabled,
|
||||
registrationEnabled: !ldap?.enabled && isEnabled(process.env.ALLOW_REGISTRATION),
|
||||
|
|
|
|||
|
|
@ -31,7 +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() });
|
||||
res.redirect(`${domains.client}/login`);
|
||||
|
||||
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
|
||||
res.redirect(`${domains.client}/login?redirect=false`);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -401,7 +401,7 @@ export type TAuthContext = {
|
|||
isAuthenticated: boolean;
|
||||
error: string | undefined;
|
||||
login: (data: t.TLoginUser) => void;
|
||||
logout: () => void;
|
||||
logout: (redirect?: string) => void;
|
||||
setError: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
roles?: Record<string, t.TRole | null | undefined>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,16 +1,78 @@
|
|||
import { useOutletContext } from 'react-router-dom';
|
||||
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 } 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<TLoginLayoutContext>();
|
||||
|
||||
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(() => {
|
||||
if (disableAutoRedirect) {
|
||||
setIsAutoRedirectDisabled(true);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('redirect');
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}, [disableAutoRedirect, searchParams, setSearchParams]);
|
||||
|
||||
// Determine whether we should auto-redirect to OpenID.
|
||||
const shouldAutoRedirect =
|
||||
startupConfig?.openidLoginEnabled &&
|
||||
startupConfig?.openidAutoRedirect &&
|
||||
startupConfig?.serverDomain &&
|
||||
!isAutoRedirectDisabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoRedirect) {
|
||||
console.log('Auto-redirecting to OpenID provider...');
|
||||
window.location.href = `${startupConfig.serverDomain}/oauth/openid`;
|
||||
}
|
||||
}, [shouldAutoRedirect, startupConfig]);
|
||||
|
||||
// Render fallback UI if auto-redirect is active.
|
||||
if (shouldAutoRedirect) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<p className="text-lg font-semibold">
|
||||
{localize('com_ui_redirecting_to_provider', { 0: startupConfig.openidLabel })}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<SocialButton
|
||||
key="openid"
|
||||
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 (
|
||||
<>
|
||||
{error != null && <ErrorMessage>{localize(getLoginError(error))}</ErrorMessage>}
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const logoutRedirectRef = useRef<string | undefined>(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) => {
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@
|
|||
"com_auth_google_login": "Continue with Google",
|
||||
"com_auth_here": "HERE",
|
||||
"com_auth_login": "Login",
|
||||
"com_ui_redirecting_to_provider": "Redirecting to {{0}}, please wait...",
|
||||
"com_auth_login_with_new_password": "You may now login with your new password.",
|
||||
"com_auth_name_max_length": "Name must be less than 80 characters",
|
||||
"com_auth_name_min_length": "Name must be at least 3 characters",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
const getLoginError = (errorText: string) => {
|
||||
const defaultError = 'com_auth_error_login';
|
||||
import { TranslationKeys } from '~/hooks';
|
||||
|
||||
const getLoginError = (errorText: string): TranslationKeys => {
|
||||
const defaultError: TranslationKeys = 'com_auth_error_login';
|
||||
|
||||
if (!errorText) {
|
||||
return defaultError;
|
||||
|
|
|
|||
|
|
@ -514,6 +514,7 @@ export type TStartupConfig = {
|
|||
appleLoginEnabled: boolean;
|
||||
openidLabel: string;
|
||||
openidImageUrl: string;
|
||||
openidAutoRedirect: boolean;
|
||||
/** LDAP Auth Configuration */
|
||||
ldap?: {
|
||||
/** LDAP enabled */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue