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:
Zimin A.N.
2026-05-26 11:18:30 +03:00
parent 0aaae9cb2b
commit 7e435e07b7
21 changed files with 394 additions and 269 deletions
+124
View File
@@ -0,0 +1,124 @@
import Keycloak from 'keycloak-js'
/**
* Ordinis Keycloak OIDC integration. Зеркало паттерна altum
* (`altum/src/lib/keycloak.ts`) — keycloak-js + explicit `updateToken`
* вместо oidc-client-ts `automaticSilentRenew` (последний триггерил
* бесконечную петлю POST /token когда refresh_token истекал, см.
* MR !269 revert).
*
* <p>Default URL = `auth.nstart.space/realms/nstart/clients/ordinis` —
* текущий ordinis realm. Phase 2 (отдельный эпик) — миграция на
* `altum.nstart.cloud/auth/realms/altum` если решим SSO с altum.
*/
const KC_URL = import.meta.env.VITE_KEYCLOAK_URL ?? 'https://auth.nstart.space'
const KC_REALM = import.meta.env.VITE_KEYCLOAK_REALM ?? 'nstart'
const KC_CLIENT_ID = import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'ordinis'
let _kc: Keycloak | null = null
let _initPromise: Promise<boolean> | null = null
export const getKeycloak = (): Keycloak => {
if (_kc === null) {
_kc = new Keycloak({ url: KC_URL, realm: KC_REALM, clientId: KC_CLIENT_ID })
// Debug hook: window.ordinisKc.tokenParsed.realm_access.roles в DevTools.
if (typeof window !== 'undefined') {
;(window as unknown as { ordinisKc?: Keycloak }).ordinisKc = _kc
}
}
return _kc
}
/**
* Инициализирует Keycloak один раз. `check-sso` — silent проверка через
* iframe (без forced redirect). После init `kc.authenticated` = true/false.
*
* Возвращает был-ли-уже-аутентифицирован.
*/
export const initKeycloak = (): Promise<boolean> => {
if (_initPromise) return _initPromise
const kc = getKeycloak()
_initPromise = kc
.init({
onLoad: 'check-sso',
pkceMethod: 'S256',
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
// iframe-based check ломается с CSP на некоторых ingress'ах
// (altum-lesson + наш handoff). check-sso через redirect использует
// silentCheckSsoRedirectUri вместо iframe — безопаснее.
checkLoginIframe: false,
})
.then((auth) => {
_authStateSubscribers.forEach((cb) => cb())
return auth
})
.catch((err) => {
console.warn('Keycloak init failed:', err)
return false
})
return _initPromise
}
/**
* Обновить access_token если до exp <minValidity сек. Вызывается перед
* каждым API-запросом в apiClient request-interceptor'е.
*
* Без петель: keycloak-js `updateToken(N)` сам делает один backchannel
* POST /token и возвращает boolean. Если refresh_token expired —
* Promise отвергается, caller (axios interceptor) сделает `kc.login()`
* (full redirect — без retry внутри API client'а).
*/
export const ensureFreshToken = async (minValidity = 30): Promise<boolean> => {
const kc = getKeycloak()
if (!kc.authenticated) return false
try {
const refreshed = await kc.updateToken(minValidity)
if (refreshed) _authStateSubscribers.forEach((cb) => cb())
return refreshed
} catch (err) {
console.warn('Token refresh failed, redirecting to login:', err)
await kc.login()
return false
}
}
export const getToken = (): string | undefined => getKeycloak().token
export const isAuthenticated = (): boolean => !!getKeycloak().authenticated
/** Запустить login flow (PKCE). После успеха возвращает на текущий URL. */
export const startLogin = (redirectUri?: string): Promise<void> => {
return getKeycloak().login({
redirectUri: redirectUri ?? window.location.href,
})
}
/** Logout — закрывает Keycloak session + redirect на home. */
export const startLogout = (): Promise<void> => {
return getKeycloak().logout({ redirectUri: window.location.origin })
}
// === Lightweight subscription API для Zustand-store ====================
// keycloak-js не event-emitter из коробки в TS-смысле; делаем простую
// сабскрипшн-модель на init/refresh/logout — store вызывает rerender.
type Unsubscribe = () => void
const _authStateSubscribers = new Set<() => void>()
export const subscribeAuthState = (cb: () => void): Unsubscribe => {
_authStateSubscribers.add(cb)
return () => {
_authStateSubscribers.delete(cb)
}
}
/** Wire native Keycloak callbacks → notify store. Вызывается один раз
* после initKeycloak resolves. */
export const wireKeycloakEvents = (): void => {
const kc = getKeycloak()
const notify = () => _authStateSubscribers.forEach((cb) => cb())
kc.onAuthSuccess = notify
kc.onAuthLogout = notify
kc.onAuthRefreshSuccess = notify
kc.onAuthRefreshError = notify
kc.onTokenExpired = notify
}