Files
mdm-ordinis/ordinis-admin-ui/src/lib/keycloak.ts
T
Zimin A.N. 7e435e07b7 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.
2026-05-26 11:18:30 +03:00

125 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}