7e435e07b7
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.
125 lines
4.8 KiB
TypeScript
125 lines
4.8 KiB
TypeScript
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
|
||
}
|