diff --git a/app/(auth)/portal/PortalLoginForm.tsx b/app/(auth)/portal/PortalLoginForm.tsx new file mode 100644 index 00000000..b7906e13 --- /dev/null +++ b/app/(auth)/portal/PortalLoginForm.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { FormEvent, useEffect, useState } from "react"; +import { Shield } from "lucide-react"; +import { signIn } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Separator } from "@/components/ui/separator"; + +interface PortalLoginFormProps { + redirectUri: string; + targetDomain: string; + enabledProviders?: Array<{ id: string; name: string }>; + existingSession?: { userId: string; name: string | null; email: string | null } | null; +} + +export default function PortalLoginForm({ + redirectUri, + targetDomain, + enabledProviders = [], + existingSession, +}: PortalLoginFormProps) { + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [oauthPending, setOauthPending] = useState(null); + + // If user already has a NextAuth session (e.g. from OAuth), auto-create forward auth session + useEffect(() => { + if (existingSession && redirectUri) { + setPending(true); + fetch("/api/forward-auth/session-login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ redirectUri }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.redirectTo) { + window.location.href = data.redirectTo; + } else { + setError(data.error ?? "Failed to authorize access."); + setPending(false); + } + }) + .catch(() => { + setError("An unexpected error occurred."); + setPending(false); + }); + } + }, [existingSession, redirectUri]); + + const handleCredentialSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setPending(true); + + const formData = new FormData(event.currentTarget); + const username = String(formData.get("username") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + + if (!username || !password) { + setError("Username and password are required."); + setPending(false); + return; + } + + try { + const response = await fetch("/api/forward-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, redirectUri }), + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error ?? "Login failed."); + setPending(false); + return; + } + + window.location.href = data.redirectTo; + } catch { + setError("An unexpected error occurred. Please try again."); + setPending(false); + } + }; + + const handleOAuthSignIn = (providerId: string) => { + setError(null); + setOauthPending(providerId); + // Redirect back to this portal page after OAuth, with the rd param preserved + const callbackUrl = `/portal?rd=${encodeURIComponent(redirectUri)}`; + signIn(providerId, { callbackUrl }); + }; + + const disabled = pending || !!oauthPending; + + if (!redirectUri) { + return ( +
+ + + Authentication Required + No redirect destination specified. + + +
+ ); + } + + // If we have a session and are auto-redirecting, show a loading state + if (existingSession && pending && !error) { + return ( +
+ + +
+ +
+ Authorizing... + + Signing in as {existingSession.name ?? existingSession.email} + +
+
+
+ ); + } + + return ( +
+ + +
+ +
+ Authentication Required + + {targetDomain + ? <>Sign in to access {targetDomain} + : "Sign in to continue" + } + +
+ + {error && ( + + {error} + + )} + + {enabledProviders.length > 0 && ( + <> +
+ {enabledProviders.map((provider) => { + const isPending = oauthPending === provider.id; + return ( + + ); + })} +
+
+ + + or + +
+ + )} + +
+
+ + +
+
+ + +
+ +
+
+
+
+ ); +} diff --git a/app/(auth)/portal/page.tsx b/app/(auth)/portal/page.tsx new file mode 100644 index 00000000..3095a866 --- /dev/null +++ b/app/(auth)/portal/page.tsx @@ -0,0 +1,33 @@ +import { auth } from "@/src/lib/auth"; +import { getEnabledOAuthProviders } from "@/src/lib/config"; +import PortalLoginForm from "./PortalLoginForm"; + +interface PortalPageProps { + searchParams: Promise<{ rd?: string }>; +} + +export default async function PortalPage({ searchParams }: PortalPageProps) { + const params = await searchParams; + const redirectUri = params.rd ?? ""; + + let targetDomain = ""; + try { + if (redirectUri) { + targetDomain = new URL(redirectUri).hostname; + } + } catch { + // invalid URL — will be caught by the login endpoint + } + + const session = await auth(); + const enabledProviders = getEnabledOAuthProviders(); + + return ( + + ); +} diff --git a/app/(dashboard)/DashboardLayoutClient.tsx b/app/(dashboard)/DashboardLayoutClient.tsx index d3494252..58adb74b 100644 --- a/app/(dashboard)/DashboardLayoutClient.tsx +++ b/app/(dashboard)/DashboardLayoutClient.tsx @@ -7,7 +7,7 @@ import { useTheme } from "next-themes"; import { LayoutDashboard, ArrowLeftRight, Cable, KeyRound, ShieldCheck, ShieldOff, BarChart2, History, Settings, LogOut, Menu, Sun, Moon, - FileJson2, + FileJson2, Users, } from "lucide-react"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; @@ -28,6 +28,7 @@ const NAV_ITEMS = [ { href: "/proxy-hosts", label: "Proxy Hosts", icon: ArrowLeftRight }, { href: "/l4-proxy-hosts", label: "L4 Proxy Hosts", icon: Cable }, { href: "/access-lists", label: "Access Lists", icon: KeyRound }, + { href: "/groups", label: "Groups", icon: Users }, { href: "/certificates", label: "Certificates", icon: ShieldCheck }, { href: "/waf", label: "WAF", icon: ShieldOff }, { href: "/analytics", label: "Analytics", icon: BarChart2 }, diff --git a/app/(dashboard)/groups/GroupsClient.tsx b/app/(dashboard)/groups/GroupsClient.tsx new file mode 100644 index 00000000..94aebe93 --- /dev/null +++ b/app/(dashboard)/groups/GroupsClient.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useState } from "react"; +import { Users, Plus, Trash2, UserPlus, UserMinus } from "lucide-react"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { useRouter } from "next/navigation"; +import { + createGroupAction, + deleteGroupAction, + addGroupMemberAction, + removeGroupMemberAction +} from "./actions"; + +type GroupMember = { + user_id: number; + email: string; + name: string | null; + created_at: string; +}; + +type Group = { + id: number; + name: string; + description: string | null; + members: GroupMember[]; + created_at: string; + updated_at: string; +}; + +type UserEntry = { + id: number; + email: string; + name: string | null; + role: string; +}; + +type Props = { + groups: Group[]; + users: UserEntry[]; +}; + +export default function GroupsClient({ groups, users }: Props) { + const router = useRouter(); + const [showCreate, setShowCreate] = useState(false); + const [addMemberGroupId, setAddMemberGroupId] = useState(null); + + function getAvailableUsers(group: Group): UserEntry[] { + const memberIds = new Set(group.members.map((m) => m.user_id)); + return users.filter((u) => !memberIds.has(u.id)); + } + + return ( +
+ + +
+ +
+ + {showCreate && ( + + +
{ + await createGroupAction(formData); + setShowCreate(false); + router.refresh(); + }} + className="flex flex-col gap-3" + > +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ )} + + {groups.length === 0 && !showCreate && ( + + + +

No groups yet. Create one to organize user access.

+
+
+ )} + +
+ {groups.map((group) => { + const available = getAvailableUsers(group); + return ( + + +
+
+

{group.name}

+ {group.description && ( +

{group.description}

+ )} +
+
+ + {group.members.length} member{group.members.length !== 1 ? "s" : ""} + + + +
+
+ + {addMemberGroupId === group.id && ( +
+

Add a user to this group

+ {available.length === 0 ? ( +

All users are already in this group.

+ ) : ( +
+ {available.map((user) => ( + + ))} +
+ )} + +
+ )} + + {group.members.length > 0 && ( + <> + +
+ {group.members.map((member) => ( +
+
+
+ {(member.name ?? member.email)[0]?.toUpperCase()} +
+ + {member.name ?? member.email.split("@")[0]} + + + {member.email} + +
+ +
+ ))} +
+ + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/app/(dashboard)/groups/actions.ts b/app/(dashboard)/groups/actions.ts new file mode 100644 index 00000000..5dfee6fd --- /dev/null +++ b/app/(dashboard)/groups/actions.ts @@ -0,0 +1,63 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireAdmin } from "@/src/lib/auth"; +import { + createGroup, + updateGroup, + deleteGroup, + addGroupMember, + removeGroupMember +} from "@/src/lib/models/groups"; + +export async function createGroupAction(formData: FormData) { + const session = await requireAdmin(); + const userId = Number(session.user.id); + + await createGroup( + { + name: String(formData.get("name") ?? ""), + description: formData.get("description") ? String(formData.get("description")) : null, + }, + userId + ); + + revalidatePath("/groups"); +} + +export async function updateGroupAction(id: number, formData: FormData) { + const session = await requireAdmin(); + const userId = Number(session.user.id); + + await updateGroup( + id, + { + name: String(formData.get("name") ?? ""), + description: formData.get("description") ? String(formData.get("description")) : null, + }, + userId + ); + + revalidatePath("/groups"); +} + +export async function deleteGroupAction(id: number) { + const session = await requireAdmin(); + const userId = Number(session.user.id); + await deleteGroup(id, userId); + revalidatePath("/groups"); +} + +export async function addGroupMemberAction(groupId: number, memberId: number) { + const session = await requireAdmin(); + const userId = Number(session.user.id); + await addGroupMember(groupId, memberId, userId); + revalidatePath("/groups"); +} + +export async function removeGroupMemberAction(groupId: number, memberId: number) { + const session = await requireAdmin(); + const userId = Number(session.user.id); + await removeGroupMember(groupId, memberId, userId); + revalidatePath("/groups"); +} diff --git a/app/(dashboard)/groups/page.tsx b/app/(dashboard)/groups/page.tsx new file mode 100644 index 00000000..b43a8bc9 --- /dev/null +++ b/app/(dashboard)/groups/page.tsx @@ -0,0 +1,16 @@ +import GroupsClient from "./GroupsClient"; +import { listGroups } from "@/src/lib/models/groups"; +import { listUsers } from "@/src/lib/models/user"; +import { requireAdmin } from "@/src/lib/auth"; + +export default async function GroupsPage() { + await requireAdmin(); + const [allGroups, allUsers] = await Promise.all([listGroups(), listUsers()]); + const userList = allUsers.map((u) => ({ + id: u.id, + email: u.email, + name: u.name, + role: u.role, + })); + return ; +} diff --git a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx index 349fe61d..53b6d9fd 100644 --- a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx +++ b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx @@ -28,6 +28,10 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +type ForwardAuthUser = { id: number; email: string; name: string | null; role: string }; +type ForwardAuthGroup = { id: number; name: string; description: string | null; member_count: number }; +type ForwardAuthAccessMap = Record; + type Props = { hosts: ProxyHost[]; certificates: Certificate[]; @@ -39,9 +43,12 @@ type Props = { initialSort?: { sortBy: string; sortDir: "asc" | "desc" }; mtlsRoles?: MtlsRole[]; issuedClientCerts?: IssuedClientCertificate[]; + forwardAuthUsers?: ForwardAuthUser[]; + forwardAuthGroups?: ForwardAuthGroup[]; + forwardAuthAccessMap?: ForwardAuthAccessMap; }; -export default function ProxyHostsClient({ hosts, certificates, accessLists, caCertificates, authentikDefaults, pagination, initialSearch, initialSort, mtlsRoles, issuedClientCerts }: Props) { +export default function ProxyHostsClient({ hosts, certificates, accessLists, caCertificates, authentikDefaults, pagination, initialSearch, initialSort, mtlsRoles, issuedClientCerts, forwardAuthUsers, forwardAuthGroups, forwardAuthAccessMap }: Props) { const [createOpen, setCreateOpen] = useState(false); const [duplicateHost, setDuplicateHost] = useState(null); const [editHost, setEditHost] = useState(null); @@ -303,6 +310,8 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC caCertificates={caCertificates} mtlsRoles={mtlsRoles ?? []} issuedClientCerts={issuedClientCerts ?? []} + forwardAuthUsers={forwardAuthUsers ?? []} + forwardAuthGroups={forwardAuthGroups ?? []} /> {editHost && ( @@ -315,6 +324,9 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC caCertificates={caCertificates} mtlsRoles={mtlsRoles ?? []} issuedClientCerts={issuedClientCerts ?? []} + forwardAuthUsers={forwardAuthUsers ?? []} + forwardAuthGroups={forwardAuthGroups ?? []} + forwardAuthAccess={forwardAuthAccessMap?.[editHost.id] ?? null} /> )} diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts index cd908581..13af9b0e 100644 --- a/app/(dashboard)/proxy-hosts/actions.ts +++ b/app/(dashboard)/proxy-hosts/actions.ts @@ -16,9 +16,11 @@ import { type WafHostConfig, type MtlsConfig, type RedirectRule, - type RewriteConfig + type RewriteConfig, + type CpmForwardAuthInput } from "@/src/lib/models/proxy-hosts"; import { getCertificate } from "@/src/lib/models/certificates"; +import { setForwardAuthAccess } from "@/src/lib/models/forward-auth"; import { getCloudflareSettings, type GeoBlockSettings } from "@/src/lib/settings"; import { parseCsv, @@ -108,6 +110,30 @@ function parseAuthentikConfig(formData: FormData): ProxyHostAuthentikInput | und return Object.keys(result).length > 0 ? result : undefined; } +function parseCpmForwardAuthConfig(formData: FormData): CpmForwardAuthInput | undefined { + if (!formData.has("cpm_forward_auth_present")) { + return undefined; + } + + const enabledIndicator = formData.has("cpm_forward_auth_enabled_present"); + const enabledValue = enabledIndicator + ? formData.has("cpm_forward_auth_enabled") + ? parseCheckbox(formData.get("cpm_forward_auth_enabled")) + : false + : undefined; + const protectedPaths = parseCsv(formData.get("cpm_forward_auth_protected_paths")); + + const result: CpmForwardAuthInput = {}; + if (enabledValue !== undefined) { + result.enabled = enabledValue; + } + if (protectedPaths.length > 0 || formData.has("cpm_forward_auth_protected_paths")) { + result.protected_paths = protectedPaths.length > 0 ? protectedPaths : null; + } + + return Object.keys(result).length > 0 ? result : undefined; +} + function parseRedirectUrl(raw: FormDataEntryValue | null): string { if (!raw || typeof raw !== "string") return ""; const trimmed = raw.trim(); @@ -485,7 +511,7 @@ export async function createProxyHostAction( console.warn(`[createProxyHostAction] ${warning}`); } - await createProxyHost( + const host = await createProxyHost( { name: String(formData.get("name") ?? "Untitled"), domains: parseCsv(formData.get("domains")), @@ -499,6 +525,7 @@ export async function createProxyHostAction( custom_pre_handlers_json: parseOptionalText(formData.get("custom_pre_handlers_json")), custom_reverse_proxy_json: parseOptionalText(formData.get("custom_reverse_proxy_json")), authentik: parseAuthentikConfig(formData), + cpm_forward_auth: parseCpmForwardAuthConfig(formData), load_balancer: parseLoadBalancerConfig(formData), dns_resolver: parseDnsResolverConfig(formData), upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData), @@ -511,6 +538,14 @@ export async function createProxyHostAction( }, userId ); + + // Save forward auth access if CPM forward auth is enabled + const faUserIds = formData.getAll("cpm_fa_user_id").map((v) => Number(v)).filter((n) => n > 0); + const faGroupIds = formData.getAll("cpm_fa_group_id").map((v) => Number(v)).filter((n) => n > 0); + if (host.cpm_forward_auth?.enabled && (faUserIds.length > 0 || faGroupIds.length > 0)) { + await setForwardAuthAccess(host.id, { userIds: faUserIds, groupIds: faGroupIds }, userId); + } + revalidatePath("/proxy-hosts"); // Return success with warning if applicable @@ -576,6 +611,7 @@ export async function updateProxyHostAction( ? parseOptionalText(formData.get("custom_reverse_proxy_json")) : undefined, authentik: parseAuthentikConfig(formData), + cpm_forward_auth: parseCpmForwardAuthConfig(formData), load_balancer: parseLoadBalancerConfig(formData), dns_resolver: parseDnsResolverConfig(formData), upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData), @@ -588,6 +624,14 @@ export async function updateProxyHostAction( }, userId ); + + // Save forward auth access if the section is present in the form + if (formData.has("cpm_forward_auth_present")) { + const faUserIds = formData.getAll("cpm_fa_user_id").map((v) => Number(v)).filter((n) => n > 0); + const faGroupIds = formData.getAll("cpm_fa_group_id").map((v) => Number(v)).filter((n) => n > 0); + await setForwardAuthAccess(id, { userIds: faUserIds, groupIds: faGroupIds }, userId); + } + revalidatePath("/proxy-hosts"); // Return success with warning if applicable diff --git a/app/(dashboard)/proxy-hosts/page.tsx b/app/(dashboard)/proxy-hosts/page.tsx index 27e85a36..aea63402 100644 --- a/app/(dashboard)/proxy-hosts/page.tsx +++ b/app/(dashboard)/proxy-hosts/page.tsx @@ -6,6 +6,9 @@ import { listAccessLists } from "@/src/lib/models/access-lists"; import { getAuthentikSettings } from "@/src/lib/settings"; import { listMtlsRoles } from "@/src/lib/models/mtls-roles"; import { listIssuedClientCertificates } from "@/src/lib/models/issued-client-certificates"; +import { listUsers } from "@/src/lib/models/user"; +import { listGroups } from "@/src/lib/models/groups"; +import { getForwardAuthAccessForHost } from "@/src/lib/models/forward-auth"; import { requireAdmin } from "@/src/lib/auth"; const PER_PAGE = 25; @@ -32,11 +35,40 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) { getAuthentikSettings(), ]); // These are safe to fail if the RBAC migration hasn't been applied yet - const [mtlsRoles, issuedClientCerts] = await Promise.all([ + const [mtlsRoles, issuedClientCerts, allUsers, allGroups] = await Promise.all([ listMtlsRoles().catch(() => []), listIssuedClientCertificates().catch(() => []), + listUsers().catch(() => []), + listGroups().catch(() => []), ]); + // Build forward auth access map for hosts that have CPM forward auth enabled + const faHosts = hosts.filter((h) => h.cpm_forward_auth?.enabled); + const faAccessEntries = await Promise.all( + faHosts.map((h) => getForwardAuthAccessForHost(h.id).catch(() => [])) + ); + const forwardAuthAccessMap: Record = {}; + faHosts.forEach((h, i) => { + const entries = faAccessEntries[i]; + forwardAuthAccessMap[h.id] = { + userIds: entries.filter((e) => e.user_id !== null).map((e) => e.user_id!), + groupIds: entries.filter((e) => e.group_id !== null).map((e) => e.group_id!), + }; + }); + + const forwardAuthUsers = allUsers.map((u) => ({ + id: u.id, + email: u.email, + name: u.name, + role: u.role, + })); + const forwardAuthGroups = allGroups.map((g) => ({ + id: g.id, + name: g.name, + description: g.description, + member_count: g.members.length, + })); + return ( ); } diff --git a/app/api/forward-auth/callback/route.ts b/app/api/forward-auth/callback/route.ts new file mode 100644 index 00000000..ed84887d --- /dev/null +++ b/app/api/forward-auth/callback/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { redeemExchangeCode } from "@/src/lib/models/forward-auth"; + +const COOKIE_NAME = "_cpm_fa"; +const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days + +/** + * Forward auth callback — redeems an exchange code and sets the session cookie. + * Caddy routes /.cpm-auth/callback on proxied domains to this endpoint. + */ +export async function GET(request: NextRequest) { + const code = request.nextUrl.searchParams.get("code"); + if (!code) { + return new NextResponse("Missing code parameter", { status: 400 }); + } + + const result = await redeemExchangeCode(code); + if (!result) { + return new NextResponse( + "Invalid or expired authorization code. Please try logging in again.", + { status: 401 } + ); + } + + // Redirect back to original URL with the session cookie set + const response = NextResponse.redirect(result.redirectUri, 302); + + response.cookies.set(COOKIE_NAME, result.rawSessionToken, { + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: COOKIE_MAX_AGE + }); + + return response; +} diff --git a/app/api/forward-auth/login/route.ts b/app/api/forward-auth/login/route.ts new file mode 100644 index 00000000..8313a1aa --- /dev/null +++ b/app/api/forward-auth/login/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import db from "@/src/lib/db"; +import { config } from "@/src/lib/config"; +import { + createForwardAuthSession, + createExchangeCode, + checkHostAccessByDomain +} from "@/src/lib/models/forward-auth"; +import { logAuditEvent } from "@/src/lib/audit"; +import { isRateLimited } from "@/src/lib/rate-limit"; + +/** + * Forward auth login endpoint — validates credentials and starts the exchange flow. + * Called by the portal login form. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const username = typeof body.username === "string" ? body.username.trim() : ""; + const password = typeof body.password === "string" ? body.password : ""; + const redirectUri = typeof body.redirectUri === "string" ? body.redirectUri : ""; + + if (!username || !password) { + return NextResponse.json({ error: "Username and password are required" }, { status: 400 }); + } + if (!redirectUri) { + return NextResponse.json({ error: "Redirect URI is required" }, { status: 400 }); + } + + // Validate redirect URI + let targetUrl: URL; + try { + targetUrl = new URL(redirectUri); + } catch { + return NextResponse.json({ error: "Invalid redirect URI" }, { status: 400 }); + } + + // Rate limiting + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const rateLimitResult = isRateLimited(ip); + if (rateLimitResult.blocked) { + return NextResponse.json( + { error: "Too many login attempts. Please try again later." }, + { status: 429 } + ); + } + + // Authenticate using the same logic as the credentials provider + const email = `${username}@localhost`; + const user = await db.query.users.findFirst({ + where: (table, operators) => operators.eq(table.email, email) + }); + + if (!user || user.status !== "active" || !user.passwordHash) { + logAuditEvent({ + userId: null, + action: "forward_auth_login_failed", + entityType: "user", + summary: `Forward auth login failed for username: ${username}` + }); + return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); + } + + const isValid = bcrypt.compareSync(password, user.passwordHash); + if (!isValid) { + logAuditEvent({ + userId: user.id, + action: "forward_auth_login_failed", + entityType: "user", + entityId: user.id, + summary: `Forward auth login failed for user ${user.email}` + }); + return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); + } + + // Check if user has access to the target host + const { hasAccess } = await checkHostAccessByDomain(user.id, targetUrl.hostname); + if (!hasAccess) { + logAuditEvent({ + userId: user.id, + action: "forward_auth_access_denied", + entityType: "proxy_host", + summary: `Forward auth access denied for user ${user.email} to host ${targetUrl.hostname}` + }); + return NextResponse.json( + { error: "You do not have access to this application." }, + { status: 403 } + ); + } + + // Create session and exchange code + const { rawToken, session } = await createForwardAuthSession(user.id); + const { rawCode } = await createExchangeCode(session.id, rawToken, redirectUri); + + logAuditEvent({ + userId: user.id, + action: "forward_auth_login", + entityType: "user", + entityId: user.id, + summary: `Forward auth login for user ${user.email} to ${targetUrl.hostname}` + }); + + // Build callback URL on the target domain + const callbackUrl = new URL("/.cpm-auth/callback", targetUrl.origin); + callbackUrl.searchParams.set("code", rawCode); + + return NextResponse.json({ redirectTo: callbackUrl.toString() }); + } catch (error) { + console.error("Forward auth login error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/forward-auth/session-login/route.ts b/app/api/forward-auth/session-login/route.ts new file mode 100644 index 00000000..30aaf83d --- /dev/null +++ b/app/api/forward-auth/session-login/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/src/lib/auth"; +import { + createForwardAuthSession, + createExchangeCode, + checkHostAccessByDomain +} from "@/src/lib/models/forward-auth"; +import { logAuditEvent } from "@/src/lib/audit"; + +/** + * Forward auth session login — creates a forward auth session from an existing + * NextAuth session (used after OAuth login redirects back to the portal). + */ +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const body = await request.json(); + const redirectUri = typeof body.redirectUri === "string" ? body.redirectUri : ""; + + if (!redirectUri) { + return NextResponse.json({ error: "Redirect URI is required" }, { status: 400 }); + } + + let targetUrl: URL; + try { + targetUrl = new URL(redirectUri); + } catch { + return NextResponse.json({ error: "Invalid redirect URI" }, { status: 400 }); + } + + const userId = Number(session.user.id); + + // Check if user has access to the target host + const { hasAccess } = await checkHostAccessByDomain(userId, targetUrl.hostname); + if (!hasAccess) { + logAuditEvent({ + userId, + action: "forward_auth_access_denied", + entityType: "proxy_host", + summary: `Forward auth access denied for user ${session.user.email} to host ${targetUrl.hostname}` + }); + return NextResponse.json( + { error: "You do not have access to this application." }, + { status: 403 } + ); + } + + // Create forward auth session and exchange code + const { rawToken, session: faSession } = await createForwardAuthSession(userId); + const { rawCode } = await createExchangeCode(faSession.id, rawToken, redirectUri); + + logAuditEvent({ + userId, + action: "forward_auth_login", + entityType: "user", + entityId: userId, + summary: `Forward auth login (session) for user ${session.user.email} to ${targetUrl.hostname}` + }); + + const callbackUrl = new URL("/.cpm-auth/callback", targetUrl.origin); + callbackUrl.searchParams.set("code", rawCode); + + return NextResponse.json({ redirectTo: callbackUrl.toString() }); + } catch (error) { + console.error("Forward auth session login error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/forward-auth/verify/route.ts b/app/api/forward-auth/verify/route.ts new file mode 100644 index 00000000..fbcb2015 --- /dev/null +++ b/app/api/forward-auth/verify/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateForwardAuthSession, checkHostAccessByDomain } from "@/src/lib/models/forward-auth"; +import { getUserById } from "@/src/lib/models/user"; +import { getGroupsForUser } from "@/src/lib/models/groups"; + +const COOKIE_NAME = "_cpm_fa"; + +/** + * Forward auth verify endpoint — called by Caddy as a subrequest. + * Returns 200 + user headers on success, 401 on failure. + */ +export async function GET(request: NextRequest) { + const token = request.cookies.get(COOKIE_NAME)?.value; + if (!token) { + return new NextResponse(null, { status: 401 }); + } + + const session = await validateForwardAuthSession(token); + if (!session) { + return new NextResponse(null, { status: 401 }); + } + + const user = await getUserById(session.userId); + if (!user || user.status !== "active") { + return new NextResponse(null, { status: 401 }); + } + + // Check host access using X-Forwarded-Host header set by Caddy + const forwardedHost = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? ""; + if (!forwardedHost) { + return new NextResponse(null, { status: 401 }); + } + + const { hasAccess } = await checkHostAccessByDomain(session.userId, forwardedHost); + if (!hasAccess) { + return new NextResponse("Forbidden", { status: 403 }); + } + + // Get user's groups for the header + const userGroups = await getGroupsForUser(session.userId); + const groupNames = userGroups.map((g) => g.name).join(","); + + // Return 200 with user info headers that Caddy will copy to upstream + return new NextResponse(null, { + status: 200, + headers: { + "X-CPM-User": user.name ?? user.email.split("@")[0], + "X-CPM-Email": user.email, + "X-CPM-Groups": groupNames, + "X-CPM-User-Id": String(user.id) + } + }); +} diff --git a/app/api/v1/forward-auth-sessions/[id]/route.ts b/app/api/v1/forward-auth-sessions/[id]/route.ts new file mode 100644 index 00000000..58e8c2ed --- /dev/null +++ b/app/api/v1/forward-auth-sessions/[id]/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { deleteForwardAuthSession } from "@/src/lib/models/forward-auth"; + +type Params = { params: Promise<{ id: string }> }; + +export async function DELETE(request: NextRequest, { params }: Params) { + try { + await requireApiAdmin(request); + const { id } = await params; + await deleteForwardAuthSession(Number(id)); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/forward-auth-sessions/route.ts b/app/api/v1/forward-auth-sessions/route.ts new file mode 100644 index 00000000..9e071928 --- /dev/null +++ b/app/api/v1/forward-auth-sessions/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { + listForwardAuthSessions, + deleteUserForwardAuthSessions +} from "@/src/lib/models/forward-auth"; + +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const sessions = await listForwardAuthSessions(); + return NextResponse.json(sessions); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function DELETE(request: NextRequest) { + try { + await requireApiAdmin(request); + const userId = request.nextUrl.searchParams.get("userId"); + if (!userId) { + return NextResponse.json({ error: "userId query parameter is required" }, { status: 400 }); + } + await deleteUserForwardAuthSessions(Number(userId)); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/groups/[id]/members/[userId]/route.ts b/app/api/v1/groups/[id]/members/[userId]/route.ts new file mode 100644 index 00000000..9d7fbd23 --- /dev/null +++ b/app/api/v1/groups/[id]/members/[userId]/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { removeGroupMember } from "@/src/lib/models/groups"; + +type Params = { params: Promise<{ id: string; userId: string }> }; + +export async function DELETE(request: NextRequest, { params }: Params) { + try { + const { userId: actorUserId } = await requireApiAdmin(request); + const { id, userId } = await params; + const group = await removeGroupMember(Number(id), Number(userId), actorUserId); + return NextResponse.json(group); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/groups/[id]/members/route.ts b/app/api/v1/groups/[id]/members/route.ts new file mode 100644 index 00000000..2c3af2f5 --- /dev/null +++ b/app/api/v1/groups/[id]/members/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { addGroupMember } from "@/src/lib/models/groups"; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { userId: actorUserId } = await requireApiAdmin(request); + const { id } = await params; + const body = await request.json(); + if (!body.userId) { + return NextResponse.json({ error: "userId is required" }, { status: 400 }); + } + const group = await addGroupMember(Number(id), Number(body.userId), actorUserId); + return NextResponse.json(group, { status: 201 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/groups/[id]/route.ts b/app/api/v1/groups/[id]/route.ts new file mode 100644 index 00000000..7982c477 --- /dev/null +++ b/app/api/v1/groups/[id]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { getGroup, updateGroup, deleteGroup } from "@/src/lib/models/groups"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(request: NextRequest, { params }: Params) { + try { + await requireApiAdmin(request); + const { id } = await params; + const group = await getGroup(Number(id)); + if (!group) { + return NextResponse.json({ error: "Group not found" }, { status: 404 }); + } + return NextResponse.json(group); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + const body = await request.json(); + const group = await updateGroup(Number(id), body, userId); + return NextResponse.json(group); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function DELETE(request: NextRequest, { params }: Params) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + await deleteGroup(Number(id), userId); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/groups/route.ts b/app/api/v1/groups/route.ts new file mode 100644 index 00000000..e1c0ec3b --- /dev/null +++ b/app/api/v1/groups/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { listGroups, createGroup } from "@/src/lib/models/groups"; + +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const allGroups = await listGroups(); + return NextResponse.json(allGroups); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function POST(request: NextRequest) { + try { + const { userId } = await requireApiAdmin(request); + const body = await request.json(); + const group = await createGroup(body, userId); + return NextResponse.json(group, { status: 201 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/proxy-hosts/[id]/forward-auth-access/route.ts b/app/api/v1/proxy-hosts/[id]/forward-auth-access/route.ts new file mode 100644 index 00000000..67d8829e --- /dev/null +++ b/app/api/v1/proxy-hosts/[id]/forward-auth-access/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { + getForwardAuthAccessForHost, + setForwardAuthAccess +} from "@/src/lib/models/forward-auth"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(request: NextRequest, { params }: Params) { + try { + await requireApiAdmin(request); + const { id } = await params; + const access = await getForwardAuthAccessForHost(Number(id)); + return NextResponse.json(access); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function PUT(request: NextRequest, { params }: Params) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + const body = await request.json(); + const access = await setForwardAuthAccess( + Number(id), + { userIds: body.userIds, groupIds: body.groupIds }, + userId + ); + return NextResponse.json(access); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/drizzle/0017_forward_auth.sql b/drizzle/0017_forward_auth.sql new file mode 100644 index 00000000..42dadf51 --- /dev/null +++ b/drizzle/0017_forward_auth.sql @@ -0,0 +1,65 @@ +-- Forward Auth: groups, group membership, per-host access control, sessions, and exchange codes + +CREATE TABLE `groups` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `description` text, + `created_by` integer REFERENCES `users`(`id`) ON DELETE SET NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `groups_name_unique` ON `groups` (`name`); +--> statement-breakpoint +CREATE TABLE `group_members` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `group_id` integer NOT NULL REFERENCES `groups`(`id`) ON DELETE CASCADE, + `user_id` integer NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE, + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `group_members_unique` ON `group_members` (`group_id`, `user_id`); +--> statement-breakpoint +CREATE INDEX `group_members_user_idx` ON `group_members` (`user_id`); +--> statement-breakpoint +CREATE TABLE `forward_auth_access` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `proxy_host_id` integer NOT NULL REFERENCES `proxy_hosts`(`id`) ON DELETE CASCADE, + `user_id` integer REFERENCES `users`(`id`) ON DELETE CASCADE, + `group_id` integer REFERENCES `groups`(`id`) ON DELETE CASCADE, + `created_at` text NOT NULL, + CHECK ((`user_id` IS NOT NULL AND `group_id` IS NULL) OR (`user_id` IS NULL AND `group_id` IS NOT NULL)) +); +--> statement-breakpoint +CREATE INDEX `faa_host_idx` ON `forward_auth_access` (`proxy_host_id`); +--> statement-breakpoint +CREATE UNIQUE INDEX `faa_user_unique` ON `forward_auth_access` (`proxy_host_id`, `user_id`); +--> statement-breakpoint +CREATE UNIQUE INDEX `faa_group_unique` ON `forward_auth_access` (`proxy_host_id`, `group_id`); +--> statement-breakpoint +CREATE TABLE `forward_auth_sessions` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE, + `token_hash` text NOT NULL, + `expires_at` text NOT NULL, + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `fas_token_hash_unique` ON `forward_auth_sessions` (`token_hash`); +--> statement-breakpoint +CREATE INDEX `fas_user_idx` ON `forward_auth_sessions` (`user_id`); +--> statement-breakpoint +CREATE INDEX `fas_expires_idx` ON `forward_auth_sessions` (`expires_at`); +--> statement-breakpoint +CREATE TABLE `forward_auth_exchanges` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `session_id` integer NOT NULL REFERENCES `forward_auth_sessions`(`id`) ON DELETE CASCADE, + `code_hash` text NOT NULL, + `session_token` text NOT NULL, + `redirect_uri` text NOT NULL, + `expires_at` text NOT NULL, + `used` integer NOT NULL DEFAULT 0, + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `fae_code_hash_unique` ON `forward_auth_exchanges` (`code_hash`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2e4deb80..3b62df4b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1775400000000, "tag": "0016_mtls_rbac", "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1775500000000, + "tag": "0017_forward_auth", + "breakpoints": true } ] } diff --git a/proxy.ts b/proxy.ts index ee63a39a..0fb907ad 100644 --- a/proxy.ts +++ b/proxy.ts @@ -41,10 +41,12 @@ export default auth((req) => { // Allow public routes if ( pathname === "/login" || + pathname === "/portal" || pathname.startsWith("/api/auth") || pathname === "/api/health" || pathname === "/api/instances/sync" || - pathname.startsWith("/api/v1/") + pathname.startsWith("/api/v1/") || + pathname.startsWith("/api/forward-auth/") ) { return NextResponse.next(); } diff --git a/src/components/proxy-hosts/CpmForwardAuthFields.tsx b/src/components/proxy-hosts/CpmForwardAuthFields.tsx new file mode 100644 index 00000000..e928804b --- /dev/null +++ b/src/components/proxy-hosts/CpmForwardAuthFields.tsx @@ -0,0 +1,206 @@ +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; +import { Users, UserCheck } from "lucide-react"; +import { ProxyHost } from "@/lib/models/proxy-hosts"; + +type UserEntry = { + id: number; + email: string; + name: string | null; + role: string; +}; + +type GroupEntry = { + id: number; + name: string; + description: string | null; + member_count: number; +}; + +type ForwardAuthAccessData = { + userIds: number[]; + groupIds: number[]; +}; + +export function CpmForwardAuthFields({ + cpmForwardAuth, + users = [], + groups = [], + currentAccess, +}: { + cpmForwardAuth?: ProxyHost["cpm_forward_auth"] | null; + users?: UserEntry[]; + groups?: GroupEntry[]; + currentAccess?: ForwardAuthAccessData | null; +}) { + const initial = cpmForwardAuth ?? null; + const [enabled, setEnabled] = useState(initial?.enabled ?? false); + const [selectedUserIds, setSelectedUserIds] = useState(currentAccess?.userIds ?? []); + const [selectedGroupIds, setSelectedGroupIds] = useState(currentAccess?.groupIds ?? []); + + function toggleUser(id: number) { + setSelectedUserIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ); + } + + function toggleGroup(id: number) { + setSelectedGroupIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ); + } + + const allUsers = users; + + return ( +
+ + + {enabled && selectedUserIds.map((id) => ( + + ))} + {enabled && selectedGroupIds.map((id) => ( + + ))} +
+
+
+

CPM Forward Auth

+

+ Require users to authenticate via Caddy Proxy Manager before accessing this host +

+
+ +
+ +
+
+
+ +