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). * *

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 | 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 => { 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 => { 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 => { return getKeycloak().login({ redirectUri: redirectUri ?? window.location.href, }) } /** Logout — закрывает Keycloak session + redirect на home. */ export const startLogout = (): Promise => { 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 }