revert: уберу proactive re-auth и 403→reauth — спам Keycloak /token

Симптом: пользователь видел ~30+ POST на Keycloak /token endpoint
с 400 Bad Request, страница залипала.

Корень. MR !267 добавил useEffect в TokenSync который на
auth.user?.expired === true делал signinSilent → catch → signinRedirect.
MR !268 добавил interceptor триггер на 403 + !accessToken.

Цикл:
1. token expires, refresh_token тоже (Keycloak SSO Session Idle прошёл)
2. useEffect видит expired=true → signinSilent → POST /token с refresh
3. Keycloak возвращает 400 invalid_grant (refresh expired)
4. catch → signinRedirect, но oidc-client-ts automaticSilentRenew=true
   параллельно тоже пытается → ещё POST /token → ещё 400
5. signinRedirect не успевает редиректнуть до следующего render'а
6. reauthing.current=false → useEffect fire'ит снова → новый signinSilent
7. → ещё 400, и так в loop ~30 раз пока что-то не разрулит

Откатываю обе ветки. Возвращаемся к v2.42.3 поведению: silent renew
автоматически пробует один раз, на failure пользователь видит 403
(или stale data из cache) и делает relogin вручную. Не идеально, но
не спамит Keycloak и не залипает на endlessly.

Backend ScopeContext admin → RESTRICTED (MR !267 backend часть)
остаётся — это безвредная правка, помогает юзерам с admin ролью
без явного INTERNAL.
This commit is contained in:
Zimin A.N.
2026-05-26 10:56:11 +03:00
parent 465a37836c
commit 0aaae9cb2b
2 changed files with 11 additions and 51 deletions
+11 -12
View File
@@ -72,22 +72,21 @@ apiClient.interceptors.request.use((config) => {
return config return config
}) })
// Auth-related interceptor. 401 классический «нет/невалидный JWT» → // Единственный 401 interceptor. Логика silent renew / redirect / loop guard
// handler делает silent renew / redirect. // делегируется handler'у который TokenSync устанавливает через
// setUnauthorizedHandler().
// //
// 403 + accessToken=null обрабатывается так же. Корень: на проде // 403 НЕ цепляем: на проде ORDINIS_AUTH_REQUIRED=false → backend отдаёт 403
// ORDINIS_AUTH_REQUIRED=false → Spring Security пропускает anonymous → // для anonymous (нет JWT) ИЛИ для legitimate scope-deficiency (юзер с
// контроллер видит PUBLIC scope → отдаёт 403 (не 401). Это происходит // токеном но без INTERNAL). Невозможно reliably различить из ответа,
// когда silent-renew падает, oidc-client-ts убирает user, accessToken // раннее попытка триггерить reauth на 403+!accessToken приводила к
// зануляется → следующий запрос идёт без Bearer. Без этой ветки юзер // бесконечному циклу с Keycloak (signinSilent → 400 → catch → ещё
// залипал на 403 и видел «forbidden» вместо relogin redirect'а. // signinSilent через automaticSilentRenew). Лучше показать 403 и оставить
// юзеру manual relogin, чем заспамить /token endpoint Keycloak'а.
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(resp) => resp, (resp) => resp,
(error) => { (error) => {
const status = error?.response?.status if (error?.response?.status === 401 && unauthorizedHandler) {
const needsReauth =
status === 401 || (status === 403 && accessToken == null)
if (needsReauth && unauthorizedHandler) {
try { try {
unauthorizedHandler(error) unauthorizedHandler(error)
} catch (e) { } catch (e) {
-39
View File
@@ -23,9 +23,6 @@ import { LEGACY_TOKEN_STORAGE_KEY } from './oidcConfig'
export function TokenSync() { export function TokenSync() {
const auth = useAuth() const auth = useAuth()
const redirecting = useRef(false) 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. // 1) Token sync — single update path к accessToken holder в client.ts.
// ВАЖНО: пропускаем токен ТОЛЬКО когда user реально authenticated AND token // ВАЖНО: пропускаем токен ТОЛЬКО когда user реально authenticated AND token
@@ -96,41 +93,5 @@ export function TokenSync() {
return () => setUnauthorizedHandler(null) return () => setUnauthorizedHandler(null)
}, [auth]) }, [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 return null
} }