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 621201e2..0ca00b63 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 e4e1dc9b..cee59b8b 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 81bf4913..1c9abfdc 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 43f29ee9..cdcac563 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 12612daf..d3b11e66 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 defebfb6..130f9623 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 1c76a6c1..c9ebf453 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 6e6cc0b0..2cb895a1 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 c487b25e..41219432 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 b1615c9b..cbc2e09c 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 3d6c6603..40ce49a2 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 0a504f9e..d5449ff8 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 93c4e780..2e7495da 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 b0202860..4f60b983 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 3cf4cc74..18001fc9 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 5dc74216..d0af81d3 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 fc35e1ab..86fa1418 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 92f3b988..c6f5d516 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 44da8632..6f76aeea 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}