diff --git a/.env.example b/.env.example index 56317f4..e79c352 100644 --- a/.env.example +++ b/.env.example @@ -4,15 +4,24 @@ CLOUDFLARE_API_TOKEN= # Database DATABASE_URL=sqlite:data/app.db -# Auth -JWT_SECRET=dev-secret-change-me +# Auth — portal SSO (prod) или локальный admin (dev) +# AUTH_REQUIRED=true → JWT от auth-portal, apps включает cfdm +AUTH_REQUIRED=false +AUTH_JWT_SECRET=dev-secret-change-me +# alias: JWT_SECRET= +AUTH_ISSUER=https://auth.shnt.top +AUTH_PORTAL_URL=http://localhost:5175 JWT_TTL_HOURS=24 + +# Legacy local login (только при AUTH_REQUIRED=false) ADMIN_USERNAME=admin # Leave empty for dev default password "admin" ADMIN_PASSWORD_HASH= -# Frontend (Vite) -# Публичные URL приложений и integration token настраиваются в UI: Настройки → Интеграции +# Frontend (Vite) — apps/web/.env.local +# VITE_AUTH_ENABLED=true +# VITE_AUTH_PORTAL_URL=http://localhost:5175 +# Публичные URL приложений и integration token: Настройки → Интеграции # ReUI PRO (apps/web/components.json → @reui Authorization) # Ключ: https://reui.io/docs/license-setup — класть в .env.local (gitignored) diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 1d26a21..ad29ada 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -1,4 +1,4 @@ -name: Build, Test, and Push CFDM Docker Image +name: Build and Push CFDM Docker Image on: push: @@ -10,46 +10,7 @@ on: paths: ['**'] jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.12.1 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint web - run: pnpm --filter web lint - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build test stage - uses: docker/build-push-action@v5 - with: - context: . - file: ./Dockerfile.test - load: true - tags: cfdm:test - provenance: false - - - name: Run tests - run: docker run --rm cfdm:test - build-and-push: - needs: test if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push') runs-on: ubuntu-latest steps: @@ -153,7 +114,6 @@ jobs: fi update-wiki: - needs: test if: gitea.ref_name == 'main' && gitea.event_name == 'push' runs-on: ubuntu-latest steps: diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 9451523..f5b2803 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -15,13 +15,28 @@ export interface AppConfig { healthDownFailures: number; healthLatencyWarnMs: number; logLevel: string; + /** Portal SSO — when true, require portal JWT with apps includes cfdm */ + authRequired: boolean; + authIssuer: string; + authPortalUrl: string; +} + +function boolEnv(v: string | undefined, fallback: boolean): boolean { + if (v === undefined || v === "") return fallback; + return v === "1" || v.toLowerCase() === "true"; } export function loadConfig(): AppConfig { + const isProd = process.env.NODE_ENV === "production"; + const jwtSecret = + process.env.AUTH_JWT_SECRET ?? + process.env.JWT_SECRET ?? + (isProd ? "" : "dev-secret-change-me"); + return { databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db", cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(), - jwtSecret: process.env.JWT_SECRET ?? "dev-secret-change-me", + jwtSecret: jwtSecret || "dev-secret-change-me", jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24, adminUsername: process.env.ADMIN_USERNAME ?? "admin", adminPasswordHash: @@ -38,5 +53,13 @@ export function loadConfig(): AppConfig { healthLatencyWarnMs: Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000, logLevel: process.env.LOG_LEVEL ?? "info", + authRequired: boolEnv(process.env.AUTH_REQUIRED, false), + authIssuer: + process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top", + authPortalUrl: ( + process.env.AUTH_PORTAL_URL ?? + process.env.VITE_AUTH_PORTAL_URL ?? + "http://localhost:5175" + ).replace(/\/$/, ""), }; } diff --git a/apps/api/src/errors.ts b/apps/api/src/errors.ts index c8523aa..5e9b166 100644 --- a/apps/api/src/errors.ts +++ b/apps/api/src/errors.ts @@ -32,8 +32,8 @@ export class AppError extends Error { return new AppError("UNAUTHORIZED", "unauthorized", 401); } - static forbidden() { - return new AppError("FORBIDDEN", "forbidden", 403); + static forbidden(message = "forbidden") { + return new AppError("FORBIDDEN", message, 403); } static conflict(message: string) { diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts new file mode 100644 index 0000000..73f8a3c --- /dev/null +++ b/apps/api/src/lib/permissions.ts @@ -0,0 +1,137 @@ +/** + * Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission). + * Format: cfdm:
: + */ + +export type AuthUser = { + id: string; + email: string; + name: string; + apps: string[]; + permissions: string[]; + isAdmin?: boolean; +}; + +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; +} + +type Rule = { + methods: string[]; + match: (path: string) => boolean; + permission: string; +}; + +const RULES: Rule[] = [ + { + methods: ["GET"], + match: (p) => + p.startsWith("/api/v1/domains") || + p.startsWith("/api/v1/domain-monitors") || + p === "/api/v1/domain-monitors", + permission: "cfdm:domains:read", + }, + { + methods: ["POST", "PUT", "PATCH", "DELETE"], + match: (p) => + p.startsWith("/api/v1/domains") || + p.startsWith("/api/v1/domain-monitors"), + permission: "cfdm:domains:write", + }, + { + methods: ["GET"], + match: (p) => + p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"), + permission: "cfdm:dns:read", + }, + { + methods: ["POST", "PUT", "PATCH", "DELETE"], + match: (p) => + p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"), + permission: "cfdm:dns:write", + }, + { + methods: ["GET"], + match: (p) => p.startsWith("/api/v1/certificates"), + permission: "cfdm:certificates:read", + }, + { + methods: ["POST", "PUT", "PATCH", "DELETE"], + match: (p) => p.startsWith("/api/v1/certificates"), + permission: "cfdm:certificates:write", + }, + { + methods: ["GET"], + match: (p) => + p.startsWith("/api/v1/groups") || + p.startsWith("/api/v1/service-groups"), + permission: "cfdm:groups:read", + }, + { + methods: ["POST", "PUT", "PATCH", "DELETE"], + match: (p) => + p.startsWith("/api/v1/groups") || + p.startsWith("/api/v1/service-groups"), + permission: "cfdm:groups:write", + }, + { + methods: ["GET"], + match: (p) => + p.startsWith("/api/v1/services") || + p.startsWith("/api/v1/service-bindings"), + permission: "cfdm:services:read", + }, + { + methods: ["POST", "PUT", "PATCH", "DELETE"], + match: (p) => + p.startsWith("/api/v1/services") || + p.startsWith("/api/v1/service-bindings"), + permission: "cfdm:services:write", + }, + { + methods: ["GET", "POST"], + match: (p) => p.startsWith("/api/v1/sync"), + permission: "cfdm:domains:write", + }, + { + methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], + match: (p) => + p.startsWith("/api/v1/settings") || + p.startsWith("/api/v1/notifications") || + p.startsWith("/api/v1/health-check") || + p.startsWith("/api/v1/health-checks"), + permission: "cfdm:settings:admin", + }, +]; + +/** Resolve required permission for method+path, or null if public / unknown. */ +export function permissionForRequest( + method: string, + path: string, +): string | null { + const m = method.toUpperCase(); + const pathname = path.split("?")[0] ?? path; + for (const rule of RULES) { + if (!rule.methods.includes(m)) continue; + if (rule.match(pathname)) return rule.permission; + } + // Default: any authenticated cfdm user for unmatched /api/v1/* + if (pathname.startsWith("/api/v1/")) return "cfdm:domains:read"; + return null; +} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 4fb542a..3717968 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -1,28 +1,126 @@ -import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import fp from "fastify-plugin"; import type { AppConfig } from "../config.js"; import { AppError } from "../errors.js"; +import { + hasPermission, + permissionForRequest, + type AuthUser, +} from "../lib/permissions.js"; + +declare module "fastify" { + interface FastifyRequest { + authUser?: AuthUser; + } +} + +declare module "@fastify/jwt" { + interface FastifyJWT { + payload: { + sub: string; + email?: string; + name?: string; + apps?: string[]; + permissions?: string[]; + is_admin?: boolean; + iss?: string; + exp?: number; + }; + user: { + sub: string; + email?: string; + name?: string; + apps?: string[]; + permissions?: string[]; + is_admin?: boolean; + iss?: string; + exp?: number; + }; + } +} async function authPlugin( app: FastifyInstance, opts: { config: AppConfig }, ) { + const { config } = opts; + + if (config.authRequired && (!config.jwtSecret || config.jwtSecret.length < 8)) { + throw new Error( + "AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true", + ); + } + await app.register(import("@fastify/jwt"), { - secret: opts.config.jwtSecret, + secret: config.jwtSecret, + ...(config.authRequired + ? { + verify: { + allowedIss: [config.authIssuer], + }, + } + : {}), }); + + if (config.authRequired) { + app.log.info( + { issuer: config.authIssuer, portal: config.authPortalUrl }, + "AUTH_REQUIRED=true — portal JWT middleware enabled", + ); + } else { + app.log.info("AUTH_REQUIRED=false — local JWT / open protected routes with requireAuth"); + } } -export async function requireAuth(request: FastifyRequest): Promise { +/** + * Protect /api/v1 routes. + * - AUTH_REQUIRED=false: Bearer JWT from local login (legacy admin). + * - AUTH_REQUIRED=true: portal JWT with apps.includes('cfdm') + permissions. + */ +export async function requireAuth( + request: FastifyRequest, + reply: FastifyReply, +): Promise { + const config = request.server.config; const authHeader = request.headers.authorization ?? ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; if (!token) throw AppError.unauthorized(); + try { await request.jwtVerify(); } catch { throw AppError.unauthorized(); } + + if (!config.authRequired) { + return; + } + + const payload = request.user; + const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []; + const permissions = Array.isArray(payload.permissions) + ? payload.permissions.map(String) + : []; + + if (!apps.includes("cfdm")) { + throw AppError.forbidden("Нет доступа к приложению Cloudflare Domain Manager"); + } + + request.authUser = { + id: String(payload.sub), + email: String(payload.email ?? ""), + name: String(payload.name ?? ""), + apps, + permissions, + isAdmin: Boolean(payload.is_admin), + }; + + const required = permissionForRequest(request.method, request.url); + if (required && !hasPermission(permissions, required)) { + throw AppError.forbidden(`Недостаточно прав: ${required}`); + } } export default fp(authPlugin, { name: "auth" }); diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts index 2693d23..a16d287 100644 --- a/apps/api/src/routes/health.ts +++ b/apps/api/src/routes/health.ts @@ -32,6 +32,14 @@ export async function healthRoutes(app: FastifyInstance) { } export async function authRoutes(app: FastifyInstance) { + app.get("/auth/config", async (request) => { + const { config } = request.server; + return { + required: config.authRequired, + portal_url: config.authPortalUrl, + }; + }); + app.get("/settings/app-switcher", async (request) => { return getAppSwitcher(request.server.db); }); @@ -42,6 +50,14 @@ export async function authRoutes(app: FastifyInstance) { }); app.post("/auth/login", async (request, reply) => { + if (request.server.config.authRequired) { + return reply.code(403).send({ + error: { + code: "FORBIDDEN", + message: "Локальный вход отключён — используйте auth-portal", + }, + }); + } const body = loginSchema.parse(request.body); const result = await authService.login( request.server.config, diff --git a/apps/api/test/auth-portal.test.ts b/apps/api/test/auth-portal.test.ts new file mode 100644 index 0000000..69a13a9 --- /dev/null +++ b/apps/api/test/auth-portal.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import { hasPermission, permissionForRequest } from "../src/lib/permissions.js"; + +describe("permissions helpers", () => { + it("hasPermission respects admin ⊃ write ⊃ read", () => { + expect(hasPermission(["cfdm:domains:write"], "cfdm:domains:read")).toBe( + true, + ); + expect(hasPermission(["cfdm:domains:admin"], "cfdm:domains:write")).toBe( + true, + ); + expect(hasPermission(["cfdm:domains:read"], "cfdm:domains:write")).toBe( + false, + ); + }); + + it("permissionForRequest maps domains and settings", () => { + expect(permissionForRequest("GET", "/api/v1/domains")).toBe( + "cfdm:domains:read", + ); + expect(permissionForRequest("POST", "/api/v1/domains")).toBe( + "cfdm:domains:write", + ); + expect(permissionForRequest("GET", "/api/v1/settings")).toBe( + "cfdm:settings:admin", + ); + expect(permissionForRequest("POST", "/api/v1/sync/foo")).toBe( + "cfdm:domains:write", + ); + }); +}); + +describe("auth plugin (AUTH_REQUIRED)", () => { + const secret = "test-secret-at-least-8"; + const issuer = "https://auth.shnt.top"; + + beforeAll(() => { + process.env.AUTH_REQUIRED = "true"; + process.env.AUTH_JWT_SECRET = secret; + process.env.AUTH_ISSUER = issuer; + process.env.AUTH_PORTAL_URL = "http://localhost:5175"; + }); + + afterAll(() => { + delete process.env.AUTH_REQUIRED; + delete process.env.AUTH_JWT_SECRET; + delete process.env.AUTH_ISSUER; + delete process.env.AUTH_PORTAL_URL; + }); + + it("GET /api/v1/auth/config exposes portal settings", async () => { + const app = await buildApp({ + config: { + ...loadConfig(), + authRequired: true, + jwtSecret: secret, + authIssuer: issuer, + authPortalUrl: "http://localhost:5175", + staticDir: null, + }, + memory: true, + }); + + const res = await app.inject({ method: "GET", url: "/api/v1/auth/config" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ + required: true, + portal_url: "http://localhost:5175", + }); + + await app.close(); + }); + + it("401 without token; 403 without cfdm app; 200 with rights", async () => { + const app = await buildApp({ + config: { + ...loadConfig(), + authRequired: true, + jwtSecret: secret, + authIssuer: issuer, + authPortalUrl: "http://localhost:5175", + staticDir: null, + }, + memory: true, + }); + + const noAuth = await app.inject({ method: "GET", url: "/api/v1/domains" }); + expect(noAuth.statusCode).toBe(401); + + const tokenNoApp = app.jwt.sign( + { + sub: "u1", + email: "a@b.c", + name: "A", + apps: ["vps"], + permissions: ["cfdm:domains:read"], + iss: issuer, + }, + { expiresIn: "1h" }, + ); + const forbiddenApp = await app.inject({ + method: "GET", + url: "/api/v1/domains", + headers: { authorization: `Bearer ${tokenNoApp}` }, + }); + expect(forbiddenApp.statusCode).toBe(403); + + const okToken = app.jwt.sign( + { + sub: "u2", + email: "r@b.c", + name: "R", + apps: ["cfdm"], + permissions: ["cfdm:domains:read"], + iss: issuer, + }, + { expiresIn: "1h" }, + ); + const okRead = await app.inject({ + method: "GET", + url: "/api/v1/domains", + headers: { authorization: `Bearer ${okToken}` }, + }); + expect(okRead.statusCode).toBe(200); + + const denyWrite = await app.inject({ + method: "POST", + url: "/api/v1/domains", + headers: { authorization: `Bearer ${okToken}` }, + payload: { name: "x" }, + }); + expect(denyWrite.statusCode).toBe(403); + + const loginBlocked = await app.inject({ + method: "POST", + url: "/api/v1/auth/login", + payload: { username: "admin", password: "admin" }, + }); + expect(loginBlocked.statusCode).toBe(403); + + await app.close(); + }); +}); diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index d4fd7fa..356a1f0 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -8,6 +8,7 @@ import { SettingsIcon, } from 'lucide-react' import { AppSwitcher } from '@/components/app-switcher' +import { NavUser } from '@/components/nav-user' import { Sidebar, SidebarContent, @@ -106,7 +107,9 @@ export function AppSidebar() { - + + + ) } diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index 8205d70..6e1095a 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -9,7 +9,6 @@ import { BreadcrumbSeparator, } from '@cfdm/ui/components/breadcrumb' import { Separator } from '@cfdm/ui/components/separator' -import { ModeToggle } from '@/components/mode-toggle' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { AppsMenu } from '@/components/layout/apps-menu' import { SidebarTrigger } from '@cfdm/ui/components/sidebar' @@ -93,7 +92,7 @@ function useDynamicBreadcrumbLabels() { }, [matches]) } -/** Header chrome — etalon EvoBGP (Trigger + Separator + Breadcrumb + Apps/Monitor/Mode). */ +/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */ export function SiteHeader() { const pathname = useRouterState({ select: (s) => s.location.pathname }) const dynamicLabels = useDynamicBreadcrumbLabels() @@ -131,7 +130,6 @@ export function SiteHeader() {
-
) diff --git a/apps/web/src/components/nav-user.tsx b/apps/web/src/components/nav-user.tsx index 72cca56..1583e5b 100644 --- a/apps/web/src/components/nav-user.tsx +++ b/apps/web/src/components/nav-user.tsx @@ -1,5 +1,22 @@ -import { useNavigate } from '@tanstack/react-router' -import { AppAvatar, AppAvatarFallback } from '@/components/app-avatar' +import { Link } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { useTheme } from 'next-themes' +import { + ChevronsUpDownIcon, + LogOutIcon, + MonitorIcon, + MoonIcon, + PaletteIcon, + SettingsIcon, + SunIcon, +} from 'lucide-react' + +import { cn } from '@cfdm/ui/lib/utils' +import { + Avatar, + AvatarFallback, +} from '@cfdm/ui/components/avatar' +import { Button } from '@cfdm/ui/components/button' import { DropdownMenu, DropdownMenuContent, @@ -15,16 +32,108 @@ import { SidebarMenuItem, useSidebar, } from '@cfdm/ui/components/sidebar' -import { ChevronsUpDownIcon, LogOutIcon } from 'lucide-react' -import { clearToken } from '@/lib/auth' + +import { + can, + clearToken, + getClaims, + isAuthEnabled, + redirectToPortalLogin, + resetPortalHandoff, +} from '@/lib/auth' + +/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */ + +const THEMES = [ + { + value: 'light', + label: 'Светлая', + icon: , + }, + { + value: 'dark', + label: 'Тёмная', + icon: , + }, + { + value: 'system', + label: 'Системная', + icon: , + }, +] as const + +function ThemeSegmentedToggle() { + const { theme, setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + const currentTheme = mounted ? (theme ?? 'system') : 'system' + + return ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + {THEMES.map(({ value, label, icon }) => { + const isActive = currentTheme === value + return ( + + ) + })} +
+ ) +} + +function initials(name: string, email: string): string { + const base = name.trim() || email.trim() + if (!base) return '?' + const parts = base.split(/\s+/).filter(Boolean) + if (parts.length >= 2) { + return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase() + } + return base.slice(0, 2).toUpperCase() +} export function NavUser() { - const navigate = useNavigate() const { isMobile } = useSidebar() + const claims = getClaims() + const authOn = isAuthEnabled() - const handleLogout = () => { + const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость') + const email = claims?.email?.trim() || (authOn ? '' : 'auth выключен') + const fallback = initials(name, email) + + function handleSignOut() { clearToken() - navigate({ to: '/login' }) + resetPortalHandoff() + if (authOn) { + redirectToPortalLogin() + return + } + window.location.href = '/login' } return ( @@ -33,42 +142,75 @@ export function NavUser() { + } > - - АД - + + + {fallback} + +
- Администратор - admin + {name} + + {email || '—'} +
- -
- - АД - -
- Администратор - admin -
+ + + + {fallback} + + +
+ {name} + + {email || '—'} +
+ - - - Выйти - + + + {can('cfdm:settings:admin') ? ( + } + > + + Настройки + + ) : null} + + + Тема +
+ +
+
+
+ + + + + + Выйти + + diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 2e9df30..b3f845c 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -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 { + 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(path: string, init?: RequestInit): Promise { - 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(path: string, init?: RequestInit): Promise { 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) { diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index deb979e..04e687a 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -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 | 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 { + 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 + 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 '/' } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 1f2e16b..364b971 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LoginRouteImport } from './routes/login' import { Route as AuthRouteImport } from './routes/_auth' import { Route as AuthIndexRouteImport } from './routes/_auth/index' +import { Route as AuthCallbackRouteImport } from './routes/auth.callback' import { Route as AuthServicesRouteImport } from './routes/_auth/services' import { Route as AuthGroupsRouteImport } from './routes/_auth/groups' import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates' @@ -38,6 +39,11 @@ const AuthIndexRoute = AuthIndexRouteImport.update({ path: '/', getParentRoute: () => AuthRoute, } as any) +const AuthCallbackRoute = AuthCallbackRouteImport.update({ + id: '/auth/callback', + path: '/auth/callback', + getParentRoute: () => rootRouteImport, +} as any) const AuthServicesRoute = AuthServicesRouteImport.update({ id: '/services', path: '/services', @@ -103,6 +109,7 @@ export interface FileRoutesByFullPath { '/certificates': typeof AuthCertificatesRoute '/groups': typeof AuthGroupsRouteWithChildren '/services': typeof AuthServicesRoute + '/auth/callback': typeof AuthCallbackRoute '/groups/$groupId': typeof AuthGroupsGroupIdRoute '/settings/appearance': typeof AuthSettingsAppearanceRoute '/settings/integrations': typeof AuthSettingsIntegrationsRoute @@ -116,6 +123,7 @@ export interface FileRoutesByTo { '/certificates': typeof AuthCertificatesRoute '/groups': typeof AuthGroupsRouteWithChildren '/services': typeof AuthServicesRoute + '/auth/callback': typeof AuthCallbackRoute '/': typeof AuthIndexRoute '/groups/$groupId': typeof AuthGroupsGroupIdRoute '/settings/appearance': typeof AuthSettingsAppearanceRoute @@ -133,6 +141,7 @@ export interface FileRoutesById { '/_auth/certificates': typeof AuthCertificatesRoute '/_auth/groups': typeof AuthGroupsRouteWithChildren '/_auth/services': typeof AuthServicesRoute + '/auth/callback': typeof AuthCallbackRoute '/_auth/': typeof AuthIndexRoute '/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute '/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute @@ -151,6 +160,7 @@ export interface FileRouteTypes { | '/certificates' | '/groups' | '/services' + | '/auth/callback' | '/groups/$groupId' | '/settings/appearance' | '/settings/integrations' @@ -164,6 +174,7 @@ export interface FileRouteTypes { | '/certificates' | '/groups' | '/services' + | '/auth/callback' | '/' | '/groups/$groupId' | '/settings/appearance' @@ -180,6 +191,7 @@ export interface FileRouteTypes { | '/_auth/certificates' | '/_auth/groups' | '/_auth/services' + | '/auth/callback' | '/_auth/' | '/_auth/groups/$groupId' | '/_auth/settings/appearance' @@ -193,6 +205,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { AuthRoute: typeof AuthRouteWithChildren LoginRoute: typeof LoginRoute + AuthCallbackRoute: typeof AuthCallbackRoute } declare module '@tanstack/react-router' { @@ -218,6 +231,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthIndexRouteImport parentRoute: typeof AuthRoute } + '/auth/callback': { + id: '/auth/callback' + path: '/auth/callback' + fullPath: '/auth/callback' + preLoaderRoute: typeof AuthCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/_auth/services': { id: '/_auth/services' path: '/services' @@ -352,6 +372,7 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) const rootRouteChildren: RootRouteChildren = { AuthRoute: AuthRouteWithChildren, LoginRoute: LoginRoute, + AuthCallbackRoute: AuthCallbackRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 713c870..2e6957e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,6 +1,11 @@ import { createRootRouteWithContext, Outlet, redirect } from '@tanstack/react-router' import type { QueryClient } from '@tanstack/react-query' -import { getToken } from '@/lib/auth' +import { + ensureAuthConfig, + getClaims, + getToken, + redirectToPortalLogin, +} from '@/lib/auth' export interface RouterContext { queryClient: QueryClient @@ -8,9 +13,46 @@ export interface RouterContext { export const Route = createRootRouteWithContext()({ component: () => , - beforeLoad: ({ location }) => { + beforeLoad: async ({ location }) => { const isLogin = location.pathname === '/login' + const isCallback = location.pathname === '/auth/callback' + if (isCallback) return + + const cfg = await ensureAuthConfig() const token = getToken() + const claims = getClaims() + + if (cfg.required) { + if (isLogin) { + const ok = redirectToPortalLogin( + `${window.location.origin}/auth/callback`, + ) + if (!ok) { + throw redirect({ + to: '/auth/callback', + search: { error: 'sso_loop' }, + }) + } + await new Promise(() => {}) + return + } + if (!token || !claims) { + const ok = redirectToPortalLogin( + `${window.location.origin}/auth/callback`, + ) + if (!ok) { + throw redirect({ + to: '/auth/callback', + search: { error: 'sso_loop' }, + }) + } + await new Promise(() => {}) + return + } + return + } + + // Local auth mode (AUTH_REQUIRED=false) if (!token && !isLogin) { throw redirect({ to: '/login' }) } diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 341f838..0b9e530 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -1,7 +1,47 @@ -import { createFileRoute, Outlet } from '@tanstack/react-router' +import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' import { AppShell } from '@/components/layout/app-shell' +import { + can, + ensureAuthConfig, + firstAllowedPath, + getClaims, + getToken, + permissionForPath, + redirectToPortalLogin, +} from '@/lib/auth' export const Route = createFileRoute('/_auth')({ + beforeLoad: async ({ location }) => { + const cfg = await ensureAuthConfig() + if (!cfg.required) return + + const token = getToken() + const claims = getClaims() + if (!token || !claims) { + const ok = redirectToPortalLogin( + `${window.location.origin}/auth/callback`, + ) + if (!ok) { + throw redirect({ + to: '/auth/callback', + search: { error: 'sso_loop' }, + }) + } + await new Promise(() => {}) + return + } + if (!claims.apps.includes('cfdm')) { + throw redirect({ to: '/' }) + } + + const perm = permissionForPath(location.pathname) + if (perm && !can(perm)) { + const fallback = firstAllowedPath() + if (fallback !== location.pathname) { + throw redirect({ to: fallback as '/' }) + } + } + }, component: () => ( diff --git a/apps/web/src/routes/_auth/settings/appearance.tsx b/apps/web/src/routes/_auth/settings/appearance.tsx index 7343837..207f667 100644 --- a/apps/web/src/routes/_auth/settings/appearance.tsx +++ b/apps/web/src/routes/_auth/settings/appearance.tsx @@ -4,7 +4,12 @@ import { toast } from 'sonner' import { LogOutIcon, PaletteIcon } from 'lucide-react' import { api } from '@/lib/api-client' -import { clearToken } from '@/lib/auth' +import { + clearToken, + isAuthEnabled, + redirectToPortalLogin, + resetPortalHandoff, +} from '@/lib/auth' import { SettingRow } from '@/components/setting-row' import { Switch } from '@cfdm/ui/components/switch' import { Button } from '@cfdm/ui/components/button' @@ -53,6 +58,11 @@ function AppearanceSettingsPage() { const handleLogout = () => { clearToken() + resetPortalHandoff() + if (isAuthEnabled()) { + redirectToPortalLogin() + return + } void navigate({ to: '/login' }) } @@ -82,7 +92,7 @@ function AppearanceSettingsPage() {