/** * Portal JWT SSO integration for EvoBGP UI. * * Two independent auth channels: * - Portal JWT (this module) — SSO from auth-portal, used to gate UI + Bearer * to EvoBGP API when the API accepts portal-issued tokens. * - Local API key (see `@/lib/api-client`) — legacy `evobgp_api_token` * in localStorage; used when auth-portal is disabled or as a fallback. * * `VITE_AUTH_ENABLED=false` → keep the API-key gate. * `VITE_AUTH_ENABLED=true` → require portal JWT; API-key kept only for tools * (curl/dev) and as backup. */ const TOKEN_KEY = 'evobgp_portal_token' const HANDOFF_KEY = 'evobgp_portal_401_handoff' const HANDOFF_AT_KEY = 'evobgp_portal_handoff_at' /** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */ const HANDOFF_COOLDOWN_MS = 12_000 /** EvoBGP is `bgp` in the auth-portal registry (see APP_IDS). */ export const CURRENT_APP_ID = 'bgp' export type AccessClaims = { sub: string email: string name: string apps: string[] permissions: string[] is_admin?: boolean iss?: string exp?: number } export type RuntimeAuthConfig = { required: boolean portalUrl: string } let runtimeConfig: RuntimeAuthConfig | null = null let runtimeConfigPromise: Promise | null = null function viteAuthEnabled(): boolean { return ( import.meta.env.VITE_AUTH_ENABLED === 'true' || import.meta.env.VITE_AUTH_ENABLED === '1' ) } function vitePortalUrl(): string { return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace( /\/$/, '', ) } /** * Load auth mode from EvoBGP API (Docker-friendly). Falls back to VITE_* flags * when the endpoint isn't implemented (404) or the API is unreachable. * * Note: EvoBGP uses `/v1/...` (not `/api/v1/...`). */ export async function ensureAuthConfig(): Promise { if (runtimeConfig) return runtimeConfig if (runtimeConfigPromise) return runtimeConfigPromise runtimeConfigPromise = (async () => { try { const res = await fetch('/v1/auth/config', { headers: { Accept: 'application/json' }, }) if (res.ok) { const data = (await res.json()) as { required?: boolean portal_url?: string } runtimeConfig = { required: Boolean(data.required) || viteAuthEnabled(), portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''), } return runtimeConfig } } catch { /* ignore — fall through to vite defaults */ } runtimeConfig = { required: viteAuthEnabled(), portalUrl: vitePortalUrl(), } return runtimeConfig })().finally(() => { runtimeConfigPromise = null }) return runtimeConfigPromise } export function getAuthConfigSync(): RuntimeAuthConfig | null { return runtimeConfig } export function getPortalToken(): string | null { if (typeof window === 'undefined') return null return window.localStorage.getItem(TOKEN_KEY) } export function setPortalToken(token: string): void { if (typeof window === 'undefined') return window.localStorage.setItem(TOKEN_KEY, token) } export function clearPortalToken(): void { if (typeof window === 'undefined') return window.localStorage.removeItem(TOKEN_KEY) } export function isAuthEnabled(): boolean { if (runtimeConfig) return runtimeConfig.required return viteAuthEnabled() } export function authPortalUrl(): string { if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl return vitePortalUrl() } export function isPortalHandoffCoolingDown(): boolean { if (typeof window === 'undefined') return false const raw = window.sessionStorage.getItem(HANDOFF_AT_KEY) if (!raw) return false const at = Number(raw) if (!Number.isFinite(at)) return false return Date.now() - at < HANDOFF_COOLDOWN_MS } export function markPortalHandoff(): void { if (typeof window === 'undefined') return window.sessionStorage.setItem(HANDOFF_KEY, '1') window.sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now())) } export function clearPortalHandoffFlag(): void { if (typeof window === 'undefined') return window.sessionStorage.removeItem(HANDOFF_KEY) } /** Clear cooldown too — use on intentional logout so next login is allowed. */ export function resetPortalHandoff(): void { if (typeof window === 'undefined') return window.sessionStorage.removeItem(HANDOFF_KEY) window.sessionStorage.removeItem(HANDOFF_AT_KEY) } export function hasPortalHandoffFlag(): boolean { if (typeof window === 'undefined') return false return window.sessionStorage.getItem(HANDOFF_KEY) === '1' } /** * Redirect to auth-portal SSO. Returns false if cooldown blocks the handoff * (clears local token) — prevents infinite SSO when API rejects JWT. */ export function redirectToPortalLogin(returnTo?: string): boolean { if (typeof window === 'undefined') return false if (isPortalHandoffCoolingDown()) { clearPortalToken() return false } markPortalHandoff() const callback = returnTo ?? `${window.location.origin}/auth/callback` const url = new URL(authPortalUrl()) url.searchParams.set('return_to', callback) window.location.assign(url.toString()) return true } /** End portal SSO session (refresh cookie + portal token). */ export function redirectToPortalLogout(): void { clearPortalToken() resetPortalHandoff() if (typeof window === 'undefined') return window.location.assign(`${authPortalUrl()}/logout`) } export function parseHashToken(hash: string): { accessToken: string | null expiresAt: string | null } { const raw = hash.startsWith('#') ? hash.slice(1) : hash const params = new URLSearchParams(raw) return { accessToken: params.get('access_token'), expiresAt: params.get('expires_at'), } } export function decodeClaims(token: string): AccessClaims | 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 Record return { sub: String(payload.sub ?? ''), email: String(payload.email ?? ''), name: String(payload.name ?? ''), apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [], permissions: Array.isArray(payload.permissions) ? payload.permissions.map(String) : [], is_admin: Boolean(payload.is_admin), iss: payload.iss ? String(payload.iss) : undefined, exp: typeof payload.exp === 'number' ? payload.exp : undefined, } } catch { return null } } export function getClaims(): AccessClaims | null { const token = getPortalToken() if (!token) return null const claims = decodeClaims(token) if (!claims) return null if (claims.exp && claims.exp * 1000 < Date.now()) { clearPortalToken() return null } return claims } export function hasPermission( granted: readonly string[], required: string, ): boolean { if (granted.includes(required)) return true const parts = required.split(':') if (parts.length !== 3) return false const [app, section, action] = parts if (action === 'read') { return ( granted.includes(`${app}:${section}:write`) || granted.includes(`${app}:${section}:admin`) ) } if (action === 'write') { return granted.includes(`${app}:${section}:admin`) } return false } /** Access-check: pass when portal auth is disabled or claim grants required. */ export function can(required: string): boolean { if (!isAuthEnabled()) return true const claims = getClaims() if (!claims) return false if (!claims.apps.includes(CURRENT_APP_ID)) return false if (claims.is_admin) return true return hasPermission(claims.permissions, required) } /** * Whether /v1/auth/session may manage API keys (`bgp:access:admin`). * Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key operator. */ export function sessionCanManageApiKeys(session: { role?: string kind?: string is_admin?: boolean permissions?: readonly string[] } | null | undefined): boolean { if (!session) return false const jwtPath = session.kind === 'jwt' || session.is_admin === true || (session.permissions?.length ?? 0) > 0 if (jwtPath) { return ( session.is_admin === true || hasPermission(session.permissions ?? [], 'bgp:access:admin') ) } return session.role === 'operator' } /** * Whether session may create/update module entries (`bgp:modules:write`). * Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+. */ export function sessionCanWriteModules(session: { role?: string kind?: string is_admin?: boolean permissions?: readonly string[] } | null | undefined): boolean { if (!session) return false const jwtPath = session.kind === 'jwt' || session.is_admin === true || (session.permissions?.length ?? 0) > 0 if (jwtPath) { return ( session.is_admin === true || hasPermission(session.permissions ?? [], 'bgp:modules:write') ) } const role = (session.role ?? '').toLowerCase() return role === 'editor' || role === 'operator' } /** * Whether session may create/update directories (`bgp:directories:write`). * Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+. */ export function sessionCanWriteDirectories(session: { role?: string kind?: string is_admin?: boolean permissions?: readonly string[] } | null | undefined): boolean { if (!session) return false const jwtPath = session.kind === 'jwt' || session.is_admin === true || (session.permissions?.length ?? 0) > 0 if (jwtPath) { return ( session.is_admin === true || hasPermission(session.permissions ?? [], 'bgp:directories:write') ) } const role = (session.role ?? '').toLowerCase() return role === 'editor' || role === 'operator' } /** Nav path → minimum permission to show the item. Sync with app-shell NAV. */ export function permissionForPath(pathname: string): string | null { if (pathname === '/' || pathname.startsWith('/dashboard')) { return 'bgp:dashboard:read' } if (pathname.startsWith('/modules')) return 'bgp:modules:read' if (pathname.startsWith('/lookup')) return 'bgp:lookup:read' if (pathname.startsWith('/network')) return 'bgp:network:read' if (pathname.startsWith('/directories')) return 'bgp:directories:read' if (pathname.startsWith('/operations')) return 'bgp:operations:read' if (pathname.startsWith('/schedule')) return 'bgp:schedule:read' if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read' if (pathname.startsWith('/access')) return 'bgp:access:admin' if (pathname.startsWith('/tenant-settings') || pathname.startsWith('/settings')) { return canOpenSettings() ? null : 'bgp:settings:read' } return null } /** Unified `/settings` — UI (`settings:read`) or BIRD (`tenant_settings:admin`). */ export function canOpenSettings(): boolean { return can('bgp:settings:read') || can('bgp:tenant_settings:admin') } const FALLBACK_PATH = '/dashboard' /** First path in the sidebar the current user may open. */ export function firstAllowedPath(): string { const candidates: readonly string[] = [ '/dashboard', '/modules', '/lookup', '/network', '/directories', '/operations', '/schedule', '/monitoring', '/access', '/settings', ] for (const path of candidates) { const perm = permissionForPath(path) if (!perm || can(perm)) return path } return FALLBACK_PATH }