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:
Zimin A.N.
2026-05-26 11:18:30 +03:00
parent 0aaae9cb2b
commit 7e435e07b7
21 changed files with 394 additions and 269 deletions
+3 -3
View File
@@ -40,9 +40,9 @@
"d3-geo": "^3.1.1",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.0.2",
"keycloak-js": "^26.2.4",
"leaflet": "^1.9.4",
"lucide-react": "^1.14.0",
"oidc-client-ts": "^3.5.0",
"react": "^19.0.0",
"react-day-picker": "^10.0.0",
"react-dom": "^19.0.0",
@@ -50,13 +50,13 @@
"react-hook-form": "^7.54.2",
"react-i18next": "^17.0.2",
"react-leaflet": "^5.0.0",
"react-oidc-context": "^3.3.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"three": "^0.184.0",
"topojson-client": "^3.1.0",
"tw-animate-css": "^1.4.0",
"world-atlas": "^2.0.2"
"world-atlas": "^2.0.2",
"zustand": "^5.0.13"
},
"devDependencies": {
"@semantic-release/changelog": "^6.0.3",
+35 -32
View File
@@ -83,15 +83,15 @@ importers:
i18next-browser-languagedetector:
specifier: ^8.0.2
version: 8.2.1
keycloak-js:
specifier: ^26.2.4
version: 26.2.4
leaflet:
specifier: ^1.9.4
version: 1.9.4
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.5)
oidc-client-ts:
specifier: ^3.5.0
version: 3.5.0
react:
specifier: ^19.0.0
version: 19.2.5
@@ -113,9 +113,6 @@ importers:
react-leaflet:
specifier: ^5.0.0
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-oidc-context:
specifier: ^3.3.1
version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.5)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -134,6 +131,9 @@ importers:
world-atlas:
specifier: ^2.0.2
version: 2.0.2
zustand:
specifier: ^5.0.13
version: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
devDependencies:
'@semantic-release/changelog':
specifier: ^6.0.3
@@ -2691,14 +2691,13 @@ packages:
jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
kapsule@1.16.3:
resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==}
engines: {node: '>=12'}
keycloak-js@26.2.4:
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
@@ -3065,10 +3064,6 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
oidc-client-ts@3.5.0:
resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==}
engines: {node: '>=18'}
onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
@@ -3322,13 +3317,6 @@ packages:
react: ^19.0.0
react-dom: ^19.0.0
react-oidc-context@3.3.1:
resolution: {integrity: sha512-/Azvm9W4DhhOtSDBE73kFInh1b6zZRRfILKbgmk2syExMF0PCYJOn/dGdOOi2BFX8x0rCeUe45NXHU+/+xDcrQ==}
engines: {node: '>=18'}
peerDependencies:
oidc-client-ts: ^3.1.0
react: '>=16.14.0'
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -3986,6 +3974,24 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zustand@5.0.13:
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
3d-force-graph@1.80.0:
@@ -6363,12 +6369,12 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jwt-decode@4.0.0: {}
kapsule@1.16.3:
dependencies:
lodash-es: 4.18.1
keycloak-js@26.2.4: {}
keyv@5.6.0:
dependencies:
'@keyv/serialize': 1.1.1
@@ -6605,10 +6611,6 @@ snapshots:
object-assign@4.1.1: {}
oidc-client-ts@3.5.0:
dependencies:
jwt-decode: 4.0.0
onetime@5.1.2:
dependencies:
mimic-fn: 2.1.0
@@ -6820,11 +6822,6 @@ snapshots:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-oidc-context@3.3.1(oidc-client-ts@3.5.0)(react@19.2.5):
dependencies:
oidc-client-ts: 3.5.0
react: 19.2.5
react-refresh@0.17.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5):
@@ -7440,3 +7437,9 @@ snapshots:
yoctocolors@2.1.2: {}
zod@3.25.76: {}
zustand@5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)):
optionalDependencies:
'@types/react': 19.2.14
react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5)
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<body>
<script>
// keycloak-js silent-check-sso endpoint. Загружается в iframe или
// окне через silentCheckSsoRedirectUri. Передаёт результат проверки
// SSO-сессии обратно в parent через postMessage.
parent.postMessage(location.href, location.origin)
</script>
</body>
</html>
+35 -54
View File
@@ -1,40 +1,5 @@
import axios from 'axios'
/**
* Source of truth для access token. {@link TokenSync} обновляет это значение
* синхронно при изменении OIDC user state. Interceptor читает из in-memory
* holder'а — никаких {@code defaults.headers}, никаких {@code localStorage} poll'ов.
*
* <p>Зачем in-memory holder, не localStorage:
* <ul>
* <li>Race-free: interceptor и TokenSync видят один и тот же token immediately
* после {@code setAccessToken}, без ожидания useEffect cycle.</li>
* <li>Logout: {@code setAccessToken(null)} следующий request immediately
* без Authorization, без zombie-header в {@code apiClient.defaults}.</li>
* <li>Security: access token не сохраняется в {@code localStorage} (XSS
* persistent reach). См. также {@code oidcConfig.userStore} sessionStorage.</li>
* </ul>
*/
let accessToken: string | null = null
export const setAccessToken = (token: string | null): void => {
accessToken = token
}
export const getAccessToken = (): string | null => accessToken
/**
* Handler для 401 silent renew или redirect. Регистрируется {@link TokenSync}
* один раз. Все 401 проходят через единственный interceptor ниже
* двойной handler (как раньше: один в client.ts, второй в TokenSync) приводил
* к race signinSilent vs signinRedirect и redirect loop.
*/
export type Unauthorized401Handler = (error: unknown) => void
let unauthorizedHandler: Unauthorized401Handler | null = null
export const setUnauthorizedHandler = (h: Unauthorized401Handler | null): void => {
unauthorizedHandler = h
}
import { ensureFreshToken, getToken, isAuthenticated } from '@/lib/keycloak'
/**
* baseURL построение:
@@ -61,36 +26,52 @@ export const apiClient = axios.create({
timeout: 10_000,
})
apiClient.interceptors.request.use((config) => {
/**
* Request interceptor altum-style. Перед каждым запросом:
* 1. Если юзер authenticated ensureFreshToken(30): обновит если осталось
* < 30с до exp; на refresh failure ВНУТРЬ keycloak-js делает kc.login()
* (full redirect), interceptor не зацикливается.
* 2. Подкидывает `Authorization: Bearer ${getToken()}` если токен есть.
*
* Зачем явный refresh-перед-запросом vs фоновый `automaticSilentRenew` от
* oidc-client-ts: последний триггерил бесконечный POST /token loop когда
* refresh expired (см. MR !269 revert). Явный путь один backchannel call
* перед запросом, никаких таймеров, никаких race'ов.
*/
apiClient.interceptors.request.use(async (config) => {
const lang = (typeof navigator !== 'undefined' && navigator.language) || 'ru-RU'
config.headers['Accept-Language'] = `${lang},ru-RU;q=0.9,en-US;q=0.8`
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`
if (isAuthenticated()) {
await ensureFreshToken(30)
}
const token = getToken()
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
} else {
delete config.headers['Authorization']
}
return config
})
// Единственный 401 interceptor. Логика silent renew / redirect / loop guard
// делегируется handler'у который TokenSync устанавливает через
// setUnauthorizedHandler().
//
// 403 НЕ цепляем: на проде ORDINIS_AUTH_REQUIRED=false → backend отдаёт 403
// для anonymous (нет JWT) ИЛИ для legitimate scope-deficiency (юзер с
// токеном но без INTERNAL). Невозможно reliably различить из ответа,
// раннее попытка триггерить reauth на 403+!accessToken приводила к
// бесконечному циклу с Keycloak (signinSilent → 400 → catch → ещё
// signinSilent через automaticSilentRenew). Лучше показать 403 и оставить
// юзеру manual relogin, чем заспамить /token endpoint Keycloak'а.
// 401 interceptor — простая модель: на 401 от backend'а пробуем silent
// refresh + retry. На неудачу — full redirect через keycloak-js
// (ensureFreshToken внутри сделает kc.login() при refresh fail).
// Никаких 403→reauth (см. MR !269 revert): нельзя различить «нет JWT»
// от «scope insufficient», старая попытка вешала цикл с Keycloak.
apiClient.interceptors.response.use(
(resp) => resp,
(error) => {
if (error?.response?.status === 401 && unauthorizedHandler) {
async (error) => {
if (error?.response?.status === 401 && !error.config?._retried) {
try {
unauthorizedHandler(error)
error.config._retried = true
await ensureFreshToken(0)
const token = getToken()
if (token) {
error.config.headers['Authorization'] = `Bearer ${token}`
return apiClient(error.config)
}
} catch (e) {
console.error('[api] unauthorized handler failed', e)
console.error('[api] 401 retry failed', e)
}
}
return Promise.reject(error)
+1 -1
View File
@@ -1,4 +1,4 @@
import { useAuth } from 'react-oidc-context'
import { useAuth } from '@/auth/useAuth'
import { useTranslation } from 'react-i18next'
import { IconButton } from '@/ui'
import { SignInIcon, SignOutIcon, UserIcon } from '@phosphor-icons/react'
+1 -1
View File
@@ -1,5 +1,5 @@
import { type ReactNode } from 'react'
import { useAuth } from 'react-oidc-context'
import { useAuth } from '@/auth/useAuth'
/**
* Auth gate с гостевым режимом view-only.
-97
View File
@@ -1,97 +0,0 @@
import { useEffect, useRef } from 'react'
import { useAuth } from 'react-oidc-context'
import { setAccessToken, setUnauthorizedHandler } from '@/api/client'
import { LEGACY_TOKEN_STORAGE_KEY } from './oidcConfig'
/**
* Синхронизирует {@code access_token} из react-oidc-context в API client
* (in-memory holder) и подключает 401 handler на silent renew / redirect.
*
* <p>Mount внутри {@code <AuthProvider>}, рендерит null. Должен быть один
* экземпляр на app handler ставится глобально.
*
* <p><b>Single source of truth для 401:</b> раньше был дубликат interceptor'а
* в {@code api/client.ts} (просто удалял token из localStorage) + ещё один
* здесь (signinSilent/Redirect). Оба срабатывали на одном response race и
* потенциальный redirect loop. Теперь handler один, через
* {@link setUnauthorizedHandler}.
*
* <p><b>Loop guard:</b> {@code redirecting} ref предотвращает burst
* параллельных signinRedirect'ов при N concurrent 401 (типичная картина
* после истечения токена все pending requests возвращают 401 одновременно).
*/
export function TokenSync() {
const auth = useAuth()
const redirecting = useRef(false)
// 1) Token sync — single update path к accessToken holder в client.ts.
// ВАЖНО: пропускаем токен ТОЛЬКО когда user реально authenticated AND token
// не expired. Раньше передавали raw {@code auth.user?.access_token} — это
// включало случаи когда oidc-client-ts вернул user object с уже expired
// token'ом (silent renew failed но user object всё ещё в state). axios
// отправлял Bearer expired_token → backend (с permissive=false для guest
// anyway re-validates JWT через oauth2ResourceServer) → 401 → catalog UI
// показывал «Не удалось загрузить данные» вместо PUBLIC dicts.
//
// user.expired property из oidc-client-ts: true если access_token expiry
// прошла. Дополнительно проверяем isAuthenticated — react-oidc-context
// обновляет это в реальном времени.
useEffect(() => {
const liveToken =
auth.isAuthenticated && auth.user && !auth.user.expired
? auth.user.access_token
: null
setAccessToken(liveToken ?? null)
// Cleanup legacy localStorage key (was used до fix #12 + #2/3/13 cascade).
// Идемпотентный no-op если ключа нет.
if (typeof window !== 'undefined') {
window.localStorage.removeItem(LEGACY_TOKEN_STORAGE_KEY)
}
}, [auth.isAuthenticated, auth.user, auth.user?.access_token, auth.user?.expired])
// 2) 401 handler — registered once на app mount, делегирует silent renew /
// redirect на Keycloak. Guard'ы:
// - auth.isLoading / activeNavigator: OIDC bootstrap или redirect уже в
// progress — не дублируем.
// - URL содержит ?error=: вернулись из Keycloak с провалом — пусть UI
// покажет RequireAuth error screen, не зацикливаемся.
// - redirecting ref: при burst 401 (N concurrent requests) первый
// handler инициирует redirect, остальные no-op до его завершения.
useEffect(() => {
setUnauthorizedHandler(() => {
if (auth.isLoading || auth.activeNavigator) return
if (typeof window !== 'undefined' && window.location.search.includes('error=')) {
return
}
if (redirecting.current) return
redirecting.current = true
const reset = () => {
redirecting.current = false
}
if (auth.isAuthenticated) {
// Authenticated user hit 401 → token истёк. Silent renew, иначе full
// redirect для re-login.
auth
.signinSilent()
.then(reset)
.catch(() => {
// Silent renew не удался — full redirect. reset не нужен:
// page reload сбросит state. Но catch всё равно нужен на случай
// если redirect promise отвергается (Keycloak unreachable).
auth.signinRedirect().catch(reset)
})
} else {
// Anonymous user попытался hit auth-required endpoint (mutation,
// INTERNAL/RESTRICTED scope dictionary). Не редиректим автоматически —
// UI должен сам показать «Войдите для редактирования» CTA, а юзер
// сознательно кликнет «Войти». Авто-redirect ломает guest mode:
// случайный 401 от любого API (например INTERNAL fetch) выкинул бы
// в Keycloak без явного намерения.
reset()
}
})
return () => setUnauthorizedHandler(null)
}, [auth])
return null
}
-56
View File
@@ -1,56 +0,0 @@
import { WebStorageStateStore, type User, type UserManagerSettings } from 'oidc-client-ts'
import type { AuthProviderProps } from 'react-oidc-context'
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'
const authority = `${KC_URL}/realms/${KC_REALM}`
const baseSettings: UserManagerSettings = {
authority,
client_id: KC_CLIENT_ID,
redirect_uri: typeof window !== 'undefined' ? window.location.origin + '/' : '/',
post_logout_redirect_uri:
typeof window !== 'undefined' ? window.location.origin + '/' : '/',
scope: 'openid profile email',
response_type: 'code',
automaticSilentRenew: true,
loadUserInfo: false,
// sessionStorage вместо localStorage: уменьшает XSS persistent reach на
// access_token + id_token. Trade-off: токен не переживает закрытие вкладки —
// на повторном открытии будет SSO redirect к Keycloak (но без user input
// если SSO session в Keycloak ещё жива, т.е. UX impact минимальный).
// Долгосрочное правильное решение — BFF + httpOnly cookie с refresh token,
// но это отдельный epic (server-side OIDC handler).
// См. также {@code apiClient}: access_token держится в in-memory holder'е,
// не читается из storage на каждый request.
userStore:
typeof window !== 'undefined'
? new WebStorageStateStore({ store: window.sessionStorage })
: undefined,
}
/**
* Конфиг для {@code <AuthProvider>}. PKCE flow, public client secret не нужен.
*
* <p>{@code onSigninCallback} удаляет {@code ?code=...&state=...} из URL после
* успешного login, чтобы TanStack Router не запутался.
*/
export const oidcAuthConfig: AuthProviderProps = {
...baseSettings,
onSigninCallback: (_user: User | void) => {
if (typeof window !== 'undefined') {
window.history.replaceState({}, document.title, window.location.pathname)
}
},
}
// TOKEN_STORAGE_KEY (legacy 'ordinis.token' в localStorage) удалён вместе с
// fix дублирующих 401-interceptor'ов. Access token больше не дублируется
// в localStorage из приложения — он живёт в memory holder'е (см. apiClient
// setAccessToken) и oidc-client-ts userStore (sessionStorage, fix #12).
//
// Cleanup: на первый mount old клиенты могут иметь старый ключ — TokenSync
// его удаляет.
export const LEGACY_TOKEN_STORAGE_KEY = 'ordinis.token'
+53
View File
@@ -0,0 +1,53 @@
import { useAuthStore } from '@/stores/authStore'
import { ensureFreshToken, startLogin, startLogout } from '@/lib/keycloak'
/**
* Compat-shim для `useAuth` API из react-oidc-context. Минимальный
* superset того, что использовалось в codebase (см. RequireAuth,
* useUserDisplay, Sidebar, MyDraftsPanel, reviews, etc.):
* - `auth.user.profile.sub` / `preferred_username` / `email` / `name`
* - `auth.user.expired`
* - `auth.user.access_token`
* - `auth.isAuthenticated` / `auth.isLoading`
* - `auth.signinSilent()` / `auth.signinRedirect()` / `auth.signoutRedirect()`
* - `auth.activeNavigator` (всегда undefined в keycloak-js модели)
*
* <p>Поверх zustand `useAuthStore` реактивно обновляется на
* keycloak-js callbacks (onAuthSuccess, onAuthRefreshSuccess, onTokenExpired,
* onAuthLogout). Permissions / role checks остаются работать без изменений.
*/
export const useAuth = () => {
const user = useAuthStore((s) => s.user)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const isLoading = useAuthStore((s) => s.isLoading)
const error = useAuthStore((s) => s.error)
return {
user,
isAuthenticated,
isLoading,
error,
activeNavigator: undefined,
/** Forced silent refresh wraps ensureFreshToken(0). На failure
* keycloak-js делает kc.login() автоматически user редиректится. */
signinSilent: async () => {
await ensureFreshToken(0)
return useAuthStore.getState().user
},
signinRedirect: async () => {
await startLogin()
},
signoutRedirect: async () => {
await startLogout()
},
/** Local-only logout (clear store без redirect на Keycloak). Зеркало
* `react-oidc-context removeUser` callsites используют для "забыть
* user" без full logout flow. В keycloak-js модели соответствует
* очистке локального state следующий ensureFreshToken покажет
* Login. */
removeUser: async () => {
const { user, ...rest } = useAuthStore.getState()
void user
useAuthStore.setState({ ...rest, user: null, isAuthenticated: false })
},
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { useAuth } from 'react-oidc-context'
import { useAuth } from '@/auth/useAuth'
/**
* Может ли текущий пользователь выполнять mutation операции (create / update /
+1 -1
View File
@@ -1,4 +1,4 @@
import { useAuth } from 'react-oidc-context'
import { useAuth } from '@/auth/useAuth'
/**
* Permission helpers для schema + record workflow. Phase 1d **ACTIVE**:
@@ -11,7 +11,7 @@ import {
Search,
type LucideIcon,
} from 'lucide-react'
import { useAuth } from 'react-oidc-context'
import { useAuth } from '@/auth/useAuth'
import { useCanMutate } from '@/auth/useCanMutate'
import {
useCanReviewRecord,
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from 'react-oidc-context'
import { useAuth } from '@/auth/useAuth'
import {
Badge,
Button,
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth } from 'react-oidc-context'
import { useAuth } from '@/auth/useAuth'
import axios from 'axios'
import { Badge, Button, Drawer, LoadingBlock, QueryErrorState, TextArea, toast } from '@/ui'
import {
+124
View File
@@ -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
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useAuth } from 'react-oidc-context'
import { useAuth } from '@/auth/useAuth'
import { apiClient } from '@/api/client'
/**
+17 -16
View File
@@ -3,11 +3,9 @@ import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { Toaster, TooltipProvider } from '@/ui'
import { AuthProvider } from 'react-oidc-context'
import { routeTree } from './routeTree.gen'
import { oidcAuthConfig } from '@/auth/oidcConfig'
import { TokenSync } from '@/auth/TokenSync'
import { RequireAuth } from '@/auth/RequireAuth'
import { useAuthStore } from '@/stores/authStore'
import { ThemeProvider } from '@/stores/ThemeProvider'
import './i18n'
import './styles.css'
@@ -26,20 +24,23 @@ declare module '@tanstack/react-router' {
}
}
// keycloak-js init на старте (altum pattern). checkAuth дожидается initKeycloak
// и заполняет zustand `useAuthStore`. apiClient request-interceptor дёргает
// ensureFreshToken перед каждым запросом — нет фонового automaticSilentRenew
// таймера, нет петель POST /token.
void useAuthStore.getState().checkAuth()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider {...oidcAuthConfig}>
<TokenSync />
<ThemeProvider>
<TooltipProvider delayDuration={150}>
<RequireAuth>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster />
</QueryClientProvider>
</RequireAuth>
</TooltipProvider>
</ThemeProvider>
</AuthProvider>
<ThemeProvider>
<TooltipProvider delayDuration={150}>
<RequireAuth>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster />
</QueryClientProvider>
</RequireAuth>
</TooltipProvider>
</ThemeProvider>
</StrictMode>,
)
+1 -1
View File
@@ -1,6 +1,6 @@
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
import { useEffect } from 'react'
import { useAuth } from 'react-oidc-context'
import { useAuth } from '@/auth/useAuth'
export const Route = createFileRoute('/')({
beforeLoad: ({ location }) => {
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from 'react-oidc-context'
import { useAuth } from '@/auth/useAuth'
import {
Button,
EmptyState,
+1 -1
View File
@@ -24,7 +24,7 @@ import {
QueryErrorState,
} from '@/ui'
import axios from 'axios'
import { useAuth } from 'react-oidc-context'
import { useAuth } from '@/auth/useAuth'
import { ArrowCounterClockwiseIcon, CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
import {
useDictionaries,
+105
View File
@@ -0,0 +1,105 @@
import { create } from 'zustand'
import {
getKeycloak,
initKeycloak,
startLogin,
startLogout,
subscribeAuthState,
wireKeycloakEvents,
} from '@/lib/keycloak'
/**
* Auth-store на zustand заменяет react-oidc-context AuthProvider.
*
* <p>Pattern из altum (`altum/src/store/authStore.ts`):
* `checkAuth()` дожидается `initKeycloak`, забирает claims из
* `kc.tokenParsed`, кладёт user-info в store. `login/logout` обёртки
* над keycloak-js.
*
* <p>Поддерживает изоморфный API с предыдущим `useAuth()` из
* react-oidc-context см. `auth/useAuth.ts` compat-shim.
*/
export interface KeycloakProfile {
sub?: string
email?: string
name?: string
preferred_username?: string
picture?: string
realm_access?: { roles?: string[] }
[key: string]: unknown
}
export interface AuthUser {
profile: KeycloakProfile
access_token: string | undefined
expired: boolean
}
interface AuthStore {
user: AuthUser | null
isAuthenticated: boolean
isLoading: boolean
/** Error from initKeycloak (Keycloak unreachable / OIDC discovery failed).
* Mirrors react-oidc-context `auth.error` field RequireAuth использует
* для тихого fallback'а в guest mode. */
error: Error | null
checkAuth: () => Promise<void>
login: () => void
logout: () => Promise<void>
/** Re-read keycloak state в store (вызывается keycloak-js callbacks). */
syncFromKeycloak: () => void
}
const snapshotUser = (): AuthUser | null => {
const kc = getKeycloak()
if (!kc.authenticated || !kc.tokenParsed) return null
const tp = kc.tokenParsed as KeycloakProfile
return {
profile: tp,
access_token: kc.token,
expired: !kc.authenticated || (typeof tp.exp === 'number' && tp.exp * 1000 < Date.now()),
}
}
export const useAuthStore = create<AuthStore>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: true,
error: null,
checkAuth: async () => {
set({ isLoading: true, error: null })
try {
await initKeycloak()
wireKeycloakEvents()
// На каждый keycloak-js callback (login/refresh/logout/expire)
// ре-снэпшотим user → стор обновляется → подписчики ре-рендерятся.
subscribeAuthState(() => {
get().syncFromKeycloak()
})
get().syncFromKeycloak()
} catch (e) {
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: e instanceof Error ? e : new Error(String(e)),
})
}
},
syncFromKeycloak: () => {
const user = snapshotUser()
set({ user, isAuthenticated: !!user, isLoading: false })
},
login: () => {
void startLogin()
},
logout: async () => {
set({ user: null, isAuthenticated: false })
await startLogout()
},
}))