import { buildSsoRedirectUrl, isJwtExpired, isReturnToAllowed, readJwtPayload, type LoginResponse, type MeResponse, } from '@authportal/shared' const TOKEN_KEY = 'authportal_token' /** Fallback if /api/v1/auth/config unavailable (dev). Includes `private` for LAN SSO. */ export const DEFAULT_RETURN_TO_ALLOWLIST = '.shnt.top,localhost,private,http://localhost:5173' let returnToAllowlist: string | null = null let returnToAllowlistPromise: Promise | null = null export async function ensureReturnToAllowlist(): Promise { if (returnToAllowlist) return returnToAllowlist if (returnToAllowlistPromise) return returnToAllowlistPromise returnToAllowlistPromise = (async () => { const fromVite = import.meta.env.VITE_RETURN_TO_ALLOWLIST as | string | undefined try { const res = await fetch('/api/v1/auth/config') if (res.ok) { const data = (await res.json()) as { return_to_allowlist?: string } if (data.return_to_allowlist) { returnToAllowlist = data.return_to_allowlist return returnToAllowlist } } } catch { /* ignore */ } returnToAllowlist = fromVite || DEFAULT_RETURN_TO_ALLOWLIST return returnToAllowlist })().finally(() => { returnToAllowlistPromise = null }) return returnToAllowlistPromise } export function getToken(): string | null { 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) { localStorage.setItem(TOKEN_KEY, token) } export function clearToken() { localStorage.removeItem(TOKEN_KEY) } /** True when stored JWT claims disagree with live /me (DB). */ export function jwtClaimsStaleVsMe(token: string, me: MeResponse): boolean { const payload = readJwtPayload(token) if (!payload) return true if (Boolean(payload.is_admin) !== Boolean(me.is_admin)) return true const claimApps = Array.isArray(payload.apps) ? payload.apps.map(String).sort().join(',') : '' const meApps = [...me.apps].map(String).sort().join(',') if (claimApps !== meApps) return true const claimPerms = Array.isArray(payload.permissions) ? payload.permissions.map(String).sort().join(',') : '' const mePerms = [...me.permissions].map(String).sort().join(',') if (claimPerms !== mePerms) return true return false } /** * Re-mint access token from API (current DB rights) and store it. * Always use before SSO handoff so apps never get a stale user JWT. */ export async function reissueAccessToken(): Promise { const token = getToken() if (!token) { throw new Error('Нет сессии') } const res = await fetch('/api/v1/auth/reissue', { method: 'POST', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', }, }) if (!res.ok) { clearToken() throw new Error('Не удалось обновить сессию') } const body = (await res.json()) as LoginResponse setToken(body.access_token) return body } /** SSO open: fresh JWT → app /auth/callback. */ export async function ssoOpenApp(appBaseUrl: string): Promise { const base = appBaseUrl.replace(/\/$/, '') const issued = await reissueAccessToken() const callback = `${base}/auth/callback` window.location.href = buildSsoRedirectUrl( callback, issued.access_token, issued.expires_at, ) } /** SSO return_to handoff with fresh JWT. */ export async function ssoHandoffReturnTo(returnTo: string): Promise { const allowlist = await ensureReturnToAllowlist() if (!isReturnToAllowed(returnTo, allowlist)) return false const issued = await reissueAccessToken() window.location.href = buildSsoRedirectUrl( returnTo, issued.access_token, issued.expires_at, ) return true }