From f95d5aaf4dc48b24b60304a6e60498130307fa82 Mon Sep 17 00:00:00 2001 From: heptapod <164861708+leondape@users.noreply.github.com> Date: Wed, 19 Mar 2025 14:51:56 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92feat:=20Enable=20OpenID=20Auto-Redi?= =?UTF-8?q?rect=20(#6066)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .env.example | 3 + api/server/routes/__tests__/config.spec.js | 1 + api/server/routes/config.js | 1 + api/server/routes/oauth.js | 4 +- client/src/common/types.ts | 2 +- client/src/components/Auth/Login.tsx | 64 +++++++++++++++++++++- client/src/hooks/AuthContext.tsx | 29 +++++++--- client/src/locales/en/translation.json | 1 + client/src/routes/Root.tsx | 7 +-- client/src/utils/getLoginError.ts | 6 +- packages/data-provider/src/config.ts | 1 + 11 files changed, 102 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index b86092d56..2a86619db 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 13af53f29..0bb80bb9e 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 c36f4a9b8..e8d2fe57a 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/api/server/routes/oauth.js b/api/server/routes/oauth.js index 046370798..9006b25c5 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -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`); }); /** diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 975f46893..118cefce1 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -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>; roles?: Record; }; diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index a33255370..48cbfe1a9 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -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(); + 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 ( +
+

+ {localize('com_ui_redirecting_to_provider', { 0: startupConfig.openidLabel })} +

+
+ + startupConfig.openidImageUrl ? ( + OpenID Logo + ) : ( + + ) + } + label={startupConfig.openidLabel} + id="openid" + /> +
+
+ ); + } + return ( <> {error != null && {localize(getLoginError(error))}} diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index 2828a1bc5..e21d19ebf 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/locales/en/translation.json b/client/src/locales/en/translation.json index 86d2ce6b4..58dd833c2 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index a7d999ae4..da02b7c4c 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/getLoginError.ts b/client/src/utils/getLoginError.ts index 27fafed0c..492948d6e 100644 --- a/client/src/utils/getLoginError.ts +++ b/client/src/utils/getLoginError.ts @@ -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; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 96c807172..679d600eb 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -514,6 +514,7 @@ export type TStartupConfig = { appleLoginEnabled: boolean; openidLabel: string; openidImageUrl: string; + openidAutoRedirect: boolean; /** LDAP Auth Configuration */ ldap?: { /** LDAP enabled */