Add forward auth portal — CPM as built-in IdP replacing Authentik

CPM can now act as its own forward auth provider for proxied sites.
Users authenticate at a login portal (credentials or OAuth) and Caddy
gates access via a verify subrequest, eliminating the need for external
IdPs like Authentik.

Key components:
- Forward auth flow: verify endpoint, exchange code callback, login portal
- User groups with membership management
- Per-proxy-host access control (users and/or groups)
- Caddy config generation for forward_auth handler + callback route
- OAuth and credential login on the portal page
- Admin UI: groups page, inline user/group assignment in proxy host form
- REST API: /api/v1/groups, /api/v1/forward-auth-sessions, per-host access
- Integration tests for groups and forward auth schema

Also fixes mTLS E2E test selectors broken by the RBAC refactor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-04-05 22:32:17 +02:00
parent 277ae6e79c
commit 03c8f40417
34 changed files with 2788 additions and 11 deletions

View File

@@ -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<string | null>(null);
const [pending, setPending] = useState(false);
const [oauthPending, setOauthPending] = useState<string | null>(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<HTMLFormElement>) => {
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 (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center space-y-1">
<CardTitle className="text-xl">Authentication Required</CardTitle>
<CardDescription>No redirect destination specified.</CardDescription>
</CardHeader>
</Card>
</div>
);
}
// If we have a session and are auto-redirecting, show a loading state
if (existingSession && pending && !error) {
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center space-y-1">
<div className="flex justify-center mb-2">
<Shield className="h-8 w-8 text-muted-foreground" />
</div>
<CardTitle className="text-xl">Authorizing...</CardTitle>
<CardDescription>
Signing in as {existingSession.name ?? existingSession.email}
</CardDescription>
</CardHeader>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center space-y-1">
<div className="flex justify-center mb-2">
<Shield className="h-8 w-8 text-muted-foreground" />
</div>
<CardTitle className="text-xl">Authentication Required</CardTitle>
<CardDescription>
{targetDomain
? <>Sign in to access <span className="font-medium text-foreground">{targetDomain}</span></>
: "Sign in to continue"
}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{enabledProviders.length > 0 && (
<>
<div className="space-y-2">
{enabledProviders.map((provider) => {
const isPending = oauthPending === provider.id;
return (
<Button
key={provider.id}
variant="outline"
className="w-full"
onClick={() => handleOAuthSignIn(provider.id)}
disabled={disabled}
>
{isPending ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent mr-2" />
) : null}
Sign in with {provider.name}
</Button>
);
})}
</div>
<div className="relative">
<Separator />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
or
</span>
</div>
</>
)}
<form onSubmit={handleCredentialSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
autoComplete="username"
autoFocus={enabledProviders.length === 0}
disabled={disabled}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
disabled={disabled}
required
/>
</div>
<Button type="submit" className="w-full" disabled={disabled}>
{pending ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<PortalLoginForm
redirectUri={redirectUri}
targetDomain={targetDomain}
enabledProviders={enabledProviders}
existingSession={session ? { userId: session.user.id, name: session.user.name ?? null, email: session.user.email ?? null } : null}
/>
);
}

View File

@@ -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 },

View File

