From 47b880221d38e3df8843baa5a0038e9e596b30ff Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 7 Apr 2026 13:17:34 +0200 Subject: [PATCH] fix(oidc): resolve login/logout loop in OIDC-only mode Three distinct bugs caused infinite OIDC redirect loops: 1. After logout, navigating to /login with no signal to suppress the auto-redirect caused the login page to immediately re-trigger the OIDC flow. Fixed by passing `{ state: { noRedirect: true } }` via React Router's navigation state (not URL params, which were fragile due to async cleanup timing) from all logout call sites. 2. On the OIDC callback page (/login?oidc_code=...), App.tsx's mount-level loadUser() fired concurrently with the LoginPage's exchange fetch. The App-level call had no cookie yet and got a 401, which (if it resolved after the successful exchange loadUser()) would overwrite isAuthenticated back to false. Fixed by skipping loadUser() in App.tsx when the initial path is /login. 3. React 18 StrictMode double-invokes useEffect. The first run called window.history.replaceState to clean the oidc_code from the URL before starting the async exchange, so the second run saw no oidc_code and fell through to the getAppConfig auto-redirect, firing window.location.href = '/api/auth/oidc/login' before the exchange could complete. Fixed by adding a useRef guard to prevent double-execution and moving replaceState into the fetch callbacks so the URL is only cleaned after the exchange resolves. Also adds login.oidcLoggedOut translation key in all 14 languages to show "You have been logged out" instead of the generic OIDC-only message when landing on /login after an intentional logout. Closes #491 --- client/src/App.tsx | 2 +- client/src/components/Layout/Navbar.tsx | 2 +- client/src/components/Settings/AccountTab.tsx | 2 +- client/src/i18n/translations/ar.ts | 1 + client/src/i18n/translations/br.ts | 1 + client/src/i18n/translations/cs.ts | 1 + client/src/i18n/translations/de.ts | 1 + client/src/i18n/translations/en.ts | 1 + client/src/i18n/translations/es.ts | 1 + client/src/i18n/translations/fr.ts | 1 + client/src/i18n/translations/hu.ts | 1 + client/src/i18n/translations/it.ts | 1 + client/src/i18n/translations/nl.ts | 1 + client/src/i18n/translations/pl.ts | 1 + client/src/i18n/translations/ru.ts | 1 + client/src/i18n/translations/zh.ts | 1 + client/src/i18n/translations/zhTw.ts | 1 + client/src/pages/AdminPage.tsx | 2 +- client/src/pages/LoginPage.tsx | 22 +++++++++++++------ 19 files changed, 33 insertions(+), 11 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 621201e..0ca00b6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -82,7 +82,7 @@ export default function App() { const { loadSettings } = useSettingsStore() useEffect(() => { - if (!location.pathname.startsWith('/shared/')) { + if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) { loadUser() } authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index e4e1dc9..cee59b8 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -53,7 +53,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: const handleLogout = () => { logout() - navigate('/login') + navigate('/login', { state: { noRedirect: true } }) } const toggleDarkMode = () => { diff --git a/client/src/components/Settings/AccountTab.tsx b/client/src/components/Settings/AccountTab.tsx index 81bf491..1c9abfd 100644 --- a/client/src/components/Settings/AccountTab.tsx +++ b/client/src/components/Settings/AccountTab.tsx @@ -575,7 +575,7 @@ export default function AccountTab(): React.ReactElement { try { await authApi.deleteOwnAccount() logout() - navigate('/login') + navigate('/login', { state: { noRedirect: true } }) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('common.error'))) setShowDeleteConfirm(false) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 43f29ee..cdcac56 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -367,6 +367,7 @@ const ar: Record = { 'login.demoFailed': 'فشل الدخول إلى العرض التجريبي', 'login.oidcSignIn': 'تسجيل الدخول عبر {name}', 'login.oidcOnly': 'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.', + 'login.oidcLoggedOut': 'تم تسجيل خروجك. سجّل الدخول مجدداً عبر مزود SSO.', 'login.demoHint': 'جرّب العرض التجريبي دون الحاجة للتسجيل', 'login.mfaTitle': 'المصادقة الثنائية', 'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 12612da..d3b11e6 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -362,6 +362,7 @@ const br: Record = { 'login.demoFailed': 'Falha no login de demonstração', 'login.oidcSignIn': 'Entrar com {name}', 'login.oidcOnly': 'Login por senha desativado. Use o provedor SSO.', + 'login.oidcLoggedOut': 'Você foi desconectado. Entre novamente usando o provedor SSO.', 'login.demoHint': 'Experimente a demonstração — sem cadastro', 'login.mfaTitle': 'Autenticação em duas etapas', 'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index defebfb..130f962 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -362,6 +362,7 @@ const cs: Record = { 'login.demoFailed': 'Přihlášení do dema se nezdařilo', 'login.oidcSignIn': 'Přihlásit se přes {name}', 'login.oidcOnly': 'Ověřování heslem je zakázáno. Přihlaste se prosím přes SSO poskytovatele.', + 'login.oidcLoggedOut': 'Byl jste odhlášen. Přihlaste se znovu přes SSO poskytovatele.', 'login.demoHint': 'Vyzkoušejte demo – registrace není nutná', 'login.mfaTitle': 'Dvoufaktorové ověření', 'login.mfaSubtitle': 'Zadejte 6místný kód z vaší autentizační aplikace.', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 1c76a6c..c9ebf45 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -362,6 +362,7 @@ const de: Record = { 'login.demoFailed': 'Demo-Login fehlgeschlagen', 'login.oidcSignIn': 'Anmelden mit {name}', 'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.', + 'login.oidcLoggedOut': 'Du wurdest abgemeldet. Melde dich erneut über deinen SSO-Anbieter an.', 'login.demoHint': 'Demo ausprobieren — ohne Registrierung', 'login.mfaTitle': 'Zwei-Faktor-Authentifizierung', 'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 6e6cc0b..2cb895a 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -383,6 +383,7 @@ const en: Record = { 'login.demoFailed': 'Demo login failed', 'login.oidcSignIn': 'Sign in with {name}', 'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.', + 'login.oidcLoggedOut': 'You have been logged out. Sign in again using your SSO provider.', 'login.demoHint': 'Try the demo — no registration needed', 'login.mfaTitle': 'Two-factor authentication', 'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index c487b25..4121943 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1490,6 +1490,7 @@ const es: Record = { 'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña', 'admin.oidcOnlyModeHint': 'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.', 'login.oidcOnly': 'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.', + 'login.oidcLoggedOut': 'Has cerrado sesión. Vuelve a iniciar sesión con tu proveedor SSO.', // Settings (2.6.2) 'settings.currentPasswordRequired': 'La contraseña actual es obligatoria', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index b1615c9..cbc2e09 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -369,6 +369,7 @@ const fr: Record = { 'login.demoFailed': 'Échec de la connexion démo', 'login.oidcSignIn': 'Se connecter avec {name}', 'login.oidcOnly': 'L\'authentification par mot de passe est désactivée. Veuillez vous connecter via votre fournisseur SSO.', + 'login.oidcLoggedOut': 'Vous avez été déconnecté. Reconnectez-vous via votre fournisseur SSO.', 'login.demoHint': 'Essayez la démo — aucune inscription nécessaire', // Register diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 3d6c660..40ce49a 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -362,6 +362,7 @@ const hu: Record = { 'login.demoFailed': 'Demo bejelentkezés sikertelen', 'login.oidcSignIn': 'Bejelentkezés ezzel: {name}', 'login.oidcOnly': 'A jelszavas hitelesítés le van tiltva. Kérjük, jelentkezz be az SSO szolgáltatódon keresztül.', + 'login.oidcLoggedOut': 'Kijelentkeztél. Jelentkezz be újra az SSO szolgáltatódon keresztül.', 'login.demoHint': 'Próbáld ki a demót — regisztráció nélkül', 'login.mfaTitle': 'Kétfaktoros hitelesítés', 'login.mfaSubtitle': 'Add meg a 6 jegyű kódot a hitelesítő alkalmazásból.', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 0a504f9..d5449ff 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -362,6 +362,7 @@ const it: Record = { 'login.demoFailed': 'Accesso demo fallito', 'login.oidcSignIn': 'Accedi con {name}', 'login.oidcOnly': 'L\'autenticazione tramite password è disabilitata. Accedi utilizzando il tuo provider SSO.', + 'login.oidcLoggedOut': 'Sei stato disconnesso. Accedi nuovamente tramite il tuo provider SSO.', 'login.demoHint': 'Prova la demo — nessuna registrazione necessaria', 'login.mfaTitle': 'Autenticazione a due fattori', 'login.mfaSubtitle': 'Inserisci il codice a 6 cifre dalla tua app authenticator.', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 93c4e78..2e7495d 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -369,6 +369,7 @@ const nl: Record = { 'login.demoFailed': 'Demo-login mislukt', 'login.oidcSignIn': 'Inloggen met {name}', 'login.oidcOnly': 'Wachtwoordauthenticatie is uitgeschakeld. Log in via je SSO-provider.', + 'login.oidcLoggedOut': 'Je bent uitgelogd. Log opnieuw in via je SSO-provider.', 'login.demoHint': 'Probeer de demo — geen registratie nodig', // Register diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index b020286..4f60b98 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -329,6 +329,7 @@ const pl: Record = { 'login.demoFailed': 'Nie udało się zalogować do wersji demonstracyjnej', 'login.oidcSignIn': 'Zaloguj się z {name}', 'login.oidcOnly': 'Uwierzytelnianie hasłem jest wyłączone. Zaloguj się za pomocą swojego dostawcy SSO.', + 'login.oidcLoggedOut': 'Zostałeś wylogowany. Zaloguj się ponownie za pomocą swojego dostawcy SSO.', 'login.demoHint': 'Wypróbuj demo — nie wymaga rejestracji', 'login.mfaTitle': 'Uwierzytelnianie dwuskładnikowe', 'login.mfaSubtitle': 'Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 3cf4cc7..18001fc 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -369,6 +369,7 @@ const ru: Record = { 'login.demoFailed': 'Ошибка демо-входа', 'login.oidcSignIn': 'Войти через {name}', 'login.oidcOnly': 'Вход по паролю отключён. Используйте вашего провайдера SSO для входа.', + 'login.oidcLoggedOut': 'Вы вышли из системы. Войдите снова через вашего провайдера SSO.', 'login.demoHint': 'Попробуйте демо — регистрация не требуется', // Register diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 5dc7421..d0af81d 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -369,6 +369,7 @@ const zh: Record = { 'login.demoFailed': '演示登录失败', 'login.oidcSignIn': '通过 {name} 登录', 'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。', + 'login.oidcLoggedOut': '您已退出登录。请重新通过 SSO 提供商登录。', 'login.demoHint': '试用演示——无需注册', // Register diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index fc35e1a..86fa141 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -353,6 +353,7 @@ const zhTw: Record = { 'login.demoFailed': '演示登入失敗', 'login.oidcSignIn': '透過 {name} 登入', 'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。', + 'login.oidcLoggedOut': '您已登出。請重新透過 SSO 提供商登入。', 'login.demoHint': '試用演示——無需註冊', // Register diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 92f3b98..c6f5d51 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -1551,7 +1551,7 @@ docker run -d --name trek \\ await adminApi.rotateJwtSecret() setShowRotateJwtModal(false) logout() - navigate('/login') + navigate('/login', { state: { noRedirect: true } }) } catch { toast.error(t('common.error')) setRotatingJwt(false) diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 44da863..6f76aee 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useMemo } from 'react' -import { useNavigate } from 'react-router-dom' +import React, { useState, useEffect, useMemo, useRef } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n' @@ -29,10 +29,13 @@ export default function LoginPage(): React.ReactElement { const [appConfig, setAppConfig] = useState(null) const [inviteToken, setInviteToken] = useState('') const [inviteValid, setInviteValid] = useState(false) + const exchangeInitiated = useRef(false) const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore() const { setLanguageLocal } = useSettingsStore() const navigate = useNavigate() + const location = useLocation() + const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect const redirectTarget = useMemo(() => { const params = new URLSearchParams(window.location.search) @@ -63,11 +66,13 @@ export default function LoginPage(): React.ReactElement { } if (oidcCode) { + if (exchangeInitiated.current) return + exchangeInitiated.current = true setIsLoading(true) - window.history.replaceState({}, '', '/login') fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' }) .then(r => r.json()) .then(async data => { + window.history.replaceState({}, '', '/login') if (data.token) { await loadUser() navigate('/dashboard', { replace: true }) @@ -75,7 +80,10 @@ export default function LoginPage(): React.ReactElement { setError(data.error || 'OIDC login failed') } }) - .catch(() => setError('OIDC login failed')) + .catch(() => { + window.history.replaceState({}, '', '/login') + setError('OIDC login failed') + }) .finally(() => setIsLoading(false)) return } @@ -96,12 +104,12 @@ export default function LoginPage(): React.ReactElement { if (config) { setAppConfig(config) if (!config.has_users) setMode('register') - if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite) { + if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite && !noRedirect) { window.location.href = '/api/auth/oidc/login' } } }) - }, [navigate, t]) + }, [navigate, t, noRedirect]) const handleDemoLogin = async (): Promise => { setError('') @@ -527,7 +535,7 @@ export default function LoginPage(): React.ReactElement { {oidcOnly ? ( <>

{t('login.title')}

-

{t('login.oidcOnly')}

+

{noRedirect ? t('login.oidcLoggedOut') : t('login.oidcOnly')}

{error && (
{error}