1 Commits
Author SHA1 Message Date
DenozordecandCursor 96d754ef26 fix(auth): при истечении сессии вести на portal вместо sso_loop
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m1s
CD / quality (push) Successful in 1m9s
CD / publish (push) Successful in 1m36s
Co-authored-by: Cursor <[email protected]>
2026-09-04 18:18:12 +07:00
5 changed files with 39 additions and 56 deletions
+1 -7
View File
@@ -30,13 +30,7 @@ async function handoffOnUnauthorized(): Promise<void> {
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'
}
+13
View File
@@ -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
+3 -12
View File
@@ -5,6 +5,7 @@ import {
getClaims,
getToken,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
} from '@/lib/auth'
export interface RouterContext {
@@ -28,12 +29,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
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<RouterContext>()({
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
}
+2 -6
View File
@@ -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
}
+20 -31
View File
@@ -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 (
<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">
{error === 'jwt_rejected'
? 'API отклонил JWT (обычно разный AUTH_JWT_SECRET / AUTH_ISSUER с portal). Проверьте .env контейнера CDN Manager.'
: 'Повторный вход через portal остановлен (защита от цикла редиректов). Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.'}{' '}
Войдите заново на portal, затем откройте CDN Manager.
</p>
<a className="text-primary text-sm underline" href={authPortalUrl()}>
Открыть Auth Portal
</a>
</div>
)
}
return null
// beforeLoad always navigates away; placeholder while assigning location.
return (
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
Перенаправление на Auth Portal
</div>
)
}