fix(admin-ui): 403 + отсутствует accessToken → trigger reauth handler

Симптом: после ~30 мин админ видит 403 на /admin/*, перелогин чинит.

Цепочка событий:
1. access_token истекает
2. oidc-client-ts автоматический silent renew падает (refresh_token expired
   за 30 мин SSO Session Idle / iframe заблокирован)
3. auth.user становится undefined (oidc убирает после full expiry)
4. TokenSync setAccessToken(null) — accessToken в apiClient зануляется
5. Следующий запрос идёт БЕЗ Authorization header
6. Backend (ORDINIS_AUTH_REQUIRED=false) пропускает как anonymous →
   ScopeContext = PUBLIC → controller с requireInternal() кидает 403

401-handler не срабатывает на 403 → юзер залипает.

Также мой предыдущий useEffect в TokenSync (MR !267) триггерит на
auth.user?.expired === true, но если user удалён полностью —
optional-chaining вернёт undefined и useEffect не сработает.

Fix: расширил interceptor чтобы на (status === 403 && accessToken == null)
тоже вызывать unauthorizedHandler. Этот handler — single source of
truth, делает signinSilent → fallback signinRedirect.

Не цепляет legitimate 403 (когда у юзера ЕСТЬ токен но scope недостаточно —
например viewer пытается dropить INTERNAL dict). accessToken != null
в таких случаях → ветка не сработает.
This commit is contained in:
Zimin A.N.
2026-05-25 19:19:39 +03:00
parent 172a6bd9cc
commit a1e83a86bc
+13 -4
View File
@@ -72,13 +72,22 @@ apiClient.interceptors.request.use((config) => {
return config return config
}) })
// Единственный 401 interceptor. Логика silent renew / redirect / loop guard // Auth-related interceptor. 401 классический «нет/невалидный JWT» →
// делегируется handler'у который TokenSync устанавливает через // handler делает silent renew / redirect.
// 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'а.
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(resp) => resp, (resp) => resp,
(error) => { (error) => {
if (error?.response?.status === 401 && unauthorizedHandler) { const status = error?.response?.status
const needsReauth =
status === 401 || (status === 403 && accessToken == null)
if (needsReauth && unauthorizedHandler) {
try { try {
unauthorizedHandler(error) unauthorizedHandler(error)
} catch (e) { } catch (e) {