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:
@@ -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",
|
||||
|
||||
Generated
+35
-32
@@ -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>
|
||||
@@ -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,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,5 +1,5 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { useAuth } from 'react-oidc-context'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
|
||||
/**
|
||||
* Auth gate с гостевым режимом view-only.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,4 +1,4 @@
|
||||
import { useAuth } from 'react-oidc-context'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
|
||||
/**
|
||||
* Может ли текущий пользователь выполнять mutation операции (create / update /
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user