From a61b352f0ac77e92a60a243fdc7f04d50927da24 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 18 Jul 2026 16:08:50 +0700 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20SSO-=D1=86=D0=B8=D0=BA=D0=BB?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B4=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cooldown 12с между portal handoff и страница sso_loop вместо бесконечного редиректа. Co-authored-by: Cursor --- apps/web/src/components/layout/nav-user.tsx | 2 + apps/web/src/lib/api-client.ts | 50 +++++++++--------- apps/web/src/lib/auth.ts | 44 +++++++++++++++- apps/web/src/routes/_auth.tsx | 10 +++- apps/web/src/routes/auth.callback.tsx | 57 ++++++++++++++++++--- 5 files changed, 130 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/layout/nav-user.tsx b/apps/web/src/components/layout/nav-user.tsx index 333f2b3..c0d9f22 100644 --- a/apps/web/src/components/layout/nav-user.tsx +++ b/apps/web/src/components/layout/nav-user.tsx @@ -40,6 +40,7 @@ import { getClaims, isAuthEnabled, redirectToPortalLogin, + resetPortalHandoff, } from '@/lib/auth' /** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */ @@ -128,6 +129,7 @@ export function NavUser() { function handleSignOut() { clearToken() + resetPortalHandoff() redirectToPortalLogin() } diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 6a79b2d..fc623d7 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -8,7 +8,15 @@ import type { Payment, BalanceLedgerRow, } from '@/types/entities' -import { clearToken, ensureAuthConfig, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth' +import { + clearToken, + ensureAuthConfig, + getToken, + hasPortalHandoffFlag, + isAuthEnabled, + isPortalHandoffCoolingDown, + redirectToPortalLogin, +} from '@/lib/auth' import { getStoredSpaceId } from '@/lib/space' const API_BASE = import.meta.env.VITE_API_URL ?? '' @@ -22,6 +30,18 @@ export class ApiError extends Error { } } +async function handoffOnUnauthorized(): Promise { + clearToken() + const cfg = await ensureAuthConfig() + if ( + (cfg.required || isAuthEnabled()) && + !hasPortalHandoffFlag() && + !isPortalHandoffCoolingDown() + ) { + redirectToPortalLogin(`${window.location.origin}/auth/callback`) + } +} + async function fetchApi(path: string, options: RequestInit = {}): Promise { const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}` const headers = new Headers(options.headers) @@ -43,15 +63,7 @@ async function fetchApi(path: string, options: RequestInit = {}): Promise }) if (!res.ok) { if (res.status === 401) { - // Avoid redirect storms: only hand off once per page load - const handoffKey = 'vps_auth_401_handoff' - const already = sessionStorage.getItem(handoffKey) - clearToken() - const cfg = await ensureAuthConfig() - if ((cfg.required || isAuthEnabled()) && !already) { - sessionStorage.setItem(handoffKey, '1') - redirectToPortalLogin(`${window.location.origin}/auth/callback`) - } + await handoffOnUnauthorized() } let message = res.statusText || 'API error' try { @@ -171,11 +183,7 @@ export const api = { const res = await fetch(`${API_BASE}/api/backup/json`, { headers }) if (!res.ok) { if (res.status === 401) { - clearToken() - const cfg = await ensureAuthConfig() - if (cfg.required || isAuthEnabled()) { - redirectToPortalLogin(`${window.location.origin}/auth/callback`) - } + await handoffOnUnauthorized() } throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) } @@ -191,11 +199,7 @@ export const api = { const res = await fetch(`${API_BASE}/api/backup/database`, { headers }) if (!res.ok) { if (res.status === 401) { - clearToken() - const cfg = await ensureAuthConfig() - if (cfg.required || isAuthEnabled()) { - redirectToPortalLogin(`${window.location.origin}/auth/callback`) - } + await handoffOnUnauthorized() } throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) } @@ -271,11 +275,7 @@ export const api = { }) if (!res.ok) { if (res.status === 401) { - clearToken() - const cfg = await ensureAuthConfig() - if (cfg.required || isAuthEnabled()) { - redirectToPortalLogin(`${window.location.origin}/auth/callback`) - } + await handoffOnUnauthorized() } throw new ApiError(res.statusText || 'Ошибка восстановления', res.status) } diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 4fc23cd..a07ce2c 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,6 +1,10 @@ /** Portal JWT storage + claims helpers for VPS Tracker UI. */ const TOKEN_KEY = 'vps_auth_token' +const HANDOFF_KEY = 'vps_auth_401_handoff' +const HANDOFF_AT_KEY = 'vps_portal_handoff_at' +/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */ +const HANDOFF_COOLDOWN_MS = 12_000 const API_BASE = import.meta.env.VITE_API_URL ?? '' export type AccessClaims = { @@ -96,12 +100,50 @@ export function authPortalUrl(): string { return vitePortalUrl() } -export function redirectToPortalLogin(returnTo?: string) { +/** True when another portal handoff happened too recently (SSO loop guard). */ +export function isPortalHandoffCoolingDown(): boolean { + const raw = sessionStorage.getItem(HANDOFF_AT_KEY) + if (!raw) return false + const at = Number(raw) + if (!Number.isFinite(at)) return false + return Date.now() - at < HANDOFF_COOLDOWN_MS +} + +export function markPortalHandoff(): void { + sessionStorage.setItem(HANDOFF_KEY, '1') + sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now())) +} + +export function clearPortalHandoffFlag(): void { + sessionStorage.removeItem(HANDOFF_KEY) +} + +/** Clear cooldown too — use on intentional logout so next login is allowed. */ +export function resetPortalHandoff(): void { + sessionStorage.removeItem(HANDOFF_KEY) + sessionStorage.removeItem(HANDOFF_AT_KEY) +} + +export function hasPortalHandoffFlag(): boolean { + return sessionStorage.getItem(HANDOFF_KEY) === '1' +} + +/** + * Redirect to auth-portal SSO. Returns false if cooldown blocks the handoff + * (clears local token) — prevents infinite SSO when API rejects JWT. + */ +export function redirectToPortalLogin(returnTo?: string): boolean { + if (isPortalHandoffCoolingDown()) { + clearToken() + return false + } + markPortalHandoff() const callback = returnTo ?? `${window.location.origin}/auth/callback` const url = new URL(authPortalUrl()) url.searchParams.set('return_to', callback) window.location.assign(url.toString()) + return true } export function parseHashToken(hash: string): { diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 8189f9d..129bcf1 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -18,7 +18,15 @@ export const Route = createFileRoute('/_auth')({ const token = getToken() const claims = getClaims() if (!token || !claims) { - redirectToPortalLogin(`${window.location.origin}/auth/callback`) + const ok = redirectToPortalLogin( + `${window.location.origin}/auth/callback`, + ) + if (!ok) { + throw redirect({ + to: '/auth/callback', + search: { error: 'sso_loop' }, + }) + } // Abort route load while browser navigates away await new Promise(() => {}) return diff --git a/apps/web/src/routes/auth.callback.tsx b/apps/web/src/routes/auth.callback.tsx index 3bfff02..282d5c6 100644 --- a/apps/web/src/routes/auth.callback.tsx +++ b/apps/web/src/routes/auth.callback.tsx @@ -1,5 +1,8 @@ import { createFileRoute, redirect } from '@tanstack/react-router' import { + authPortalUrl, + clearPortalHandoffFlag, + clearToken, ensureAuthConfig, firstAllowedPath, getClaims, @@ -10,22 +13,64 @@ import { } from '@/lib/auth' export const Route = createFileRoute('/auth/callback')({ - beforeLoad: async () => { + validateSearch: (search: Record) => ({ + error: typeof search.error === 'string' ? search.error : undefined, + }), + beforeLoad: async ({ search }) => { await ensureAuthConfig() + + if (search.error === 'sso_loop') { + return + } + const { accessToken } = parseHashToken(window.location.hash) if (accessToken) { setToken(accessToken) - // Do not replaceState to strip the hash here — that re-triggers beforeLoad - // with an empty hash and sends the user back to the portal (SSO loop). - sessionStorage.removeItem('vps_auth_401_handoff') + // Keep handoff timestamp for cooldown; only clear the per-load flag. + clearPortalHandoffFlag() + const claims = getClaims() + if (!claims) { + clearToken() + // Expired/invalid token from portal — force interactive login (no return_to storm) + window.location.assign(authPortalUrl()) + await new Promise(() => {}) + return + } throw redirect({ to: firstAllowedPath() }) } // Already stored from a previous parse (e.g. remount) — finish handoff. if (getToken() && getClaims()) { + clearPortalHandoffFlag() throw redirect({ to: firstAllowedPath() }) } - redirectToPortalLogin(`${window.location.origin}/auth/callback`) + const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`) + if (!ok) { + throw redirect({ to: '/auth/callback', search: { error: 'sso_loop' } }) + } await new Promise(() => {}) }, - component: () => null, + component: AuthCallbackPage, }) + +function AuthCallbackPage() { + const { error } = Route.useSearch() + if (error === 'sso_loop') { + return ( +
+

Сессия не принята

+

+ Повторный вход через portal остановлен (защита от цикла редиректов). + Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен. + Войдите заново на portal, затем откройте VPS Tracker. +

+ + Открыть Auth Portal + +
+ ) + } + return null +}