🔒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:
heptapod 2025-03-19 14:51:56 +01:00 committed by GitHub
parent 09abce063f
commit f95d5aaf4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 102 additions and 17 deletions

View file

@ -432,6 +432,9 @@ OPENID_NAME_CLAIM=
OPENID_BUTTON_LABEL= OPENID_BUTTON_LABEL=
OPENID_IMAGE_URL= 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
LDAP_URL= LDAP_URL=

View file

@ -18,6 +18,7 @@ afterEach(() => {
delete process.env.OPENID_ISSUER; delete process.env.OPENID_ISSUER;
delete process.env.OPENID_SESSION_SECRET; delete process.env.OPENID_SESSION_SECRET;
delete process.env.OPENID_BUTTON_LABEL; delete process.env.OPENID_BUTTON_LABEL;
delete process.env.OPENID_AUTO_REDIRECT;
delete process.env.OPENID_AUTH_URL; delete process.env.OPENID_AUTH_URL;
delete process.env.GITHUB_CLIENT_ID; delete process.env.GITHUB_CLIENT_ID;
delete process.env.GITHUB_CLIENT_SECRET; delete process.env.GITHUB_CLIENT_SECRET;

View file

@ -58,6 +58,7 @@ router.get('/', async function (req, res) {
!!process.env.OPENID_SESSION_SECRET, !!process.env.OPENID_SESSION_SECRET,
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID', openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
openidImageUrl: process.env.OPENID_IMAGE_URL, openidImageUrl: process.env.OPENID_IMAGE_URL,
openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT),
serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080', serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080',
emailLoginEnabled, emailLoginEnabled,
registrationEnabled: !ldap?.enabled && isEnabled(process.env.ALLOW_REGISTRATION), registrationEnabled: !ldap?.enabled && isEnabled(process.env.ALLOW_REGISTRATION),

View file

@ -31,7 +31,9 @@ const oauthHandler = async (req, res) => {
router.get('/error', (req, res) => { router.get('/error', (req, res) => {
// A single error message is pushed by passport when authentication fails. // A single error message is pushed by passport when authentication fails.
logger.error('Error in OAuth authentication:', { message: req.session.messages.pop() }); 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`);
}); });
/** /**

View file

@ -401,7 +401,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>;
}; };

View file

@ -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 { 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 } 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 [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 ( return (
<> <>
{error != null && <ErrorMessage>{localize(getLoginError(error))}</ErrorMessage>} {error != null && <ErrorMessage>{localize(getLoginError(error))}</ErrorMessage>}

View file

@ -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) => {

View file

@ -104,6 +104,7 @@
"com_auth_google_login": "Continue with Google", "com_auth_google_login": "Continue with Google",
"com_auth_here": "HERE", "com_auth_here": "HERE",
"com_auth_login": "Login", "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_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_max_length": "Name must be less than 80 characters",
"com_auth_name_min_length": "Name must be at least 3 characters", "com_auth_name_min_length": "Name must be at least 3 characters",

View file

@ -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) {

View file

@ -1,5 +1,7 @@
const getLoginError = (errorText: string) => { import { TranslationKeys } from '~/hooks';
const defaultError = 'com_auth_error_login';
const getLoginError = (errorText: string): TranslationKeys => {
const defaultError: TranslationKeys = 'com_auth_error_login';
if (!errorText) { if (!errorText) {
return defaultError; return defaultError;

View file

@ -514,6 +514,7 @@ export type TStartupConfig = {
appleLoginEnabled: boolean; appleLoginEnabled: boolean;
openidLabel: string; openidLabel: string;
openidImageUrl: string; openidImageUrl: string;
openidAutoRedirect: boolean;
/** LDAP Auth Configuration */ /** LDAP Auth Configuration */
ldap?: { ldap?: {
/** LDAP enabled */ /** LDAP enabled */