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