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:
@@ -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