feat(admin-ui): SSO через Keycloak (PKCE, public client)

react-oidc-context + oidc-client-ts. Confidential client тоже работает —
SPA просто не использует client_secret.

- src/auth/oidcConfig.ts — UserManager settings (authority, redirect_uri,
  PKCE, automaticSilentRenew). VITE_KEYCLOAK_{URL,REALM,CLIENT_ID} с дефолтами
  на nstart realm + ordinis client.
- src/auth/TokenSync.tsx — синкает access_token в localStorage и axios default
  header после login/refresh. Existing API код не трогали — interceptor уже
  читает localStorage.
  Bonus: 401 interceptor триггерит signinSilent → signinRedirect fallback.
- src/auth/AuthBadge.tsx — header'ный бейдж: "Войти" если anonymous, имя +
  logout если залогинен.
- main.tsx обёрнут в <AuthProvider>.
- __root.tsx показывает AuthBadge рядом с language switch.
- .env.example с конфигом Keycloak.
- i18n auth.login / auth.logout (RU + EN).

Keycloak client `ordinis` — настроить:
  Valid redirect URIs: https://ordinis.k8s.265.nstart.cloud/*,
                       https://ordinis.k8s.264.nstart.cloud/*,
                       http://localhost:5173/*
  Web Origins: те же + (или "+"), PKCE Code Challenge: S256
This commit is contained in:
Zimin A.N.
2026-05-04 18:28:31 +03:00
parent 2fc9b451e4
commit cef78f9971
9 changed files with 224 additions and 10 deletions
+78
View File
@@ -0,0 +1,78 @@
import { useAuth } from 'react-oidc-context'
import { useTranslation } from 'react-i18next'
import { Button, IconButton } from '@nstart/ui'
import { SignInIcon, SignOutIcon, UserIcon } from '@phosphor-icons/react'
/**
* Бейдж в header'е: «Войти» если anonymous; имя + logout если залогинен.
*
* <p>Logout жмёт {@code signoutRedirect} — Keycloak инвалидирует SSO-сессию и
* вернёт на post-logout-uri (корень).
*/
export function AuthBadge() {
const { t } = useTranslation()
const auth = useAuth()
if (auth.isLoading) {
return (
<span className="text-2xs uppercase tracking-label text-carbon/60">
{t('loading')}
</span>
)
}
if (auth.error) {
// Не валим UI — просто кнопка для повтора login.
return (
<Button
type="button"
variant="secondary"
leftIcon={<SignInIcon size={16} />}
onClick={() => auth.signinRedirect()}
>
{t('auth.login')}
</Button>
)
}
if (!auth.isAuthenticated) {
return (
<Button
type="button"
variant="primary"
leftIcon={<SignInIcon size={16} />}
onClick={() => auth.signinRedirect()}
>
{t('auth.login')}
</Button>
)
}
const profile = auth.user?.profile
const display =
profile?.preferred_username ||
profile?.name ||
profile?.email ||
profile?.sub ||
'user'
return (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-sm text-carbon">
<UserIcon size={14} weight="duotone" />
<span className="hidden sm:inline">{display}</span>
</span>
<IconButton
type="button"
icon={<SignOutIcon weight="bold" />}
label={t('auth.logout')}
onClick={() =>
auth.signoutRedirect().catch(() => {
// Если Keycloak недоступен — просто очищаем локальный state.
auth.removeUser()
})
}
/>
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect } from 'react'
import { useAuth } from 'react-oidc-context'
import { apiClient } from '@/api/client'
import { TOKEN_STORAGE_KEY } from './oidcConfig'
/**
* Синхронизирует {@code access_token} из react-oidc-context в localStorage и
* axios default header. Existing API код ничего не знает про OIDC — просто
* читает токен через axios interceptor.
*
* <p>Mount внутри {@code <AuthProvider>}, рендерит null.
*/
export function TokenSync() {
const auth = useAuth()
useEffect(() => {
const token = auth.user?.access_token
if (token) {
localStorage.setItem(TOKEN_STORAGE_KEY, token)
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`
} else {
localStorage.removeItem(TOKEN_STORAGE_KEY)
delete apiClient.defaults.headers.common['Authorization']
}
}, [auth.user?.access_token])
// Авто-логин на 401 — токен мог истечь и silent renew не успел.
useEffect(() => {
const id = apiClient.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401 && auth.isAuthenticated) {
auth.signinSilent().catch(() => auth.signinRedirect())
}
return Promise.reject(err)
},
)
return () => apiClient.interceptors.response.eject(id)
}, [auth])
return null
}
+41
View File
@@ -0,0 +1,41 @@
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,
userStore:
typeof window !== 'undefined'
? new WebStorageStateStore({ store: window.localStorage })
: 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)
}
},
}
export const TOKEN_STORAGE_KEY = 'ordinis.token'
+4
View File
@@ -172,6 +172,8 @@ i18n
'field.frequency_bands': 'Частотные диапазоны',
'loading': 'Загрузка...',
'error.failed': 'Не удалось загрузить данные',
'auth.login': 'Войти',
'auth.logout': 'Выйти',
},
},
'en-US': {
@@ -336,6 +338,8 @@ i18n
'field.frequency_bands': 'Frequency bands',
'loading': 'Loading...',
'error.failed': 'Failed to load data',
'auth.login': 'Sign in',
'auth.logout': 'Sign out',
},
},
},
+13 -7
View File
@@ -3,7 +3,10 @@ import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { NStartUiProvider, DatePickerProvider, DATE_PICKER_LOCALE_RU } from '@nstart/ui'
import { AuthProvider } from 'react-oidc-context'
import { routeTree } from './routeTree.gen'
import { oidcAuthConfig } from '@/auth/oidcConfig'
import { TokenSync } from '@/auth/TokenSync'
import './i18n'
import './styles.css'
@@ -23,12 +26,15 @@ declare module '@tanstack/react-router' {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<NStartUiProvider>
<DatePickerProvider datePicker={DATE_PICKER_LOCALE_RU}>
<RouterProvider router={router} />
</DatePickerProvider>
</NStartUiProvider>
</QueryClientProvider>
<AuthProvider {...oidcAuthConfig}>
<TokenSync />
<QueryClientProvider client={queryClient}>
<NStartUiProvider>
<DatePickerProvider datePicker={DATE_PICKER_LOCALE_RU}>
<RouterProvider router={router} />
</DatePickerProvider>
</NStartUiProvider>
</QueryClientProvider>
</AuthProvider>
</StrictMode>,
)
+3 -1
View File
@@ -2,6 +2,7 @@ import { createRootRouteWithContext, Link, Outlet } from '@tanstack/react-router
import type { QueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { LanguageSwitch } from '@nstart/ui'
import { AuthBadge } from '@/auth/AuthBadge'
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: RootLayout,
@@ -44,12 +45,13 @@ function RootLayout() {
{t('nav.outbox')}
</Link>
</nav>
<div className="ml-auto">
<div className="ml-auto flex items-center gap-3">
<LanguageSwitch
value={i18n.language}
options={LANG_OPTIONS}
onChange={(id) => i18n.changeLanguage(id)}
/>
<AuthBadge />
</div>
</div>
</header>