@@ -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<number | null>(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 (
<div className="flex flex-col gap-6 w-full">
<PageHeader
title="Groups"
description="Organize users into groups for forward auth access control."
/>
<div className="flex justify-end">
<Button onClick={() => setShowCreate(!showCreate)} variant="outline" size="sm">
<Plus className="h-4 w-4 mr-1" />
New Group
</Button>
</div>
{showCreate && (
<Card>
<CardContent className="pt-4">
<form
action={async (formData) => {
await createGroupAction(formData);
setShowCreate(false);
router.refresh();
}}
className="flex flex-col gap-3"
>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="e.g. Developers" required />
</div>
<div className="space-y-1">
<Label htmlFor="description">Description</Label>
<Input id="description" name="description" placeholder="Optional description" />
</div>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm">Create</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowCreate(false)}>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{groups.length === 0 && !showCreate && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Users className="h-10 w-10 mx-auto mb-3 opacity-40" />
<p>No groups yet. Create one to organize user access.</p>
</CardContent>
</Card>
)}
<div className="grid gap-4">
{groups.map((group) => {
const available = getAvailableUsers(group);
return (
<Card key={group.id} className="border-l-4 border-l-blue-500">
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-semibold text-base">{group.name}</h3>
{group.description && (
<p className="text-sm text-muted-foreground">{group.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground border rounded-full px-2 py-0.5">
{group.members.length} member{group.members.length !== 1 ? "s" : ""}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() =>
setAddMemberGroupId(addMemberGroupId === group.id ? null : group.id)
}
title="Add member"
>
<UserPlus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={async () => {
if (confirm(`Delete group "${group.name}"?`)) {
await deleteGroupAction(group.id);
router.refresh();
}
}}
title="Delete group"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{addMemberGroupId === group.id && (
<div className="mb-3">
<p className="text-sm font-medium mb-2">Add a user to this group</p>
{available.length === 0 ? (
<p className="text-sm text-muted-foreground">All users are already in this group.</p>
) : (
<div className="border rounded-md max-h-48 overflow-y-auto">
{available.map((user) => (
<button
key={user.id}
type="button"
className="w-full flex items-center justify-between px-3 py-2 text-left hover:bg-muted/50 border-b last:border-b-0 transition-colors"
onClick={async () => {
await addGroupMemberAction(group.id, user.id);
setAddMemberGroupId(null);
router.refresh();
}}
>
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium shrink-0">
{(user.name ?? user.email)[0]?.toUpperCase()}
</div>
<div>
<span className="text-sm">{user.name ?? user.email.split("@")[0]}</span>
<span className="text-xs text-muted-foreground ml-2">{user.email}</span>
</div>
</div>
<span className="text-xs text-muted-foreground capitalize">{user.role}</span>
</button>
))}
</div>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="mt-2"
onClick={() => setAddMemberGroupId(null)}
>
Cancel
</Button>
</div>
)}
{group.members.length > 0 && (
<>
<Separator className="my-2" />
<div className="space-y-1">
{group.members.map((member) => (
<div
key={member.user_id}
className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium">
{(member.name ?? member.email)[0]?.toUpperCase()}
</div>
<span className="text-sm">
{member.name ?? member.email.split("@")[0]}
</span>
<span className="text-xs text-muted-foreground">
{member.email}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
await removeGroupMemberAction(group.id, member.user_id);
router.refresh();
}}
title="Remove member"
>
<UserMinus className="h-3 w-3" />
</Button>
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
);
}

View File

@@ -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");
}

View File

@@ -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 <GroupsClient groups={allGroups} users={userList} />;
}

View File

@@ -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<number, { userIds: number[]; groupIds: number[] }>;
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<ProxyHost | null>(null);
const [editHost, setEditHost] = useState<ProxyHost | null>(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}
/>
)}

View File

@@ -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

View File

@@ -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<number, { userIds: number[]; groupIds: number[] }> = {};
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 (
<ProxyHostsClient
hosts={hosts}
@@ -49,6 +81,9 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }}
mtlsRoles={mtlsRoles}
issuedClientCerts={issuedClientCerts}
forwardAuthUsers={forwardAuthUsers}
forwardAuthGroups={forwardAuthGroups}
forwardAuthAccessMap={forwardAuthAccessMap}
/>
);
}

View File

@@ -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;
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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)
}
});
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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`);

View File

@@ -120,6 +120,13 @@
"when": 1775400000000,
"tag": "0016_mtls_rbac",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1775500000000,
"tag": "0017_forward_auth",
"breakpoints": true
}
]
}

View File

@@ -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();
}

View File

@@ -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<number[]>(currentAccess?.userIds ?? []);
const [selectedGroupIds, setSelectedGroupIds] = useState<number[]>(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 (
<div className="rounded-lg border border-primary bg-primary/5 p-5">
<input type="hidden" name="cpm_forward_auth_present" value="1" />
<input type="hidden" name="cpm_forward_auth_enabled_present" value="1" />
{enabled && selectedUserIds.map((id) => (
<input key={`faa-u-${id}`} type="hidden" name="cpm_fa_user_id" value={String(id)} />
))}
{enabled && selectedGroupIds.map((id) => (
<input key={`faa-g-${id}`} type="hidden" name="cpm_fa_group_id" value={String(id)} />
))}
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center justify-between">
<div>
<p className="text-sm font-semibold">CPM Forward Auth</p>
<p className="text-sm text-muted-foreground">
Require users to authenticate via Caddy Proxy Manager before accessing this host
</p>
</div>
<Switch
name="cpm_forward_auth_enabled"
checked={enabled}
onCheckedChange={setEnabled}
/>
</div>
<div className={cn(
"overflow-hidden transition-all duration-200",
enabled ? "max-h-[3000px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}>
<div className="flex flex-col gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Protected Paths (Optional)</label>
<Textarea
name="cpm_forward_auth_protected_paths"
placeholder="/secret/*, /admin/*"
defaultValue={initial?.protected_paths?.join(", ") ?? ""}
disabled={!enabled}
rows={2}
/>
<p className="text-xs text-muted-foreground mt-1">
Leave empty to protect entire domain. Comma-separated paths to protect specific routes only.
</p>
</div>
{/* Allowed Groups */}
{groups.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<Users className="h-4 w-4 text-primary" />
<p className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
Allowed Groups
</p>
{selectedGroupIds.length > 0 && (
<Badge variant="secondary" className="text-xs ml-auto">
{selectedGroupIds.length} selected
</Badge>
)}
</div>
<div className="rounded-md border bg-background">
{groups.map((group) => (
<div
key={group.id}
className="flex items-center gap-2.5 px-3 py-2 hover:bg-muted/30 border-b last:border-b-0"
>
<Checkbox
checked={selectedGroupIds.includes(group.id)}
onCheckedChange={() => toggleGroup(group.id)}
/>
<label
className="flex-1 min-w-0 cursor-pointer"
onClick={() => toggleGroup(group.id)}
>
<span className="text-sm font-medium">{group.name}</span>
{group.description && (
<span className="text-xs text-muted-foreground ml-2">
{group.description}
</span>
)}
</label>
<Badge variant="outline" className="text-xs shrink-0">
{group.member_count} member{group.member_count !== 1 ? "s" : ""}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Allowed Users */}
{allUsers.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<UserCheck className="h-4 w-4 text-primary" />
<p className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
Allowed Users
</p>
{selectedUserIds.length > 0 && (
<Badge variant="secondary" className="text-xs ml-auto">
{selectedUserIds.length} selected
</Badge>
)}
</div>
<div className="rounded-md border bg-background max-h-52 overflow-y-auto">
{allUsers.map((user) => (
<div
key={user.id}
className="flex items-center gap-2.5 px-3 py-2 hover:bg-muted/30 border-b last:border-b-0"
>
<Checkbox
checked={selectedUserIds.includes(user.id)}
onCheckedChange={() => toggleUser(user.id)}
/>
<label
className="flex-1 min-w-0 cursor-pointer"
onClick={() => toggleUser(user.id)}
>
<span className="text-sm">
{user.name ?? user.email.split("@")[0]}
</span>
<span className="text-xs text-muted-foreground ml-2">
{user.email}
</span>
</label>
</div>
))}
</div>
</div>
)}
{groups.length === 0 && allUsers.length === 0 && (
<div className="rounded border border-dashed p-4 text-center">
<p className="text-sm text-muted-foreground">
No groups or users yet. Create groups on the Groups page.
</p>
</div>
)}
{selectedGroupIds.length === 0 && selectedUserIds.length === 0 && (groups.length > 0 || allUsers.length > 0) && (
<p className="text-xs text-amber-600 dark:text-amber-400">
No users or groups selected nobody will be able to access this host.
</p>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -24,6 +24,7 @@ import { UpstreamInput } from "./UpstreamInput";
import { GeoBlockFields } from "./GeoBlockFields";
import { WafFields } from "./WafFields";
import { MtlsFields } from "./MtlsConfig";
import { CpmForwardAuthFields } from "./CpmForwardAuthFields";
import { RedirectsFields } from "./RedirectsFields";
import { LocationRulesFields } from "./LocationRulesFields";
import { RewriteFields } from "./RewriteFields";
@@ -31,6 +32,10 @@ import type { CaCertificate } from "@/lib/models/ca-certificates";
import type { MtlsRole } from "@/lib/models/mtls-roles";
import type { IssuedClientCertificate } from "@/lib/models/issued-client-certificates";
type ForwardAuthUser = { id: number; email: string; name: string | null; role: string };
type ForwardAuthGroup = { id: number; name: string; description: string | null; member_count: number };
type ForwardAuthAccessData = { userIds: number[]; groupIds: number[] };
export function CreateHostDialog({
open,
onClose,
@@ -41,6 +46,8 @@ export function CreateHostDialog({
caCertificates = [],
mtlsRoles = [],
issuedClientCerts = [],
forwardAuthUsers = [],
forwardAuthGroups = [],
}: {
open: boolean;
onClose: () => void;
@@ -51,6 +58,8 @@ export function CreateHostDialog({
caCertificates?: CaCertificate[];
mtlsRoles?: MtlsRole[];
issuedClientCerts?: IssuedClientCertificate[];
forwardAuthUsers?: ForwardAuthUser[];
forwardAuthGroups?: ForwardAuthGroup[];
}) {
const [state, formAction] = useFormState(createProxyHostAction, INITIAL_ACTION_STATE);
@@ -165,6 +174,11 @@ export function CreateHostDialog({
</p>
</div>
<AuthentikFields defaults={authentikDefaults} authentik={initialData?.authentik} />
<CpmForwardAuthFields
cpmForwardAuth={initialData?.cpm_forward_auth}
users={forwardAuthUsers}
groups={forwardAuthGroups}
/>
<LoadBalancerFields loadBalancer={initialData?.load_balancer} />
<DnsResolverFields dnsResolver={initialData?.dns_resolver} />
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstream_dns_resolution} />
@@ -190,6 +204,9 @@ export function EditHostDialog({
caCertificates = [],
mtlsRoles = [],
issuedClientCerts = [],
forwardAuthUsers = [],
forwardAuthGroups = [],
forwardAuthAccess,
}: {
open: boolean;
host: ProxyHost;
@@ -199,6 +216,9 @@ export function EditHostDialog({
caCertificates?: CaCertificate[];
mtlsRoles?: MtlsRole[];
issuedClientCerts?: IssuedClientCertificate[];
forwardAuthUsers?: ForwardAuthUser[];
forwardAuthGroups?: ForwardAuthGroup[];
forwardAuthAccess?: ForwardAuthAccessData | null;
}) {
const [state, formAction] = useFormState(updateProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE);
@@ -303,6 +323,12 @@ export function EditHostDialog({
</p>
</div>
<AuthentikFields authentik={host.authentik} />
<CpmForwardAuthFields
cpmForwardAuth={host.cpm_forward_auth}
users={forwardAuthUsers}
groups={forwardAuthGroups}
currentAccess={forwardAuthAccess}
/>
<LoadBalancerFields loadBalancer={host.load_balancer} />
<DnsResolverFields dnsResolver={host.dns_resolver} />
<UpstreamDnsResolutionFields upstreamDnsResolution={host.upstream_dns_resolution} />

View File

@@ -106,10 +106,16 @@ type UpstreamDnsResolutionMeta = {
family?: UpstreamDnsAddressFamily;
};
type CpmForwardAuthMeta = {
enabled?: boolean;
protected_paths?: string[];
};
type ProxyHostMeta = {
custom_reverse_proxy_json?: string;
custom_pre_handlers_json?: string;
authentik?: ProxyHostAuthentikMeta;
cpm_forward_auth?: CpmForwardAuthMeta;
load_balancer?: LoadBalancerMeta;
dns_resolver?: DnsResolverMeta;
upstream_dns_resolution?: UpstreamDnsResolutionMeta;
@@ -698,6 +704,7 @@ async function buildProxyRoutes(
const handlers: Record<string, unknown>[] = [];
const meta = parseJson<ProxyHostMeta>(row.meta, {});
const authentik = parseAuthentikConfig(meta.authentik);
const cpmForwardAuth = meta.cpm_forward_auth?.enabled ? meta.cpm_forward_auth : null;
const hostRoutes: CaddyHttpRoute[] = [];
const effectiveGeoBlock = resolveEffectiveGeoBlock(
@@ -1111,6 +1118,188 @@ async function buildProxyRoutes(
hostRoutes.push(route);
}
}
} else if (cpmForwardAuth) {
// ── CPM Forward Auth ────────────────────────────────────────────
// Uses CPM itself as the auth provider (replaces Authentik)
const cpmDialAddress = getCpmDialAddress();
if (cpmDialAddress) {
const CPM_COPY_HEADERS = [
"X-CPM-User",
"X-CPM-Email",
"X-CPM-Groups",
"X-CPM-User-Id"
];
// Build handle_response routes for copying user headers on 2xx
const cpmHandleResponseRoutes: Record<string, unknown>[] = [
{ handle: [{ handler: "vars" }] }
];
for (const headerName of CPM_COPY_HEADERS) {
cpmHandleResponseRoutes.push({
handle: [
{
handler: "headers",
request: {
set: { [headerName]: [`{http.reverse_proxy.header.${headerName}}`] }
}
} as Record<string, unknown>
],
match: [
{
not: [{ vars: { [`{http.reverse_proxy.header.${headerName}}`]: [""] } }]
}
]
});
}
// Forward auth handler — subrequest to CPM verify endpoint
const cpmForwardAuthHandler: Record<string, unknown> = {
handler: "reverse_proxy",
upstreams: [{ dial: cpmDialAddress }],
rewrite: {
method: "GET",
uri: "/api/forward-auth/verify"
},
headers: {
request: {
set: {
"X-Forwarded-Method": ["{http.request.method}"],
"X-Forwarded-Uri": ["{http.request.uri}"],
"X-Forwarded-Host": ["{http.request.host}"],
"X-Forwarded-Proto": ["{http.request.scheme}"]
}
}
},
handle_response: [
{
match: { status_code: [2] },
routes: cpmHandleResponseRoutes
},
{
match: { status_code: [401, 403] },
routes: [
{
handle: [
{
handler: "static_response",
status_code: 302,
headers: {
Location: [
`${config.baseUrl}/portal?rd={http.request.scheme}://{http.request.host}{http.request.uri}`
]
}
}
]
}
]
}
],
trusted_proxies: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "fd00::/8", "::1/128"]
};
// Callback route — unprotected, so it goes before forward_auth
const cpmCallbackRoute: CaddyHttpRoute = {
match: [{ path: ["/.cpm-auth/callback"] }],
handle: [
{
handler: "reverse_proxy",
upstreams: [{ dial: cpmDialAddress }],
rewrite: {
uri: "/api/forward-auth/callback?{http.request.uri.query}"
},
headers: {
request: {
set: {
"X-Forwarded-Host": ["{http.request.host}"],
"X-Forwarded-Proto": ["{http.request.scheme}"]
}
}
}
}
],
terminal: true
};
const locationRules = meta.location_rules ?? [];
if (cpmForwardAuth.protected_paths && cpmForwardAuth.protected_paths.length > 0) {
// Path-specific authentication
for (const domainGroup of domainGroups) {
// Add callback route (unprotected)
hostRoutes.push({
...cpmCallbackRoute,
match: [{ host: domainGroup, path: ["/.cpm-auth/callback"] }]
});
// Protected paths
for (const protectedPath of cpmForwardAuth.protected_paths) {
const protectedHandlers: Record<string, unknown>[] = [...handlers];
const protectedReverseProxy = JSON.parse(JSON.stringify(reverseProxyHandler));
protectedHandlers.push(cpmForwardAuthHandler);
protectedHandlers.push(protectedReverseProxy);
hostRoutes.push({
match: [{ host: domainGroup, path: [protectedPath] }],
handle: protectedHandlers,
terminal: true
});
}
// Location rules (unprotected)
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
);
if (!safePath) continue;
hostRoutes.push({
match: [{ host: domainGroup, path: [safePath] }],
handle: [...handlers, locationProxy],
terminal: true
});
}
// Unprotected catch-all
hostRoutes.push({
match: [{ host: domainGroup }],
handle: [...handlers, reverseProxyHandler],
terminal: true
});
}
} else {
// Protect entire site
for (const domainGroup of domainGroups) {
// Callback route first (unprotected)
hostRoutes.push({
...cpmCallbackRoute,
match: [{ host: domainGroup, path: ["/.cpm-auth/callback"] }]
});
// Location rules with forward auth
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
);
if (!safePath) continue;
hostRoutes.push({
match: [{ host: domainGroup, path: [safePath] }],
handle: [...handlers, cpmForwardAuthHandler, locationProxy],
terminal: true
});
}
// Main route with forward auth
hostRoutes.push({
match: [{ host: domainGroup }],
handle: [...handlers, cpmForwardAuthHandler, reverseProxyHandler],
terminal: true
});
}
}
}
} else {
const locationRules = meta.location_rules ?? [];
@@ -1998,6 +2187,41 @@ export async function applyCaddyConfig() {
}
}
/**
* Derives the dial address (host:port) for Caddy to reach CPM internally.
* Uses FORWARD_AUTH_INTERNAL_URL env var if set. Otherwise, if CADDY_API_URL
* points to a Docker service name (e.g. "caddy:2019"), assumes Docker networking
* and defaults to "web:3000". Falls back to deriving from BASE_URL.
*/
function getCpmDialAddress(): string | null {
const internalUrl = config.forwardAuthInternalUrl;
if (internalUrl) {
// Strip protocol, trailing slashes, and paths
return internalUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
}
// If CADDY_API_URL uses a Docker service name, assume Docker networking
// and use the web service name directly
try {
const caddyUrl = new URL(config.caddyApiUrl);
if (caddyUrl.hostname !== "localhost" && caddyUrl.hostname !== "127.0.0.1" && caddyUrl.hostname !== "::1") {
// Caddy is on a Docker network — CPM is the "web" service on port 3000
return "web:3000";
}
} catch {
// ignore
}
// Derive from BASE_URL (works for non-Docker setups)
try {
const url = new URL(config.baseUrl);
const port = url.port || (url.protocol === "https:" ? "443" : "80");
return `${url.hostname}:${port}`;
} catch {
return null;
}
}
function parseAuthentikConfig(meta: ProxyHostAuthentikMeta | undefined | null): AuthentikRouteConfig | null {
if (!meta || !meta.enabled) {
return null;

View File

@@ -177,7 +177,8 @@ export const config = {
tokenUrl: process.env.OAUTH_TOKEN_URL ?? null,
userinfoUrl: process.env.OAUTH_USERINFO_URL ?? null,
allowAutoLinking: process.env.OAUTH_ALLOW_AUTO_LINKING === "true",
}
},
forwardAuthInternalUrl: process.env.FORWARD_AUTH_INTERNAL_URL ?? null,
};
/**

View File

@@ -338,6 +338,98 @@ export const mtlsAccessRules = sqliteTable(
})
);
// ── Forward Auth (IdP) ───────────────────────────────────────────────
export const groups = sqliteTable(
"groups",
{
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
description: text("description"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({
nameUnique: uniqueIndex("groups_name_unique").on(table.name)
})
);
export const groupMembers = sqliteTable(
"group_members",
{
id: integer("id").primaryKey({ autoIncrement: true }),
groupId: integer("group_id")
.references(() => groups.id, { onDelete: "cascade" })
.notNull(),
userId: integer("user_id")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
createdAt: text("created_at").notNull()
},
(table) => ({
memberUnique: uniqueIndex("group_members_unique").on(table.groupId, table.userId),
userIdx: index("group_members_user_idx").on(table.userId)
})
);
export const forwardAuthAccess = sqliteTable(
"forward_auth_access",
{
id: integer("id").primaryKey({ autoIncrement: true }),
proxyHostId: integer("proxy_host_id")
.references(() => proxyHosts.id, { onDelete: "cascade" })
.notNull(),
userId: integer("user_id").references(() => users.id, { onDelete: "cascade" }),
groupId: integer("group_id").references(() => groups.id, { onDelete: "cascade" }),
createdAt: text("created_at").notNull()
},
(table) => ({
hostIdx: index("faa_host_idx").on(table.proxyHostId),
userUnique: uniqueIndex("faa_user_unique").on(table.proxyHostId, table.userId),
groupUnique: uniqueIndex("faa_group_unique").on(table.proxyHostId, table.groupId)
})
);
export const forwardAuthSessions = sqliteTable(
"forward_auth_sessions",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
tokenHash: text("token_hash").notNull(),
expiresAt: text("expires_at").notNull(),
createdAt: text("created_at").notNull()
},
(table) => ({
tokenHashUnique: uniqueIndex("fas_token_hash_unique").on(table.tokenHash),
userIdx: index("fas_user_idx").on(table.userId),
expiresIdx: index("fas_expires_idx").on(table.expiresAt)
})
);
export const forwardAuthExchanges = sqliteTable(
"forward_auth_exchanges",
{
id: integer("id").primaryKey({ autoIncrement: true }),
sessionId: integer("session_id")
.references(() => forwardAuthSessions.id, { onDelete: "cascade" })
.notNull(),
codeHash: text("code_hash").notNull(),
sessionToken: text("session_token").notNull(), // raw session token (short-lived, single-use)
redirectUri: text("redirect_uri").notNull(),
expiresAt: text("expires_at").notNull(),
used: integer("used", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at").notNull()
},
(table) => ({
codeHashUnique: uniqueIndex("fae_code_hash_unique").on(table.codeHash)
})
);
// ── L4 Proxy Hosts ───────────────────────────────────────────────────
export const l4ProxyHosts = sqliteTable("l4_proxy_hosts", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),

View File

@@ -0,0 +1,296 @@
import { createHash, randomBytes } from "node:crypto";
import db, { nowIso, toIso } from "../db";
import { logAuditEvent } from "../audit";
import {
forwardAuthSessions,
forwardAuthExchanges,
forwardAuthAccess,
groupMembers,
users,
groups,
proxyHosts
} from "../db/schema";
import { eq, inArray, lt } from "drizzle-orm";
const DEFAULT_SESSION_TTL = 7 * 24 * 60 * 60; // 7 days in seconds
const EXCHANGE_CODE_TTL = 60; // 60 seconds
function hashToken(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
}
// ── Sessions ─────────────────────────────────────────────────────────
export type ForwardAuthSession = {
id: number;
user_id: number;
expires_at: string;
created_at: string;
};
export async function createForwardAuthSession(
userId: number,
ttlSeconds?: number
): Promise<{ rawToken: string; session: ForwardAuthSession }> {
const rawToken = randomBytes(32).toString("hex");
const tokenHash = hashToken(rawToken);
const now = nowIso();
const ttl = ttlSeconds ?? DEFAULT_SESSION_TTL;
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
const [row] = await db
.insert(forwardAuthSessions)
.values({ userId, tokenHash, expiresAt, createdAt: now })
.returning();
if (!row) throw new Error("Failed to create forward auth session");
return {
rawToken,
session: {
id: row.id,
user_id: row.userId,
expires_at: toIso(row.expiresAt)!,
created_at: toIso(row.createdAt)!
}
};
}
export async function validateForwardAuthSession(
rawToken: string
): Promise<{ sessionId: number; userId: number } | null> {
const tokenHash = hashToken(rawToken);
const session = await db.query.forwardAuthSessions.findFirst({
where: (table, operators) => operators.eq(table.tokenHash, tokenHash)
});
if (!session) return null;
if (new Date(session.expiresAt) <= new Date()) return null;
return { sessionId: session.id, userId: session.userId };
}
export async function listForwardAuthSessions(): Promise<ForwardAuthSession[]> {
const rows = await db.query.forwardAuthSessions.findMany({
where: (table, operators) => operators.gt(table.expiresAt, nowIso())
});
return rows.map((r) => ({
id: r.id,
user_id: r.userId,
expires_at: toIso(r.expiresAt)!,
created_at: toIso(r.createdAt)!
}));
}
export async function deleteForwardAuthSession(id: number): Promise<void> {
await db.delete(forwardAuthSessions).where(eq(forwardAuthSessions.id, id));
}
export async function deleteUserForwardAuthSessions(userId: number): Promise<void> {
await db
.delete(forwardAuthSessions)
.where(eq(forwardAuthSessions.userId, userId));
}
// ── Exchange Codes ───────────────────────────────────────────────────
export async function createExchangeCode(
sessionId: number,
rawSessionToken: string,
redirectUri: string
): Promise<{ rawCode: string }> {
const rawCode = randomBytes(32).toString("hex");
const codeHash = hashToken(rawCode);
const now = nowIso();
const expiresAt = new Date(Date.now() + EXCHANGE_CODE_TTL * 1000).toISOString();
await db.insert(forwardAuthExchanges).values({
sessionId,
codeHash,
sessionToken: rawSessionToken,
redirectUri,
expiresAt,
used: false,
createdAt: now
});
return { rawCode };
}
export async function redeemExchangeCode(
rawCode: string
): Promise<{ sessionId: number; redirectUri: string; rawSessionToken: string } | null> {
const codeHash = hashToken(rawCode);
const exchange = await db.query.forwardAuthExchanges.findFirst({
where: (table, operators) => operators.eq(table.codeHash, codeHash)
});
if (!exchange) return null;
if (exchange.used) return null;
if (new Date(exchange.expiresAt) <= new Date()) return null;
// Mark as used atomically
await db
.update(forwardAuthExchanges)
.set({ used: true })
.where(eq(forwardAuthExchanges.id, exchange.id));
return {
sessionId: exchange.sessionId,
redirectUri: exchange.redirectUri,
rawSessionToken: exchange.sessionToken
};
}
// ── Host Access Control ──────────────────────────────────────────────
export type ForwardAuthAccessEntry = {
id: number;
proxy_host_id: number;
user_id: number | null;
group_id: number | null;
created_at: string;
};
export async function checkHostAccess(
userId: number,
proxyHostId: number
): Promise<boolean> {
// Admins always have access
const user = await db.query.users.findFirst({
where: (table, operators) => operators.eq(table.id, userId)
});
if (!user) return false;
// Check direct user access
const directAccess = await db.query.forwardAuthAccess.findFirst({
where: (table, operators) =>
operators.and(
operators.eq(table.proxyHostId, proxyHostId),
operators.eq(table.userId, userId)
)
});
if (directAccess) return true;
// Check group-based access
const userGroupIds = await db
.select({ groupId: groupMembers.groupId })
.from(groupMembers)
.where(eq(groupMembers.userId, userId));
if (userGroupIds.length === 0) return false;
const groupIds = userGroupIds.map((r) => r.groupId);
const groupAccess = await db.query.forwardAuthAccess.findFirst({
where: (table, operators) =>
operators.and(
operators.eq(table.proxyHostId, proxyHostId),
inArray(table.groupId, groupIds)
)
});
return !!groupAccess;
}
export async function checkHostAccessByDomain(
userId: number,
host: string
): Promise<{ hasAccess: boolean; proxyHostId: number | null }> {
// Find proxy host(s) that contain this domain
const allHosts = await db.query.proxyHosts.findMany({
where: (table, operators) => operators.eq(table.enabled, true)
});
for (const ph of allHosts) {
let domains: string[] = [];
try {
domains = JSON.parse(ph.domains);
} catch {
continue;
}
if (domains.some((d) => d.toLowerCase() === host.toLowerCase())) {
const hasAccess = await checkHostAccess(userId, ph.id);
return { hasAccess, proxyHostId: ph.id };
}
}
// Host not found in any proxy host — deny by default
return { hasAccess: false, proxyHostId: null };
}
export async function getForwardAuthAccessForHost(
proxyHostId: number
): Promise<ForwardAuthAccessEntry[]> {
const rows = await db
.select()
.from(forwardAuthAccess)
.where(eq(forwardAuthAccess.proxyHostId, proxyHostId));
return rows.map((r) => ({
id: r.id,
proxy_host_id: r.proxyHostId,
user_id: r.userId,
group_id: r.groupId,
created_at: toIso(r.createdAt)!
}));
}
export async function setForwardAuthAccess(
proxyHostId: number,
access: { userIds?: number[]; groupIds?: number[] },
actorUserId: number
): Promise<ForwardAuthAccessEntry[]> {
// Delete existing access for this host
await db
.delete(forwardAuthAccess)
.where(eq(forwardAuthAccess.proxyHostId, proxyHostId));
const now = nowIso();
const values: Array<{
proxyHostId: number;
userId: number | null;
groupId: number | null;
createdAt: string;
}> = [];
for (const uid of access.userIds ?? []) {
values.push({ proxyHostId, userId: uid, groupId: null, createdAt: now });
}
for (const gid of access.groupIds ?? []) {
values.push({ proxyHostId, userId: null, groupId: gid, createdAt: now });
}
if (values.length > 0) {
await db.insert(forwardAuthAccess).values(values);
}
logAuditEvent({
userId: actorUserId,
action: "update",
entityType: "forward_auth_access",
entityId: proxyHostId,
summary: `Updated forward auth access for proxy host ${proxyHostId}`
});
return getForwardAuthAccessForHost(proxyHostId);
}
// ── Cleanup ──────────────────────────────────────────────────────────
export async function cleanupExpiredSessions(): Promise<number> {
const now = nowIso();
// Delete expired exchanges first (FK constraint)
await db
.delete(forwardAuthExchanges)
.where(lt(forwardAuthExchanges.expiresAt, now));
// Delete expired sessions
const result = await db
.delete(forwardAuthSessions)
.where(lt(forwardAuthSessions.expiresAt, now))
.returning();
return result.length;
}

249
src/lib/models/groups.ts Normal file
View File

@@ -0,0 +1,249 @@
import db, { nowIso, toIso } from "../db";
import { logAuditEvent } from "../audit";
import { groups, groupMembers, users } from "../db/schema";
import { asc, eq, inArray, count } from "drizzle-orm";
export type Group = {
id: number;
name: string;
description: string | null;
members: GroupMember[];
created_at: string;
updated_at: string;
};
export type GroupMember = {
user_id: number;
email: string;
name: string | null;
created_at: string;
};
export type GroupInput = {
name: string;
description?: string | null;
};
type GroupRow = typeof groups.$inferSelect;
function toGroup(row: GroupRow, members: GroupMember[]): Group {
return {
id: row.id,
name: row.name,
description: row.description,
members,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
};
}
export async function listGroups(): Promise<Group[]> {
const allGroups = await db.query.groups.findMany({
orderBy: (table) => asc(table.name)
});
if (allGroups.length === 0) return [];
const groupIds = allGroups.map((g) => g.id);
const allMembers = await db
.select({
groupId: groupMembers.groupId,
userId: groupMembers.userId,
email: users.email,
name: users.name,
createdAt: groupMembers.createdAt
})
.from(groupMembers)
.innerJoin(users, eq(groupMembers.userId, users.id))
.where(inArray(groupMembers.groupId, groupIds));
const membersByGroup = new Map<number, GroupMember[]>();
for (const m of allMembers) {
const bucket = membersByGroup.get(m.groupId) ?? [];
bucket.push({
user_id: m.userId,
email: m.email,
name: m.name,
created_at: toIso(m.createdAt)!
});
membersByGroup.set(m.groupId, bucket);
}
return allGroups.map((g) => toGroup(g, membersByGroup.get(g.id) ?? []));
}
export async function countGroups(): Promise<number> {
const [row] = await db.select({ value: count() }).from(groups);
return row?.value ?? 0;
}
export async function getGroup(id: number): Promise<Group | null> {
const group = await db.query.groups.findFirst({
where: (table, operators) => operators.eq(table.id, id)
});
if (!group) return null;
const members = await db
.select({
userId: groupMembers.userId,
email: users.email,
name: users.name,
createdAt: groupMembers.createdAt
})
.from(groupMembers)
.innerJoin(users, eq(groupMembers.userId, users.id))
.where(eq(groupMembers.groupId, id));
return toGroup(
group,
members.map((m) => ({
user_id: m.userId,
email: m.email,
name: m.name,
created_at: toIso(m.createdAt)!
}))
);
}
export async function createGroup(input: GroupInput, actorUserId: number): Promise<Group> {
const now = nowIso();
const [row] = await db
.insert(groups)
.values({
name: input.name.trim(),
description: input.description ?? null,
createdBy: actorUserId,
createdAt: now,
updatedAt: now
})
.returning();
if (!row) throw new Error("Failed to create group");
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "group",
entityId: row.id,
summary: `Created group ${input.name}`
});
return (await getGroup(row.id))!;
}
export async function updateGroup(
id: number,
input: { name?: string; description?: string | null },
actorUserId: number
): Promise<Group> {
const existing = await db.query.groups.findFirst({
where: (table, operators) => operators.eq(table.id, id)
});
if (!existing) throw new Error("Group not found");
await db
.update(groups)
.set({
name: input.name ?? existing.name,
description: input.description !== undefined ? input.description : existing.description,
updatedAt: nowIso()
})
.where(eq(groups.id, id));
logAuditEvent({
userId: actorUserId,
action: "update",
entityType: "group",
entityId: id,
summary: `Updated group ${input.name ?? existing.name}`
});
return (await getGroup(id))!;
}
export async function deleteGroup(id: number, actorUserId: number): Promise<void> {
const existing = await db.query.groups.findFirst({
where: (table, operators) => operators.eq(table.id, id)
});
if (!existing) throw new Error("Group not found");
await db.delete(groups).where(eq(groups.id, id));
logAuditEvent({
userId: actorUserId,
action: "delete",
entityType: "group",
entityId: id,
summary: `Deleted group ${existing.name}`
});
}
export async function addGroupMember(
groupId: number,
userId: number,
actorUserId: number
): Promise<Group> {
const group = await db.query.groups.findFirst({
where: (table, operators) => operators.eq(table.id, groupId)
});
if (!group) throw new Error("Group not found");
await db.insert(groupMembers).values({
groupId,
userId,
createdAt: nowIso()
});
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "group_member",
entityId: groupId,
summary: `Added user ${userId} to group ${group.name}`
});
return (await getGroup(groupId))!;
}
export async function removeGroupMember(
groupId: number,
userId: number,
actorUserId: number
): Promise<Group> {
const group = await db.query.groups.findFirst({
where: (table, operators) => operators.eq(table.id, groupId)
});
if (!group) throw new Error("Group not found");
const member = await db.query.groupMembers.findFirst({
where: (table, operators) =>
operators.and(
operators.eq(table.groupId, groupId),
operators.eq(table.userId, userId)
)
});
if (!member) throw new Error("Member not found in group");
await db.delete(groupMembers).where(eq(groupMembers.id, member.id));
logAuditEvent({
userId: actorUserId,
action: "delete",
entityType: "group_member",
entityId: groupId,
summary: `Removed user ${userId} from group ${group.name}`
});
return (await getGroup(groupId))!;
}
export async function getGroupsForUser(userId: number): Promise<{ id: number; name: string }[]> {
const rows = await db
.select({ id: groups.id, name: groups.name })
.from(groupMembers)
.innerJoin(groups, eq(groupMembers.groupId, groups.id))
.where(eq(groupMembers.userId, userId));
return rows;
}

View File

@@ -226,6 +226,21 @@ export type MtlsConfig = {
ca_certificate_ids?: number[];
};
export type CpmForwardAuthConfig = {
enabled: boolean;
protected_paths: string[] | null;
};
export type CpmForwardAuthInput = {
enabled?: boolean;
protected_paths?: string[] | null;
};
type CpmForwardAuthMeta = {
enabled?: boolean;
protected_paths?: string[];
};
type ProxyHostMeta = {
custom_reverse_proxy_json?: string;
custom_pre_handlers_json?: string;
@@ -237,6 +252,7 @@ type ProxyHostMeta = {
geoblock_mode?: GeoBlockMode;
waf?: WafHostConfig;
mtls?: MtlsConfig;
cpm_forward_auth?: CpmForwardAuthMeta;
redirects?: RedirectRule[];
rewrite?: RewriteConfig;
location_rules?: LocationRule[];
@@ -268,6 +284,7 @@ export type ProxyHost = {
geoblock_mode: GeoBlockMode;
waf: WafHostConfig | null;
mtls: MtlsConfig | null;
cpm_forward_auth: CpmForwardAuthConfig | null;
redirects: RedirectRule[];
rewrite: RewriteConfig | null;
location_rules: LocationRule[];
@@ -296,6 +313,7 @@ export type ProxyHostInput = {
geoblock_mode?: GeoBlockMode;
waf?: WafHostConfig | null;
mtls?: MtlsConfig | null;
cpm_forward_auth?: CpmForwardAuthInput | null;
redirects?: RedirectRule[] | null;
rewrite?: RewriteConfig | null;
location_rules?: LocationRule[] | null;
@@ -529,6 +547,21 @@ function sanitizeUpstreamDnsResolutionMeta(
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function sanitizeCpmForwardAuthMeta(meta: CpmForwardAuthMeta | undefined): CpmForwardAuthMeta | undefined {
if (!meta) return undefined;
const normalized: CpmForwardAuthMeta = {};
if (meta.enabled !== undefined) {
normalized.enabled = Boolean(meta.enabled);
}
if (Array.isArray(meta.protected_paths)) {
const paths = meta.protected_paths.map((p) => p?.trim()).filter((p): p is string => Boolean(p));
if (paths.length > 0) {
normalized.protected_paths = paths;
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function serializeMeta(meta: ProxyHostMeta | null | undefined) {
if (!meta) {
return null;
@@ -580,6 +613,13 @@ function serializeMeta(meta: ProxyHostMeta | null | undefined) {
normalized.mtls = meta.mtls;
}
if (meta.cpm_forward_auth) {
const cfa = sanitizeCpmForwardAuthMeta(meta.cpm_forward_auth);
if (cfa) {
normalized.cpm_forward_auth = cfa;
}
}
if (meta.redirects && meta.redirects.length > 0) {
normalized.redirects = meta.redirects;
}
@@ -657,6 +697,7 @@ function parseMeta(value: string | null): ProxyHostMeta {
geoblock_mode: parsed.geoblock_mode,
waf: parsed.waf,
mtls: parsed.mtls,
cpm_forward_auth: sanitizeCpmForwardAuthMeta(parsed.cpm_forward_auth),
redirects: sanitizeRedirectRules(parsed.redirects),
rewrite: sanitizeRewriteConfig(parsed.rewrite) ?? undefined,
location_rules: sanitizeLocationRules(parsed.location_rules),
@@ -1134,6 +1175,18 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
}
}
if (input.cpm_forward_auth !== undefined) {
if (input.cpm_forward_auth && input.cpm_forward_auth.enabled) {
const cfa: CpmForwardAuthMeta = { enabled: true };
if (input.cpm_forward_auth.protected_paths && input.cpm_forward_auth.protected_paths.length > 0) {
cfa.protected_paths = input.cpm_forward_auth.protected_paths;
}
next.cpm_forward_auth = cfa;
} else {
delete next.cpm_forward_auth;
}
}
if (input.redirects !== undefined) {
const rules = sanitizeRedirectRules(input.redirects ?? []);
if (rules.length > 0) {
@@ -1488,6 +1541,9 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
geoblock_mode: meta.geoblock_mode ?? "merge",
waf: meta.waf ?? null,
mtls: meta.mtls ?? null,
cpm_forward_auth: meta.cpm_forward_auth?.enabled
? { enabled: true, protected_paths: meta.cpm_forward_auth.protected_paths ?? null }
: null,
redirects: meta.redirects ?? [],
rewrite: meta.rewrite ?? null,
location_rules: meta.location_rules ?? [],
@@ -1622,6 +1678,12 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
...(existing.geoblock_mode !== "merge" ? { geoblock_mode: existing.geoblock_mode } : {}),
...(existing.waf ? { waf: existing.waf } : {}),
...(existing.mtls ? { mtls: existing.mtls } : {}),
...(existing.cpm_forward_auth?.enabled ? {
cpm_forward_auth: {
enabled: true,
...(existing.cpm_forward_auth.protected_paths ? { protected_paths: existing.cpm_forward_auth.protected_paths } : {})
}
} : {}),
};
const meta = buildMeta(existingMeta, input);

View File

@@ -101,13 +101,17 @@ export async function createProxyHost(page: Page, config: ProxyHostConfig): Prom
await mtlsCard.scrollIntoViewIfNeeded();
await mtlsCard.getByRole('switch').click();
await expect(page.getByText(/trusted client ca certificates/i)).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/trusted certificates/i)).toBeVisible({ timeout: 10_000 });
// Check each CA certificate by label
// Click each CA group header to select all issued certs from that CA
for (const caName of config.mtlsCaNames) {
await page.getByLabel(caName, { exact: true }).check();
const caLabel = page.locator('label').filter({ hasText: caName });
await caLabel.scrollIntoViewIfNeeded();
await caLabel.click();
}
await expect(page.locator('input[name="mtls_ca_cert_id"]')).toHaveCount(config.mtlsCaNames.length);
// Verify at least one cert was selected (each CA group selects its certs)
const certInputs = page.locator('input[name="mtls_cert_id"]');
await expect(certInputs.first()).toBeAttached({ timeout: 5_000 });
}
// Inject hidden fields:

View File

@@ -0,0 +1,288 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createHash, randomBytes } from 'node:crypto';
import { createTestDb, type TestDb } from '../helpers/db';
import {
forwardAuthSessions,
forwardAuthExchanges,
forwardAuthAccess,
groups,
groupMembers,
users,
proxyHosts
} from '@/src/lib/db/schema';
import { eq } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
function futureIso(seconds: number) {
return new Date(Date.now() + seconds * 1000).toISOString();
}
function pastIso(seconds: number) {
return new Date(Date.now() - seconds * 1000).toISOString();
}
function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
async function insertUser(overrides: Partial<typeof users.$inferInsert> = {}) {
const now = nowIso();
const [user] = await db.insert(users).values({
email: `user${Math.random().toString(36).slice(2)}@localhost`,
name: 'Test User',
role: 'user',
provider: 'credentials',
subject: `test-${Date.now()}-${Math.random()}`,
status: 'active',
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return user;
}
async function insertProxyHost(overrides: Partial<typeof proxyHosts.$inferInsert> = {}) {
const now = nowIso();
const [host] = await db.insert(proxyHosts).values({
name: 'Test Host',
domains: JSON.stringify(['app.example.com']),
upstreams: JSON.stringify(['backend:8080']),
sslForced: true,
hstsEnabled: true,
hstsSubdomains: false,
allowWebsocket: true,
preserveHostHeader: true,
skipHttpsHostnameValidation: false,
enabled: true,
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return host;
}
describe('forward auth sessions', () => {
it('creates a session with hashed token', async () => {
const user = await insertUser();
const rawToken = randomBytes(32).toString('hex');
const tokenHash = hashToken(rawToken);
const now = nowIso();
const [session] = await db.insert(forwardAuthSessions).values({
userId: user.id,
tokenHash,
expiresAt: futureIso(3600),
createdAt: now,
}).returning();
expect(session.tokenHash).toBe(tokenHash);
expect(session.userId).toBe(user.id);
});
it('enforces unique token hashes', async () => {
const user = await insertUser();
const tokenHash = hashToken('same-token');
const now = nowIso();
await db.insert(forwardAuthSessions).values({
userId: user.id, tokenHash, expiresAt: futureIso(3600), createdAt: now,
});
await expect(
db.insert(forwardAuthSessions).values({
userId: user.id, tokenHash, expiresAt: futureIso(3600), createdAt: now,
})
).rejects.toThrow();
});
it('cascades user deletion to sessions', async () => {
const user = await insertUser();
const now = nowIso();
await db.insert(forwardAuthSessions).values({
userId: user.id,
tokenHash: hashToken('token1'),
expiresAt: futureIso(3600),
createdAt: now,
});
await db.delete(users).where(eq(users.id, user.id));
const sessions = await db.query.forwardAuthSessions.findMany();
expect(sessions).toHaveLength(0);
});
});
describe('forward auth exchanges', () => {
it('creates an exchange code linked to a session', async () => {
const user = await insertUser();
const now = nowIso();
const [session] = await db.insert(forwardAuthSessions).values({
userId: user.id,
tokenHash: hashToken('session-token'),
expiresAt: futureIso(3600),
createdAt: now,
}).returning();
const rawCode = randomBytes(32).toString('hex');
const [exchange] = await db.insert(forwardAuthExchanges).values({
sessionId: session.id,
codeHash: hashToken(rawCode),
sessionToken: 'raw-session-token',
redirectUri: 'https://app.example.com/path',
expiresAt: futureIso(60),
used: false,
createdAt: now,
}).returning();
expect(exchange.sessionId).toBe(session.id);
expect(exchange.sessionToken).toBe('raw-session-token');
expect(exchange.used).toBe(false);
});
it('cascades session deletion to exchanges', async () => {
const user = await insertUser();
const now = nowIso();
const [session] = await db.insert(forwardAuthSessions).values({
userId: user.id,
tokenHash: hashToken('session2'),
expiresAt: futureIso(3600),
createdAt: now,
}).returning();
await db.insert(forwardAuthExchanges).values({
sessionId: session.id,
codeHash: hashToken('code1'),
sessionToken: 'raw-token',
redirectUri: 'https://app.example.com/',
expiresAt: futureIso(60),
used: false,
createdAt: now,
});
await db.delete(forwardAuthSessions).where(eq(forwardAuthSessions.id, session.id));
const exchanges = await db.query.forwardAuthExchanges.findMany();
expect(exchanges).toHaveLength(0);
});
});
describe('forward auth access', () => {
it('creates user-level access for a proxy host', async () => {
const user = await insertUser();
const host = await insertProxyHost();
const now = nowIso();
const [access] = await db.insert(forwardAuthAccess).values({
proxyHostId: host.id,
userId: user.id,
groupId: null,
createdAt: now,
}).returning();
expect(access.proxyHostId).toBe(host.id);
expect(access.userId).toBe(user.id);
expect(access.groupId).toBeNull();
});
it('creates group-level access for a proxy host', async () => {
const host = await insertProxyHost();
const now = nowIso();
const [group] = await db.insert(groups).values({
name: 'Devs',
createdAt: now,
updatedAt: now,
}).returning();
const [access] = await db.insert(forwardAuthAccess).values({
proxyHostId: host.id,
userId: null,
groupId: group.id,
createdAt: now,
}).returning();
expect(access.groupId).toBe(group.id);
expect(access.userId).toBeNull();
});
it('prevents duplicate user access per host', async () => {
const user = await insertUser();
const host = await insertProxyHost();
const now = nowIso();
await db.insert(forwardAuthAccess).values({
proxyHostId: host.id, userId: user.id, groupId: null, createdAt: now,
});
await expect(
db.insert(forwardAuthAccess).values({
proxyHostId: host.id, userId: user.id, groupId: null, createdAt: now,
})
).rejects.toThrow();
});
it('cascades proxy host deletion to access entries', async () => {
const user = await insertUser();
const host = await insertProxyHost();
const now = nowIso();
await db.insert(forwardAuthAccess).values({
proxyHostId: host.id, userId: user.id, groupId: null, createdAt: now,
});
await db.delete(proxyHosts).where(eq(proxyHosts.id, host.id));
const access = await db.query.forwardAuthAccess.findMany();
expect(access).toHaveLength(0);
});
it('cascades group deletion to access entries', async () => {
const host = await insertProxyHost();
const now = nowIso();
const [group] = await db.insert(groups).values({
name: 'Team', createdAt: now, updatedAt: now,
}).returning();
await db.insert(forwardAuthAccess).values({
proxyHostId: host.id, userId: null, groupId: group.id, createdAt: now,
});
await db.delete(groups).where(eq(groups.id, group.id));
const access = await db.query.forwardAuthAccess.findMany();
expect(access).toHaveLength(0);
});
it('allows both user and group access on same host', async () => {
const user = await insertUser();
const host = await insertProxyHost();
const now = nowIso();
const [group] = await db.insert(groups).values({
name: 'Group', createdAt: now, updatedAt: now,
}).returning();
await db.insert(forwardAuthAccess).values([
{ proxyHostId: host.id, userId: user.id, groupId: null, createdAt: now },
{ proxyHostId: host.id, userId: null, groupId: group.id, createdAt: now },
]);
const access = await db.query.forwardAuthAccess.findMany({
where: (t, { eq }) => eq(t.proxyHostId, host.id),
});
expect(access).toHaveLength(2);
});
});

View File

@@ -0,0 +1,130 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import { groups, groupMembers, users } from '@/src/lib/db/schema';
import { eq } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
async function insertUser(overrides: Partial<typeof users.$inferInsert> = {}) {
const now = nowIso();
const [user] = await db.insert(users).values({
email: `user${Math.random().toString(36).slice(2)}@localhost`,
name: 'Test User',
role: 'user',
provider: 'credentials',
subject: `test-${Date.now()}`,
status: 'active',
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return user;
}
async function insertGroup(overrides: Partial<typeof groups.$inferInsert> = {}) {
const now = nowIso();
const [group] = await db.insert(groups).values({
name: `Group ${Date.now()}`,
description: null,
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return group;
}
describe('groups integration', () => {
it('creates a group and stores it', async () => {
const group = await insertGroup({ name: 'Developers' });
const row = await db.query.groups.findFirst({ where: (t, { eq }) => eq(t.id, group.id) });
expect(row).toBeDefined();
expect(row!.name).toBe('Developers');
});
it('enforces unique group names', async () => {
await insertGroup({ name: 'UniqueGroup' });
await expect(insertGroup({ name: 'UniqueGroup' })).rejects.toThrow();
});
it('adds members to a group', async () => {
const group = await insertGroup({ name: 'Team' });
const user = await insertUser();
const now = nowIso();
await db.insert(groupMembers).values({
groupId: group.id,
userId: user.id,
createdAt: now,
});
const members = await db.query.groupMembers.findMany({
where: (t, { eq }) => eq(t.groupId, group.id),
});
expect(members).toHaveLength(1);
expect(members[0].userId).toBe(user.id);
});
it('prevents duplicate memberships', async () => {
const group = await insertGroup();
const user = await insertUser();
const now = nowIso();
await db.insert(groupMembers).values({ groupId: group.id, userId: user.id, createdAt: now });
await expect(
db.insert(groupMembers).values({ groupId: group.id, userId: user.id, createdAt: now })
).rejects.toThrow();
});
it('cascades group deletion to members', async () => {
const group = await insertGroup();
const user = await insertUser();
const now = nowIso();
await db.insert(groupMembers).values({ groupId: group.id, userId: user.id, createdAt: now });
await db.delete(groups).where(eq(groups.id, group.id));
const members = await db.query.groupMembers.findMany({
where: (t, { eq }) => eq(t.groupId, group.id),
});
expect(members).toHaveLength(0);
});
it('cascades user deletion to memberships', async () => {
const group = await insertGroup();
const user = await insertUser();
const now = nowIso();
await db.insert(groupMembers).values({ groupId: group.id, userId: user.id, createdAt: now });
await db.delete(users).where(eq(users.id, user.id));
const members = await db.query.groupMembers.findMany({
where: (t, { eq }) => eq(t.groupId, group.id),
});
expect(members).toHaveLength(0);
});
it('supports multiple groups per user', async () => {
const group1 = await insertGroup({ name: 'Group A' });
const group2 = await insertGroup({ name: 'Group B' });
const user = await insertUser();
const now = nowIso();
await db.insert(groupMembers).values([
{ groupId: group1.id, userId: user.id, createdAt: now },
{ groupId: group2.id, userId: user.id, createdAt: now },
]);
const memberships = await db.query.groupMembers.findMany({
where: (t, { eq }) => eq(t.userId, user.id),
});
expect(memberships).toHaveLength(2);
});
});