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
This commit is contained in:
@@ -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<string, PermissionLevel> }) => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -367,6 +367,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.demoFailed': 'فشل الدخول إلى العرض التجريبي',
|
||||
'login.oidcSignIn': 'تسجيل الدخول عبر {name}',
|
||||
'login.oidcOnly': 'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.',
|
||||
'login.oidcLoggedOut': 'تم تسجيل خروجك. سجّل الدخول مجدداً عبر مزود SSO.',
|
||||
'login.demoHint': 'جرّب العرض التجريبي دون الحاجة للتسجيل',
|
||||
'login.mfaTitle': 'المصادقة الثنائية',
|
||||
'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||
|
||||
@@ -362,6 +362,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.',
|
||||
|
||||
@@ -362,6 +362,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.',
|
||||
|
||||
@@ -362,6 +362,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.',
|
||||
|
||||
@@ -383,6 +383,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.',
|
||||
|
||||
@@ -1490,6 +1490,7 @@ const es: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -369,6 +369,7 @@ const fr: Record<string, string> = {
|
||||
'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
|
||||
|
||||
@@ -362,6 +362,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.',
|
||||
|
||||
@@ -362,6 +362,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.',
|
||||
|
||||
@@ -369,6 +369,7 @@ const nl: Record<string, string> = {
|
||||
'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
|
||||
|
||||
@@ -329,6 +329,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.',
|
||||
|
||||
@@ -369,6 +369,7 @@ const ru: Record<string, string> = {
|
||||
'login.demoFailed': 'Ошибка демо-входа',
|
||||
'login.oidcSignIn': 'Войти через {name}',
|
||||
'login.oidcOnly': 'Вход по паролю отключён. Используйте вашего провайдера SSO для входа.',
|
||||
'login.oidcLoggedOut': 'Вы вышли из системы. Войдите снова через вашего провайдера SSO.',
|
||||
'login.demoHint': 'Попробуйте демо — регистрация не требуется',
|
||||
|
||||
// Register
|
||||
|
||||
@@ -369,6 +369,7 @@ const zh: Record<string, string> = {
|
||||
'login.demoFailed': '演示登录失败',
|
||||
'login.oidcSignIn': '通过 {name} 登录',
|
||||
'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。',
|
||||
'login.oidcLoggedOut': '您已退出登录。请重新通过 SSO 提供商登录。',
|
||||
'login.demoHint': '试用演示——无需注册',
|
||||
|
||||
// Register
|
||||
|
||||
@@ -353,6 +353,7 @@ const zhTw: Record<string, string> = {
|
||||
'login.demoFailed': '演示登入失敗',
|
||||
'login.oidcSignIn': '透過 {name} 登入',
|
||||
'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。',
|
||||
'login.oidcLoggedOut': '您已登出。請重新透過 SSO 提供商登入。',
|
||||
'login.demoHint': '試用演示——無需註冊',
|
||||
|
||||
// Register
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<AppConfig | null>(null)
|
||||
const [inviteToken, setInviteToken] = useState<string>('')
|
||||
const [inviteValid, setInviteValid] = useState<boolean>(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<void> => {
|
||||
setError('')
|
||||
@@ -527,7 +535,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
{oidcOnly ? (
|
||||
<>
|
||||
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>{t('login.title')}</h2>
|
||||
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{t('login.oidcOnly')}</p>
|
||||
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{noRedirect ? t('login.oidcLoggedOut') : t('login.oidcOnly')}</p>
|
||||
{error && (
|
||||
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
||||
{error}
|
||||
|
||||
Reference in New Issue
Block a user