fix(auth): не отдавать SSO с просроченным JWT и не бить /me
getToken чистит expired; при плохом return_to остаёмся на login без /me. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import { isJwtExpired } from '@authportal/shared'
|
||||||
|
|
||||||
const TOKEN_KEY = 'authportal_token'
|
const TOKEN_KEY = 'authportal_token'
|
||||||
|
|
||||||
/** Fallback if /api/v1/auth/config unavailable (dev). Includes `private` for LAN SSO. */
|
/** Fallback if /api/v1/auth/config unavailable (dev). Includes `private` for LAN SSO. */
|
||||||
@@ -37,7 +39,13 @@ export async function ensureReturnToAllowlist(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getToken(): string | null {
|
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) {
|
export function setToken(token: string) {
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export const Route = createFileRoute('/')({
|
|||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// return_to present but not allowlisted — stay on login, do not hammer /me
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ pnpm --filter web dev # :5173
|
|||||||
| 403 «нет доступа к приложению» | В portal не выдан app `vps` |
|
| 403 «нет доступа к приложению» | В portal не выдан app `vps` |
|
||||||
| 403 на write | Только `*:read` в permissions |
|
| 403 на write | Только `*:read` в permissions |
|
||||||
| Loop на login | `return_to` не в `RETURN_TO_ALLOWLIST` |
|
| 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 |
|
| CORS | Portal и VPS на разных origin — fragment handoff не требует CORS для token |
|
||||||
|
|
||||||
## Production
|
## Production
|
||||||
|
|||||||
@@ -187,6 +187,26 @@ function isPrivateHostname(hostname: string): boolean {
|
|||||||
return false
|
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(
|
export function buildSsoRedirectUrl(
|
||||||
returnTo: string,
|
returnTo: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user