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
}