feat(auth): implement portal SSO and local admin authentication
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m57s
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s

Added support for portal SSO with JWT authentication and local admin login. Updated environment configuration to include AUTH_REQUIRED, AUTH_JWT_SECRET, AUTH_ISSUER, and AUTH_PORTAL_URL. Enhanced the auth plugin to handle JWT verification based on the new configuration. Introduced new routes for authentication and updated the API client to manage token handling and redirects. Improved user experience by integrating authentication checks across various routes and components.
This commit is contained in:
Denozordec
2026-07-18 18:25:29 +07:00
parent 60e15ca40a
commit 6a6cb34eeb
22 changed files with 1101 additions and 97 deletions
+28 -3
View File
@@ -1,3 +1,13 @@
import {
clearToken,
ensureAuthConfig,
getToken,
hasPortalHandoffFlag,
isAuthEnabled,
isPortalHandoffCoolingDown,
redirectToPortalLogin,
} from '@/lib/auth'
export class ApiError extends Error {
constructor(
public status: number,
@@ -9,8 +19,24 @@ export class ApiError extends Error {
}
}
async function handoffOnUnauthorized(): Promise<void> {
clearToken()
const cfg = await ensureAuthConfig()
if (
(cfg.required || isAuthEnabled()) &&
!hasPortalHandoffFlag() &&
!isPortalHandoffCoolingDown()
) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
return
}
if (!cfg.required && !isAuthEnabled()) {
window.location.href = '/login'
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = localStorage.getItem('cfdm_token')
const token = getToken()
const headers = new Headers(init?.headers)
if (init?.body != null && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
@@ -19,8 +45,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { ...init, headers })
if (res.status === 401 && !path.includes('/auth/login')) {
localStorage.removeItem('cfdm_token')
window.location.href = '/login'
await handoffOnUnauthorized()
throw new ApiError(401, 'UNAUTHORIZED', 'Unauthorized')
}
if (!res.ok) {
+243 -3
View File
@@ -1,11 +1,251 @@
/** Portal JWT storage + claims helpers for Cloudflare Domain Manager. */
const TOKEN_KEY = 'cfdm_token'
const HANDOFF_KEY = 'cfdm_auth_401_handoff'
const HANDOFF_AT_KEY = 'cfdm_portal_handoff_at'
/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */
const HANDOFF_COOLDOWN_MS = 12_000
const API_BASE = import.meta.env.VITE_API_URL ?? ''
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 API (Docker-friendly). Falls back to VITE_* flags. */
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
if (runtimeConfig) return runtimeConfig
if (runtimeConfigPromise) return runtimeConfigPromise
runtimeConfigPromise = (async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/auth/config`)
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 — use vite defaults */
}
runtimeConfig = {
required: viteAuthEnabled(),
portalUrl: vitePortalUrl(),
}
return runtimeConfig
})().finally(() => {
runtimeConfigPromise = null
})
return runtimeConfigPromise
}
export function getAuthConfigSync(): RuntimeAuthConfig | null {
return runtimeConfig
}
export function getToken(): string | null {
return localStorage.getItem('cfdm_token')
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string) {
localStorage.setItem('cfdm_token', token)
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken() {
localStorage.removeItem('cfdm_token')
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()
}
/** True when another portal handoff happened too recently (SSO loop guard). */
export function isPortalHandoffCoolingDown(): boolean {
const raw = 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 {
sessionStorage.setItem(HANDOFF_KEY, '1')
sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
}
export function clearPortalHandoffFlag(): void {
sessionStorage.removeItem(HANDOFF_KEY)
}
/** Clear cooldown too — use on intentional logout so next login is allowed. */
export function resetPortalHandoff(): void {
sessionStorage.removeItem(HANDOFF_KEY)
sessionStorage.removeItem(HANDOFF_AT_KEY)
}
export function hasPortalHandoffFlag(): boolean {
return 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 (isPortalHandoffCoolingDown()) {
clearToken()
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
}
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 = getToken()
if (!token) return null
const claims = decodeClaims(token)
if (!claims) return null
if (claims.exp && claims.exp * 1000 < Date.now()) {
clearToken()
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
}
export function can(required: string): boolean {
if (!isAuthEnabled()) return true
const claims = getClaims()
if (!claims) return false
if (!claims.apps.includes('cfdm')) return false
return hasPermission(claims.permissions, required)
}
/** Nav path → minimum permission to show the item. */
export function permissionForPath(pathname: string): string | null {
if (pathname === '/' || pathname.startsWith('/dashboard')) {
return 'cfdm:domains:read'
}
if (pathname.startsWith('/domains')) return 'cfdm:domains:read'
if (pathname.startsWith('/groups')) return 'cfdm:groups:read'
if (pathname.startsWith('/services')) return 'cfdm:services:read'
if (pathname.startsWith('/certificates')) return 'cfdm:certificates:read'
if (pathname.startsWith('/settings')) return 'cfdm:settings:admin'
return 'cfdm:domains:read'
}
export function firstAllowedPath(): string {
const candidates = [
'/',
'/domains',
'/groups',
'/services',
'/certificates',
'/settings/appearance',
]
for (const path of candidates) {
const perm = permissionForPath(path)
if (!perm || can(perm)) return path
}
return '/'
}