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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user