diff --git a/ordinis-admin-ui/src/auth/TokenSync.tsx b/ordinis-admin-ui/src/auth/TokenSync.tsx index f836685..f178c9e 100644 --- a/ordinis-admin-ui/src/auth/TokenSync.tsx +++ b/ordinis-admin-ui/src/auth/TokenSync.tsx @@ -23,6 +23,9 @@ import { LEGACY_TOKEN_STORAGE_KEY } from './oidcConfig' export function TokenSync() { const auth = useAuth() const redirecting = useRef(false) + // Anti-loop guard для проактивной re-auth при expiry — без него + // useEffect срабатывал бы повторно на каждый render пока expired=true. + const reauthing = useRef(false) // 1) Token sync — single update path к accessToken holder в client.ts. // ВАЖНО: пропускаем токен ТОЛЬКО когда user реально authenticated AND token @@ -93,5 +96,41 @@ export function TokenSync() { return () => setUnauthorizedHandler(null) }, [auth]) + // 3) Проактивная re-auth при expiry. automaticSilentRenew: true должен + // обновлять access_token автоматически, но молча падает если: + // - refresh_token истёк (Keycloak SSO Session Idle ~30 мин) + // - silent renew iframe заблокирован 3rd-party cookie policy + // - silentRenewError не подцеплен глобально + // + // В этих случаях user.expired=true, accessToken=null, requests идут + // как anonymous → backend с requireInternal() отдаёт 403 (не 401), + // 401-handler не срабатывает → юзер видит «403 forbidden» вместо + // привычного relogin redirect. Симптом который пользователь репортит + // как «перелогин решает». + // + // Логика: если user был authenticated и стал expired — пытаемся + // signinSilent (вдруг refresh ещё жив), на failure full redirect. + useEffect(() => { + if (!auth.user?.expired) { + reauthing.current = false + return + } + if (reauthing.current || auth.isLoading || auth.activeNavigator) return + if (typeof window !== 'undefined' && window.location.search.includes('error=')) { + return + } + reauthing.current = true + auth + .signinSilent() + .then(() => { + reauthing.current = false + }) + .catch(() => { + auth.signinRedirect().catch(() => { + reauthing.current = false + }) + }) + }, [auth, auth.user?.expired]) + return null }