From 0aaae9cb2b53c00d6c7e7cae08f8904c97f118e0 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Tue, 26 May 2026 10:56:11 +0300 Subject: [PATCH] =?UTF-8?q?revert:=20=D1=83=D0=B1=D0=B5=D1=80=D1=83=20proa?= =?UTF-8?q?ctive=20re-auth=20=D0=B8=20403=E2=86=92reauth=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B0=D0=BC=20Keycloak=20/token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Симптом: пользователь видел ~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. --- ordinis-admin-ui/src/api/client.ts | 23 +++++++-------- ordinis-admin-ui/src/auth/TokenSync.tsx | 39 ------------------------- 2 files changed, 11 insertions(+), 51 deletions(-) diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index f9c25fd..4800f4d 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -72,22 +72,21 @@ apiClient.interceptors.request.use((config) => { return config }) -// Auth-related interceptor. 401 классический «нет/невалидный JWT» → -// handler делает silent renew / redirect. +// Единственный 401 interceptor. Логика silent renew / redirect / loop guard +// делегируется handler'у который TokenSync устанавливает через +// setUnauthorizedHandler(). // -// 403 + accessToken=null обрабатывается так же. Корень: на проде -// ORDINIS_AUTH_REQUIRED=false → Spring Security пропускает anonymous → -// контроллер видит PUBLIC scope → отдаёт 403 (не 401). Это происходит -// когда silent-renew падает, oidc-client-ts убирает user, accessToken -// зануляется → следующий запрос идёт без Bearer. Без этой ветки юзер -// залипал на 403 и видел «forbidden» вместо relogin redirect'а. +// 403 НЕ цепляем: на проде ORDINIS_AUTH_REQUIRED=false → backend отдаёт 403 +// для anonymous (нет JWT) ИЛИ для legitimate scope-deficiency (юзер с +// токеном но без INTERNAL). Невозможно reliably различить из ответа, +// раннее попытка триггерить reauth на 403+!accessToken приводила к +// бесконечному циклу с Keycloak (signinSilent → 400 → catch → ещё +// signinSilent через automaticSilentRenew). Лучше показать 403 и оставить +// юзеру manual relogin, чем заспамить /token endpoint Keycloak'а. apiClient.interceptors.response.use( (resp) => resp, (error) => { - const status = error?.response?.status - const needsReauth = - status === 401 || (status === 403 && accessToken == null) - if (needsReauth && unauthorizedHandler) { + if (error?.response?.status === 401 && unauthorizedHandler) { try { unauthorizedHandler(error) } catch (e) { diff --git a/ordinis-admin-ui/src/auth/TokenSync.tsx b/ordinis-admin-ui/src/auth/TokenSync.tsx index f178c9e..f836685 100644 --- a/ordinis-admin-ui/src/auth/TokenSync.tsx +++ b/ordinis-admin-ui/src/auth/TokenSync.tsx @@ -23,9 +23,6 @@ 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 @@ -96,41 +93,5 @@ 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 }