diff --git a/ordinis-admin-ui/package.json b/ordinis-admin-ui/package.json index 32959c6..b0c82f2 100644 --- a/ordinis-admin-ui/package.json +++ b/ordinis-admin-ui/package.json @@ -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", diff --git a/ordinis-admin-ui/pnpm-lock.yaml b/ordinis-admin-ui/pnpm-lock.yaml index 2bb8b3a..c686e44 100644 --- a/ordinis-admin-ui/pnpm-lock.yaml +++ b/ordinis-admin-ui/pnpm-lock.yaml @@ -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) diff --git a/ordinis-admin-ui/public/silent-check-sso.html b/ordinis-admin-ui/public/silent-check-sso.html new file mode 100644 index 0000000..2762a8c --- /dev/null +++ b/ordinis-admin-ui/public/silent-check-sso.html @@ -0,0 +1,11 @@ + + + + + + diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 4800f4d..a8cc54c 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -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'ов. - * - *

Зачем in-memory holder, не localStorage: - *

- */ -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) diff --git a/ordinis-admin-ui/src/auth/AuthBadge.tsx b/ordinis-admin-ui/src/auth/AuthBadge.tsx index e12d656..8d698f2 100644 --- a/ordinis-admin-ui/src/auth/AuthBadge.tsx +++ b/ordinis-admin-ui/src/auth/AuthBadge.tsx @@ -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' diff --git a/ordinis-admin-ui/src/auth/RequireAuth.tsx b/ordinis-admin-ui/src/auth/RequireAuth.tsx index 7e9167c..3ce7136 100644 --- a/ordinis-admin-ui/src/auth/RequireAuth.tsx +++ b/ordinis-admin-ui/src/auth/RequireAuth.tsx @@ -1,5 +1,5 @@ import { type ReactNode } from 'react' -import { useAuth } from 'react-oidc-context' +import { useAuth } from '@/auth/useAuth' /** * Auth gate с гостевым режимом view-only. diff --git a/ordinis-admin-ui/src/auth/TokenSync.tsx b/ordinis-admin-ui/src/auth/TokenSync.tsx deleted file mode 100644 index f836685..0000000 --- a/ordinis-admin-ui/src/auth/TokenSync.tsx +++ /dev/null @@ -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. - * - *

Mount внутри {@code }, рендерит null. Должен быть один - * экземпляр на app — handler ставится глобально. - * - *

Single source of truth для 401: раньше был дубликат interceptor'а - * в {@code api/client.ts} (просто удалял token из localStorage) + ещё один - * здесь (signinSilent/Redirect). Оба срабатывали на одном response — race и - * потенциальный redirect loop. Теперь handler один, через - * {@link setUnauthorizedHandler}. - * - *

Loop guard: {@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 -} diff --git a/ordinis-admin-ui/src/auth/oidcConfig.ts b/ordinis-admin-ui/src/auth/oidcConfig.ts deleted file mode 100644 index 12cee86..0000000 --- a/ordinis-admin-ui/src/auth/oidcConfig.ts +++ /dev/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 }. PKCE flow, public client — secret не нужен. - * - *

