fix(auth): TokenSync — проактивная re-auth при expired user

Симптом: после ~30 мин неактивности пользователь видит 403 на /admin/audit
и /admin/users. Перелогин решает.

Корень. automaticSilentRenew: true в oidcConfig должен обновлять
access_token, но падает молча когда:
- refresh_token истёк (Keycloak SSO Session Idle = 30 мин)
- silent renew iframe заблокирован 3rd-party cookies
- silentRenewError не подцеплен глобально

В этих случаях user.expired=true, accessToken=null. Дальнейшие запросы идут
БЕЗ Authorization header → backend с ORDINIS_AUTH_REQUIRED=false пропускает
как anonymous → requireInternal() видит PUBLIC scope → 403 (не 401).
401-handler в interceptor'е не срабатывает (статус не тот) → юзер залипает.

Fix. Новый useEffect в TokenSync watches auth.user?.expired. На transition
в true: signinSilent (вдруг refresh ещё жив), на failure signinRedirect.
Anti-loop ref предотвращает повторные попытки пока expired=true.

Бонусом — за компанию с MR !267 (admin → RESTRICTED) — после v2.42.4 деплоя
админ может работать сессией дольше 30 мин без 403'ов.
This commit is contained in:
Zimin A.N.
2026-05-25 18:54:37 +03:00
parent c5adbdf39c
commit 4cd92aeb60
+39
View File
@@ -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
}