feat(auth): миграция oidc-client-ts → keycloak-js (altum-pattern)

Phase 1a из плана: только swap библиотеки, realm/URL остаются на
auth.nstart.space/realms/nstart. Phase 2 (intgr с altum auth-service)
— отдельный эпик.

Зачем. oidc-client-ts с automaticSilentRenew триггерил бесконечный
POST /token loop когда refresh_token истекал (см. MR !269 revert,
~30+ 400'к в console, страница залипала на 403). Altum давно ушёл
с oidc-client-ts на keycloak-js по той же причине.

Новая модель.
- src/lib/keycloak.ts — singleton + initKeycloak(check-sso, PKCE) +
  ensureFreshToken(N) wrapper над kc.updateToken(). Зеркало
  altum/src/lib/keycloak.ts.
- src/stores/authStore.ts — Zustand store: {user, isAuthenticated,
  isLoading, error}. checkAuth() ждёт initKeycloak, wireKeycloakEvents()
  → onAuthSuccess/Refresh/Logout/TokenExpired re-снэпшотят store.
- src/auth/useAuth.ts — compat shim для react-oidc-context API
  (auth.user.profile, isAuthenticated, signinSilent, signinRedirect,
  signoutRedirect, removeUser, error). Все 11 callsites продолжают
  работать без правок (только сменили путь import'а).
- src/api/client.ts — request interceptor дёргает ensureFreshToken(30)
  ПЕРЕД каждым запросом + attaches Bearer ${getToken()}. 401 interceptor
  делает ensureFreshToken(0) + retry один раз. Никаких таймеров, никаких
  setAccessToken/setUnauthorizedHandler holder'ов — keycloak-js сам source
  of truth.
- src/main.tsx — убран <AuthProvider>, <TokenSync />. Просто
  useAuthStore.getState().checkAuth() на старте, потом обычный render.
- public/silent-check-sso.html — endpoint для silentCheckSsoRedirectUri.

Удалены.
- src/auth/TokenSync.tsx — не нужен, ensureFreshToken на запросах.
- src/auth/oidcConfig.ts — не нужен, конфиг внутри lib/keycloak.ts.
- npm deps: react-oidc-context, oidc-client-ts.

Добавлены deps: keycloak-js@26.2.4, zustand@5.0.13.

Tests: 171/171 passed, TSC чистый.

Migration callsites — все unchanged по семантике благодаря compat shim.
This commit is contained in:
Zimin A.N.
2026-05-26 11:18:30 +03:00
parent 0aaae9cb2b
commit 7e435e07b7
21 changed files with 394 additions and 269 deletions
+105
View File
@@ -0,0 +1,105 @@
import { create } from 'zustand'
import {
getKeycloak,
initKeycloak,
startLogin,
startLogout,
subscribeAuthState,
wireKeycloakEvents,
} from '@/lib/keycloak'
/**
* Auth-store на zustand — заменяет react-oidc-context AuthProvider.
*
* <p>Pattern из altum (`altum/src/store/authStore.ts`):
* `checkAuth()` дожидается `initKeycloak`, забирает claims из
* `kc.tokenParsed`, кладёт user-info в store. `login/logout` — обёртки
* над keycloak-js.
*
* <p>Поддерживает изоморфный API с предыдущим `useAuth()` из
* react-oidc-context — см. `auth/useAuth.ts` compat-shim.
*/
export interface KeycloakProfile {
sub?: string
email?: string
name?: string
preferred_username?: string
picture?: string
realm_access?: { roles?: string[] }
[key: string]: unknown
}
export interface AuthUser {
profile: KeycloakProfile
access_token: string | undefined
expired: boolean
}
interface AuthStore {
user: AuthUser | null
isAuthenticated: boolean
isLoading: boolean
/** Error from initKeycloak (Keycloak unreachable / OIDC discovery failed).
* Mirrors react-oidc-context `auth.error` field — RequireAuth использует
* для тихого fallback'а в guest mode. */
error: Error | null
checkAuth: () => Promise<void>
login: () => void
logout: () => Promise<void>
/** Re-read keycloak state в store (вызывается keycloak-js callbacks). */
syncFromKeycloak: () => void
}
const snapshotUser = (): AuthUser | null => {
const kc = getKeycloak()
if (!kc.authenticated || !kc.tokenParsed) return null
const tp = kc.tokenParsed as KeycloakProfile
return {
profile: tp,
access_token: kc.token,
expired: !kc.authenticated || (typeof tp.exp === 'number' && tp.exp * 1000 < Date.now()),
}
}
export const useAuthStore = create<AuthStore>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: true,
error: null,
checkAuth: async () => {
set({ isLoading: true, error: null })
try {
await initKeycloak()
wireKeycloakEvents()
// На каждый keycloak-js callback (login/refresh/logout/expire)
// ре-снэпшотим user → стор обновляется → подписчики ре-рендерятся.
subscribeAuthState(() => {
get().syncFromKeycloak()
})
get().syncFromKeycloak()
} catch (e) {
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: e instanceof Error ? e : new Error(String(e)),
})
}
},
syncFromKeycloak: () => {
const user = snapshotUser()
set({ user, isAuthenticated: !!user, isLoading: false })
},
login: () => {
void startLogin()
},
logout: async () => {
set({ user: null, isAuthenticated: false })
await startLogout()
},
}))