{@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' diff --git a/ordinis-admin-ui/src/auth/useAuth.ts b/ordinis-admin-ui/src/auth/useAuth.ts new file mode 100644 index 0000000..76a809d --- /dev/null +++ b/ordinis-admin-ui/src/auth/useAuth.ts @@ -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 модели) + * + *

Поверх 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 }) + }, + } +} diff --git a/ordinis-admin-ui/src/auth/useCanMutate.ts b/ordinis-admin-ui/src/auth/useCanMutate.ts index 43cfa21..958b5ca 100644 --- a/ordinis-admin-ui/src/auth/useCanMutate.ts +++ b/ordinis-admin-ui/src/auth/useCanMutate.ts @@ -1,4 +1,4 @@ -import { useAuth } from 'react-oidc-context' +import { useAuth } from '@/auth/useAuth' /** * Может ли текущий пользователь выполнять mutation операции (create / update / diff --git a/ordinis-admin-ui/src/auth/usePermissions.ts b/ordinis-admin-ui/src/auth/usePermissions.ts index f77ef7b..84af018 100644 --- a/ordinis-admin-ui/src/auth/usePermissions.ts +++ b/ordinis-admin-ui/src/auth/usePermissions.ts @@ -1,4 +1,4 @@ -import { useAuth } from 'react-oidc-context' +import { useAuth } from '@/auth/useAuth' /** * Permission helpers для schema + record workflow. Phase 1d — **ACTIVE**: diff --git a/ordinis-admin-ui/src/components/layout/Sidebar.tsx b/ordinis-admin-ui/src/components/layout/Sidebar.tsx index ca58234..8deaa1f 100644 --- a/ordinis-admin-ui/src/components/layout/Sidebar.tsx +++ b/ordinis-admin-ui/src/components/layout/Sidebar.tsx @@ -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, diff --git a/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx b/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx index 11c43ea..4799d1a 100644 --- a/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx +++ b/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx @@ -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, diff --git a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx index 29a21f8..a7b5a52 100644 --- a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx +++ b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx @@ -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 { diff --git a/ordinis-admin-ui/src/lib/keycloak.ts b/ordinis-admin-ui/src/lib/keycloak.ts new file mode 100644 index 0000000..d0e0c36 --- /dev/null +++ b/ordinis-admin-ui/src/lib/keycloak.ts @@ -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). + * + *

Default URL = `auth.nstart.space/realms/nstart/clients/ordinis` — + * текущий ordinis realm. Phase 2 (отдельный эпик) — миграция на + * `altum.nstart.cloud/auth/realms/altum` если решим SSO с altum. + */ +const KC_URL = import.meta.env.VITE_KEYCLOAK_URL ?? 'https://auth.nstart.space' +const KC_REALM = import.meta.env.VITE_KEYCLOAK_REALM ?? 'nstart' +const KC_CLIENT_ID = import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'ordinis' + +let _kc: Keycloak | null = null +let _initPromise: Promise | null = null + +export const getKeycloak = (): Keycloak => { + if (_kc === null) { + _kc = new Keycloak({ url: KC_URL, realm: KC_REALM, clientId: KC_CLIENT_ID }) + // Debug hook: window.ordinisKc.tokenParsed.realm_access.roles в DevTools. + if (typeof window !== 'undefined') { + ;(window as unknown as { ordinisKc?: Keycloak }).ordinisKc = _kc + } + } + return _kc +} + +/** + * Инициализирует Keycloak один раз. `check-sso` — silent проверка через + * iframe (без forced redirect). После init `kc.authenticated` = true/false. + * + * Возвращает был-ли-уже-аутентифицирован. + */ +export const initKeycloak = (): Promise => { + if (_initPromise) return _initPromise + const kc = getKeycloak() + _initPromise = kc + .init({ + onLoad: 'check-sso', + pkceMethod: 'S256', + silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, + // iframe-based check ломается с CSP на некоторых ingress'ах + // (altum-lesson + наш handoff). check-sso через redirect использует + // silentCheckSsoRedirectUri вместо iframe — безопаснее. + checkLoginIframe: false, + }) + .then((auth) => { + _authStateSubscribers.forEach((cb) => cb()) + return auth + }) + .catch((err) => { + console.warn('Keycloak init failed:', err) + return false + }) + return _initPromise +} + +/** + * Обновить access_token если до exp => { + const kc = getKeycloak() + if (!kc.authenticated) return false + try { + const refreshed = await kc.updateToken(minValidity) + if (refreshed) _authStateSubscribers.forEach((cb) => cb()) + return refreshed + } catch (err) { + console.warn('Token refresh failed, redirecting to login:', err) + await kc.login() + return false + } +} + +export const getToken = (): string | undefined => getKeycloak().token +export const isAuthenticated = (): boolean => !!getKeycloak().authenticated + +/** Запустить login flow (PKCE). После успеха возвращает на текущий URL. */ +export const startLogin = (redirectUri?: string): Promise => { + return getKeycloak().login({ + redirectUri: redirectUri ?? window.location.href, + }) +} + +/** Logout — закрывает Keycloak session + redirect на home. */ +export const startLogout = (): Promise => { + return getKeycloak().logout({ redirectUri: window.location.origin }) +} + +// === Lightweight subscription API для Zustand-store ==================== +// keycloak-js не event-emitter из коробки в TS-смысле; делаем простую +// сабскрипшн-модель на init/refresh/logout — store вызывает rerender. + +type Unsubscribe = () => void +const _authStateSubscribers = new Set<() => void>() + +export const subscribeAuthState = (cb: () => void): Unsubscribe => { + _authStateSubscribers.add(cb) + return () => { + _authStateSubscribers.delete(cb) + } +} + +/** Wire native Keycloak callbacks → notify store. Вызывается один раз + * после initKeycloak resolves. */ +export const wireKeycloakEvents = (): void => { + const kc = getKeycloak() + const notify = () => _authStateSubscribers.forEach((cb) => cb()) + kc.onAuthSuccess = notify + kc.onAuthLogout = notify + kc.onAuthRefreshSuccess = notify + kc.onAuthRefreshError = notify + kc.onTokenExpired = notify +} diff --git a/ordinis-admin-ui/src/lib/useUserDisplay.tsx b/ordinis-admin-ui/src/lib/useUserDisplay.tsx index 4115416..c763fa5 100644 --- a/ordinis-admin-ui/src/lib/useUserDisplay.tsx +++ b/ordinis-admin-ui/src/lib/useUserDisplay.tsx @@ -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' /** diff --git a/ordinis-admin-ui/src/main.tsx b/ordinis-admin-ui/src/main.tsx index 3c0febe..7d76a4e 100644 --- a/ordinis-admin-ui/src/main.tsx +++ b/ordinis-admin-ui/src/main.tsx @@ -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( - - - - - - - - - - - - - + + + + + + + + + + , ) diff --git a/ordinis-admin-ui/src/routes/index.tsx b/ordinis-admin-ui/src/routes/index.tsx index 2697f3a..c3c3876 100644 --- a/ordinis-admin-ui/src/routes/index.tsx +++ b/ordinis-admin-ui/src/routes/index.tsx @@ -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 }) => { diff --git a/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx b/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx index a1aca9b..cfc07d8 100644 --- a/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx +++ b/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx @@ -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, diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx index 6481435..7673031 100644 --- a/ordinis-admin-ui/src/routes/reviews.tsx +++ b/ordinis-admin-ui/src/routes/reviews.tsx @@ -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, diff --git a/ordinis-admin-ui/src/stores/authStore.ts b/ordinis-admin-ui/src/stores/authStore.ts new file mode 100644 index 0000000..082e3d1 --- /dev/null +++ b/ordinis-admin-ui/src/stores/authStore.ts @@ -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. + * + *

Pattern из altum (`altum/src/store/authStore.ts`): + * `checkAuth()` дожидается `initKeycloak`, забирает claims из + * `kc.tokenParsed`, кладёт user-info в store. `login/logout` — обёртки + * над keycloak-js. + * + *

Поддерживает изоморфный 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 + login: () => void + logout: () => Promise + /** 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((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() + }, +}))