Cooldown 12с между portal handoff и страница sso_loop вместо бесконечного редиректа. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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): {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user