diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 93bd9d7..31f299b 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -30,13 +30,7 @@ async function handoffOnUnauthorized(): Promise { redirectToPortalLogin(`${window.location.origin}/auth/callback`) return } - // Cooldown / recent handoff — stop SSO storm (wrong JWT secret / issuer). - if (cfg.required || isAuthEnabled()) { - window.location.assign( - `${window.location.origin}/auth/callback?error=jwt_rejected`, - ) - return - } + // Match CFDM: on cooldown do not open sso_loop / jwt_rejected — caller handles. if (!cfg.required && !isAuthEnabled()) { window.location.href = '/login' } diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 0d547b1..271f05e 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -146,6 +146,17 @@ export function redirectToPortalLogin(returnTo?: string): boolean { return true } +/** + * Interactive portal login without return_to — breaks SSO storms when cooldown + * blocks silent handoff (expired portal session / rejected JWT). Same pattern as + * VPS Tracker / CFDM for invalid hash tokens. + */ +export function redirectToPortalLoginInteractive(): void { + clearToken() + resetPortalHandoff() + window.location.assign(authPortalUrl()) +} + /** End portal SSO session (refresh cookie + portal token). Do not pass return_to. */ export function redirectToPortalLogout(): void { clearToken() @@ -196,6 +207,8 @@ export function getClaims(): AccessClaims | null { if (!claims) return null if (claims.exp && claims.exp * 1000 < Date.now()) { clearToken() + // Allow a fresh portal handoff after local JWT expiry. + resetPortalHandoff() return null } return claims diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 44acc59..1afe452 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -5,6 +5,7 @@ import { getClaims, getToken, redirectToPortalLogin, + redirectToPortalLoginInteractive, } from '@/lib/auth' export interface RouterContext { @@ -28,12 +29,7 @@ export const Route = createRootRouteWithContext()({ const ok = redirectToPortalLogin( `${window.location.origin}/auth/callback`, ) - if (!ok) { - throw redirect({ - to: '/auth/callback', - search: { error: 'sso_loop' }, - }) - } + if (!ok) redirectToPortalLoginInteractive() await new Promise(() => {}) return } @@ -41,12 +37,7 @@ export const Route = createRootRouteWithContext()({ const ok = redirectToPortalLogin( `${window.location.origin}/auth/callback`, ) - if (!ok) { - throw redirect({ - to: '/auth/callback', - search: { error: 'sso_loop' }, - }) - } + if (!ok) redirectToPortalLoginInteractive() await new Promise(() => {}) return } diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 473b27e..018ad19 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -8,6 +8,7 @@ import { getToken, permissionForPath, redirectToPortalLogin, + redirectToPortalLoginInteractive, } from '@/lib/auth' export const Route = createFileRoute('/_auth')({ @@ -21,12 +22,7 @@ export const Route = createFileRoute('/_auth')({ const ok = redirectToPortalLogin( `${window.location.origin}/auth/callback`, ) - if (!ok) { - throw redirect({ - to: '/auth/callback', - search: { error: 'sso_loop' }, - }) - } + if (!ok) redirectToPortalLoginInteractive() await new Promise(() => {}) return } diff --git a/apps/web/src/routes/auth.callback.tsx b/apps/web/src/routes/auth.callback.tsx index fff2edc..33b92be 100644 --- a/apps/web/src/routes/auth.callback.tsx +++ b/apps/web/src/routes/auth.callback.tsx @@ -1,15 +1,14 @@ import { createFileRoute, redirect } from '@tanstack/react-router' import { - authPortalUrl, clearPortalHandoffFlag, clearToken, ensureAuthConfig, firstAllowedPath, getClaims, getToken, - markPortalHandoff, parseHashToken, redirectToPortalLogin, + redirectToPortalLoginInteractive, setToken, } from '@/lib/auth' @@ -32,24 +31,26 @@ export const Route = createFileRoute('/auth/callback')({ beforeLoad: async ({ search }) => { await ensureAuthConfig() + // Dead-end errors → interactive portal login (no return_to storm). if (search.error === 'sso_loop' || search.error === 'jwt_rejected') { + redirectToPortalLoginInteractive() + await new Promise(() => {}) return } const { accessToken } = parseHashToken(window.location.hash) if (accessToken) { setToken(accessToken) - // Start cooldown so a following API 401 cannot re-enter portal SSO storm. - markPortalHandoff() + // Match CFDM/VPS: clear handoff flag only — do not start a new cooldown + // after a successful SSO (that caused false sso_loop on expiry re-login). clearPortalHandoffFlag() const claims = getClaims() if (!claims) { clearToken() - throw redirect({ - to: '/auth/callback', - search: { error: 'jwt_rejected' }, - }) + redirectToPortalLoginInteractive() + await new Promise(() => {}) + return } if (!claims.apps.includes('cdn')) { throw redirect({ to: '/access-denied' }) @@ -58,10 +59,9 @@ export const Route = createFileRoute('/auth/callback')({ const ok = await verifyTokenAccepted(accessToken) if (!ok) { clearToken() - throw redirect({ - to: '/auth/callback', - search: { error: 'jwt_rejected' }, - }) + redirectToPortalLoginInteractive() + await new Promise(() => {}) + return } const next = firstAllowedPath() @@ -79,7 +79,8 @@ export const Route = createFileRoute('/auth/callback')({ } const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`) if (!ok) { - throw redirect({ to: '/auth/callback', search: { error: 'sso_loop' } }) + // Cooldown: fall back to interactive portal login instead of sso_loop page. + redirectToPortalLoginInteractive() } await new Promise(() => {}) }, @@ -87,22 +88,10 @@ export const Route = createFileRoute('/auth/callback')({ }) function AuthCallbackPage() { - const { error } = Route.useSearch() - if (error === 'sso_loop' || error === 'jwt_rejected') { - return ( -
-

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

-

- {error === 'jwt_rejected' - ? 'API отклонил JWT (обычно разный AUTH_JWT_SECRET / AUTH_ISSUER с portal). Проверьте .env контейнера CDN Manager.' - : 'Повторный вход через portal остановлен (защита от цикла редиректов). Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.'}{' '} - Войдите заново на portal, затем откройте CDN Manager. -

- - Открыть Auth Portal - -
- ) - } - return null + // beforeLoad always navigates away; placeholder while assigning location. + return ( +
+ Перенаправление на Auth Portal… +
+ ) }