fix(auth): остановить SSO-цикл редиректов после handoff
Docker / build (push) Failing after 19s

Cooldown 12с между portal handoff и страница sso_loop вместо бесконечного редиректа.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-18 16:08:50 +07:00
co-authored by Cursor
parent 47c1f37912
commit a61b352f0a
5 changed files with 130 additions and 33 deletions
@@ -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()
}
+25 -25
View File
@@ -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<void> {
clearToken()
const cfg = await ensureAuthConfig()
if (
(cfg.required || isAuthEnabled()) &&
!hasPortalHandoffFlag() &&
!isPortalHandoffCoolingDown()
) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
}
}
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`
const headers = new Headers(options.headers)
@@ -43,15 +63,7 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
})
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)
}
+43 -1
View File
@@ -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): {
+9 -1
View File
@@ -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
+51 -6
View File
@@ -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<string, unknown>) => ({
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 (
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
<h1 className="text-lg font-semibold">Сессия не принята</h1>
<p className="text-muted-foreground max-w-md text-sm">
Повторный вход через portal остановлен (защита от цикла редиректов).
Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.
Войдите заново на portal, затем откройте VPS Tracker.
</p>
<a
className="text-primary text-sm underline"
href={authPortalUrl()}
>
Открыть Auth Portal
</a>
</div>
)
}
return null
}