From caaadf2fdb95a05bab534fe94e83eedfb2e9638c Mon Sep 17 00:00:00 2001 From: Danilo Pejakovic Date: Wed, 26 Feb 2025 15:39:55 +0100 Subject: [PATCH 1/2] added feature for oidc auto redirection --- .env.example | 3 +++ api/server/routes/__tests__/config.spec.js | 1 + api/server/routes/config.js | 1 + client/src/components/Auth/Login.tsx | 24 ++++++++++++++++++++++ packages/data-provider/src/config.ts | 1 + 5 files changed, 30 insertions(+) diff --git a/.env.example b/.env.example index 5fb73557db8..5004bce94d9 100644 --- a/.env.example +++ b/.env.example @@ -431,6 +431,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= diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 13af53f2994..0bb80bb9eed 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -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; diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 705a1d3cb1c..115acdce4dd 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -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), diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index a3325537017..db68bd7d422 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -1,4 +1,5 @@ import { useOutletContext } from 'react-router-dom'; +import { useEffect, useRef } from 'react'; import { useAuthContext } from '~/hooks/AuthContext'; import type { TLoginLayoutContext } from '~/common'; import { ErrorMessage } from '~/components/Auth/ErrorMessage'; @@ -10,6 +11,29 @@ 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 + useEffect(() => { + // Simple check if redirect is needed and not yet attempted + if ( + !redirectAttemptedRef.current && + startupConfig?.openidLoginEnabled && + startupConfig?.openidAutoRedirect && + startupConfig?.serverDomain + ) { + // Mark that we've attempted to redirect + redirectAttemptedRef.current = true; + + // Log and redirect + console.log('Auto-redirecting to OpenID provider...'); + window.location.href = `${startupConfig.serverDomain}/oauth/openid`; + } + }, [startupConfig]); + return ( <> diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 56a560f2ee0..384cd7f7fbc 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -487,6 +487,7 @@ export type TStartupConfig = { appleLoginEnabled: boolean; openidLabel: string; openidImageUrl: string; + openidAutoRedirect: boolean; /** LDAP Auth Configuration */ ldap?: { /** LDAP enabled */ From bfc7179f16d3f7f59ff3d59b8f67763811f15f2e Mon Sep 17 00:00:00 2001 From: Danilo Pejakovic Date: Thu, 27 Feb 2025 10:58:52 +0100 Subject: [PATCH 2/2] Added Cooldown logic for OIDC auto redirect for failed login attempts --- api/server/routes/oauth.js | 11 ++++- client/src/components/Auth/Login.tsx | 34 ++++++++++---- client/src/utils/localStorage.ts | 68 ++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 046370798b3..0a3ca9a2497 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 db68bd7d422..311f40a9e28 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 3c44551b213..6975d2a9d6a 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) => {