feat(auth): integrate portal JWT for enhanced authentication and authorization
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
Added support for portal JWT authentication, enabling single sign-on (SSO) capabilities. Updated the application to handle JWT claims for user permissions and roles, enhancing security and access control. Refactored relevant components and API routes to accommodate the new authentication flow, ensuring a seamless user experience. Updated documentation to reflect the new authentication requirements and configurations. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -30,8 +30,19 @@ export function normalizeApiToken(raw: string): string {
|
||||
return t
|
||||
}
|
||||
|
||||
/** Portal JWT storage key mirrored from `@/lib/auth`. Kept local to avoid a
|
||||
* cycle when `auth` starts pulling from `api-client` for the config endpoint. */
|
||||
const PORTAL_TOKEN_STORAGE_KEY = 'evobgp_portal_token'
|
||||
|
||||
/**
|
||||
* Bearer selection: portal JWT wins over the legacy API-key. When auth-portal
|
||||
* is disabled or hasn't issued a token yet we fall back to the local API-key
|
||||
* (`evobgp_api_token`) so curl-style tooling keeps working.
|
||||
*/
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const portal = window.localStorage.getItem(PORTAL_TOKEN_STORAGE_KEY)
|
||||
if (portal && portal.trim()) return portal.trim()
|
||||
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const normalized = normalizeApiToken(raw)
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const CURRENT_APP_ID = 'evobgp'
|
||||
import { CURRENT_APP_ID } from '@/lib/auth'
|
||||
|
||||
export { CURRENT_APP_ID }
|
||||
|
||||
const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart'])
|
||||
|
||||
@@ -44,7 +46,7 @@ export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
|
||||
menuLabel: 'Приложения',
|
||||
apps: [
|
||||
{
|
||||
id: 'vps-tracker',
|
||||
id: 'vps',
|
||||
name: 'VPS Tracker',
|
||||
subtitle: 'Учёт виртуальных серверов',
|
||||
url: 'http://192.168.100.67:3001',
|
||||
@@ -60,7 +62,7 @@ export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
|
||||
shortcut: '⌘2',
|
||||
},
|
||||
{
|
||||
id: 'evobgp',
|
||||
id: 'bgp',
|
||||
name: 'EvoBGP',
|
||||
subtitle: 'BGP маршрутизация',
|
||||
url: 'http://192.168.100.67:3000',
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 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<RuntimeAuthConfig> | 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<RuntimeAuthConfig> {
|
||||
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<string, unknown>
|
||||
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
|
||||
return hasPermission(claims.permissions, required)
|
||||
}
|
||||
|
||||
/** 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('/firewall')) return 'bgp:firewall: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')) return 'bgp:tenant_settings:admin'
|
||||
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
||||
return null
|
||||
}
|
||||
|
||||
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',
|
||||
'/firewall',
|
||||
'/schedule',
|
||||
'/monitoring',
|
||||
'/access',
|
||||
'/tenant-settings',
|
||||
'/settings',
|
||||
]
|
||||
for (const path of candidates) {
|
||||
const perm = permissionForPath(path)
|
||||
if (!perm || can(perm)) return path
|
||||
}
|
||||
return FALLBACK_PATH
|
||||
}
|
||||
Reference in New Issue
Block a user