feat(auth): миграция oidc-client-ts → keycloak-js (altum-pattern)
Phase 1a из плана: только swap библиотеки, realm/URL остаются на auth.nstart.space/realms/nstart. Phase 2 (intgr с altum auth-service) — отдельный эпик. Зачем. oidc-client-ts с automaticSilentRenew триггерил бесконечный POST /token loop когда refresh_token истекал (см. MR !269 revert, ~30+ 400'к в console, страница залипала на 403). Altum давно ушёл с oidc-client-ts на keycloak-js по той же причине. Новая модель. - src/lib/keycloak.ts — singleton + initKeycloak(check-sso, PKCE) + ensureFreshToken(N) wrapper над kc.updateToken(). Зеркало altum/src/lib/keycloak.ts. - src/stores/authStore.ts — Zustand store: {user, isAuthenticated, isLoading, error}. checkAuth() ждёт initKeycloak, wireKeycloakEvents() → onAuthSuccess/Refresh/Logout/TokenExpired re-снэпшотят store. - src/auth/useAuth.ts — compat shim для react-oidc-context API (auth.user.profile, isAuthenticated, signinSilent, signinRedirect, signoutRedirect, removeUser, error). Все 11 callsites продолжают работать без правок (только сменили путь import'а). - src/api/client.ts — request interceptor дёргает ensureFreshToken(30) ПЕРЕД каждым запросом + attaches Bearer ${getToken()}. 401 interceptor делает ensureFreshToken(0) + retry один раз. Никаких таймеров, никаких setAccessToken/setUnauthorizedHandler holder'ов — keycloak-js сам source of truth. - src/main.tsx — убран <AuthProvider>, <TokenSync />. Просто useAuthStore.getState().checkAuth() на старте, потом обычный render. - public/silent-check-sso.html — endpoint для silentCheckSsoRedirectUri. Удалены. - src/auth/TokenSync.tsx — не нужен, ensureFreshToken на запросах. - src/auth/oidcConfig.ts — не нужен, конфиг внутри lib/keycloak.ts. - npm deps: react-oidc-context, oidc-client-ts. Добавлены deps: keycloak-js@26.2.4, zustand@5.0.13. Tests: 171/171 passed, TSC чистый. Migration callsites — все unchanged по семантике благодаря compat shim.
This commit is contained in:
@@ -1,40 +1,5 @@
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* Source of truth для access token. {@link TokenSync} обновляет это значение
|
||||
* синхронно при изменении OIDC user state. Interceptor читает из in-memory
|
||||
* holder'а — никаких {@code defaults.headers}, никаких {@code localStorage} poll'ов.
|
||||
*
|
||||
* <p>Зачем in-memory holder, не localStorage:
|
||||
* <ul>
|
||||
* <li>Race-free: interceptor и TokenSync видят один и тот же token immediately
|
||||
* после {@code setAccessToken}, без ожидания useEffect cycle.</li>
|
||||
* <li>Logout: {@code setAccessToken(null)} → следующий request immediately
|
||||
* без Authorization, без zombie-header в {@code apiClient.defaults}.</li>
|
||||
* <li>Security: access token не сохраняется в {@code localStorage} (XSS
|
||||
* persistent reach). См. также {@code oidcConfig.userStore} → sessionStorage.</li>
|
||||
* </ul>
|
||||
*/
|
||||
let accessToken: string | null = null
|
||||
|
||||
export const setAccessToken = (token: string | null): void => {
|
||||
accessToken = token
|
||||
}
|
||||
|
||||
export const getAccessToken = (): string | null => accessToken
|
||||
|
||||
/**
|
||||
* Handler для 401 → silent renew или redirect. Регистрируется {@link TokenSync}
|
||||
* один раз. Все 401 проходят через единственный interceptor ниже —
|
||||
* двойной handler (как раньше: один в client.ts, второй в TokenSync) приводил
|
||||
* к race signinSilent vs signinRedirect и redirect loop.
|
||||
*/
|
||||
export type Unauthorized401Handler = (error: unknown) => void
|
||||
let unauthorizedHandler: Unauthorized401Handler | null = null
|
||||
|
||||
export const setUnauthorizedHandler = (h: Unauthorized401Handler | null): void => {
|
||||
unauthorizedHandler = h
|
||||
}
|
||||
import { ensureFreshToken, getToken, isAuthenticated } from '@/lib/keycloak'
|
||||
|
||||
/**
|
||||
* baseURL построение:
|
||||
@@ -61,36 +26,52 @@ export const apiClient = axios.create({
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
/**
|
||||
* Request interceptor — altum-style. Перед каждым запросом:
|
||||
* 1. Если юзер authenticated → ensureFreshToken(30): обновит если осталось
|
||||
* < 30с до exp; на refresh failure ВНУТРЬ keycloak-js делает kc.login()
|
||||
* (full redirect), interceptor не зацикливается.
|
||||
* 2. Подкидывает `Authorization: Bearer ${getToken()}` если токен есть.
|
||||
*
|
||||
* Зачем явный refresh-перед-запросом vs фоновый `automaticSilentRenew` от
|
||||
* oidc-client-ts: последний триггерил бесконечный POST /token loop когда
|
||||
* refresh expired (см. MR !269 revert). Явный путь — один backchannel call
|
||||
* перед запросом, никаких таймеров, никаких race'ов.
|
||||
*/
|
||||
apiClient.interceptors.request.use(async (config) => {
|
||||
const lang = (typeof navigator !== 'undefined' && navigator.language) || 'ru-RU'
|
||||
config.headers['Accept-Language'] = `${lang},ru-RU;q=0.9,en-US;q=0.8`
|
||||
if (accessToken) {
|
||||
config.headers['Authorization'] = `Bearer ${accessToken}`
|
||||
if (isAuthenticated()) {
|
||||
await ensureFreshToken(30)
|
||||
}
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
} else {
|
||||
delete config.headers['Authorization']
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Единственный 401 interceptor. Логика silent renew / redirect / loop guard
|
||||
// делегируется handler'у который TokenSync устанавливает через
|
||||
// setUnauthorizedHandler().
|
||||
//
|
||||
// 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'а.
|
||||
// 401 interceptor — простая модель: на 401 от backend'а пробуем silent
|
||||
// refresh + retry. На неудачу — full redirect через keycloak-js
|
||||
// (ensureFreshToken внутри сделает kc.login() при refresh fail).
|
||||
// Никаких 403→reauth (см. MR !269 revert): нельзя различить «нет JWT»
|
||||
// от «scope insufficient», старая попытка вешала цикл с Keycloak.
|
||||
apiClient.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(error) => {
|
||||
if (error?.response?.status === 401 && unauthorizedHandler) {
|
||||
async (error) => {
|
||||
if (error?.response?.status === 401 && !error.config?._retried) {
|
||||
try {
|
||||
unauthorizedHandler(error)
|
||||
error.config._retried = true
|
||||
await ensureFreshToken(0)
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
error.config.headers['Authorization'] = `Bearer ${token}`
|
||||
return apiClient(error.config)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[api] unauthorized handler failed', e)
|
||||
console.error('[api] 401 retry failed', e)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
|
||||
Reference in New Issue
Block a user