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