From c23eb5ead0bb884518d93be11e40b007fb0a93a6 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 18 Jul 2026 16:08:50 +0700 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=D0=BD=D0=B5=20=D0=BE=D1=82=D0=B4?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20SSO=20=D1=81=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=80=D0=BE=D1=87=D0=B5=D0=BD=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?JWT=20=D0=B8=20=D0=BD=D0=B5=20=D0=B1=D0=B8=D1=82=D1=8C=20/me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getToken чистит expired; при плохом return_to остаёмся на login без /me. Co-authored-by: Cursor --- apps/web/src/lib/auth.ts | 10 +++++++++- apps/web/src/routes/index.tsx | 2 ++ docs/integrate-vps-tracker.md | 1 + packages/shared/src/contracts/auth.ts | 20 ++++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 87c2c34..c6f4289 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,3 +1,5 @@ +import { isJwtExpired } from '@authportal/shared' + const TOKEN_KEY = 'authportal_token' /** Fallback if /api/v1/auth/config unavailable (dev). Includes `private` for LAN SSO. */ @@ -37,7 +39,13 @@ export async function ensureReturnToAllowlist(): Promise { } export function getToken(): string | null { - return localStorage.getItem(TOKEN_KEY) + const token = localStorage.getItem(TOKEN_KEY) + if (!token) return null + if (isJwtExpired(token)) { + localStorage.removeItem(TOKEN_KEY) + return null + } + return token } export function setToken(token: string) { diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index aada620..0a29223 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -32,6 +32,8 @@ export const Route = createFileRoute('/')({ await new Promise(() => {}) return } + // return_to present but not allowlisted — stay on login, do not hammer /me + return } try { diff --git a/docs/integrate-vps-tracker.md b/docs/integrate-vps-tracker.md index 820aab3..08d71db 100644 --- a/docs/integrate-vps-tracker.md +++ b/docs/integrate-vps-tracker.md @@ -107,6 +107,7 @@ pnpm --filter web dev # :5173 | 403 «нет доступа к приложению» | В portal не выдан app `vps` | | 403 на write | Только `*:read` в permissions | | Loop на login | `return_to` не в `RETURN_TO_ALLOWLIST` | +| Infinite SSO / 429 | Просроченный JWT в portal localStorage; или разный `JWT_SECRET`/`ISSUER`. Portal чистит expired token; VPS блокирует повторный handoff 12с | | CORS | Portal и VPS на разных origin — fragment handoff не требует CORS для token | ## Production diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 728a568..45624b2 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -187,6 +187,26 @@ function isPrivateHostname(hostname: string): boolean { return false } +/** Decode JWT `exp` without verifying signature. Returns null if missing/invalid. */ +export function readJwtExp(token: string): number | null { + try { + const parts = token.split('.') + if (parts.length < 2) return null + const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/')) + const payload = JSON.parse(json) as { exp?: unknown } + return typeof payload.exp === 'number' ? payload.exp : null + } catch { + return null + } +} + +/** True when token is missing exp or exp is in the past (30s clock-skew grace). */ +export function isJwtExpired(token: string, nowMs: number = Date.now()): boolean { + const exp = readJwtExp(token) + if (exp == null) return true + return exp * 1000 < nowMs - 30_000 +} + export function buildSsoRedirectUrl( returnTo: string, accessToken: string,