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 && (
+
+
+
+
+
+ )}
+
+ {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
+
+
+
+
+
+
+
+
+
+
+
+ Leave empty to protect entire domain. Comma-separated paths to protect specific routes only.
+
+
+
+ {/* Allowed Groups */}
+ {groups.length > 0 && (
+
+
+
+
+ Allowed Groups
+
+ {selectedGroupIds.length > 0 && (
+
+ {selectedGroupIds.length} selected
+
+ )}
+
+
+ {groups.map((group) => (
+
+ toggleGroup(group.id)}
+ />
+
+
+ {group.member_count} member{group.member_count !== 1 ? "s" : ""}
+
+
+ ))}
+
+
+ )}
+
+ {/* Allowed Users */}
+ {allUsers.length > 0 && (
+
+
+
+
+ Allowed Users
+
+ {selectedUserIds.length > 0 && (
+
+ {selectedUserIds.length} selected
+
+ )}
+
+
+ {allUsers.map((user) => (
+
+ toggleUser(user.id)}
+ />
+
+
+ ))}
+
+
+ )}
+
+ {groups.length === 0 && allUsers.length === 0 && (
+
+
+ No groups or users yet. Create groups on the Groups page.
+
+
+ )}
+
+ {selectedGroupIds.length === 0 && selectedUserIds.length === 0 && (groups.length > 0 || allUsers.length > 0) && (
+
+ No users or groups selected — nobody will be able to access this host.
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/proxy-hosts/HostDialogs.tsx b/src/components/proxy-hosts/HostDialogs.tsx
index daac06d4..e0958464 100644
--- a/src/components/proxy-hosts/HostDialogs.tsx
+++ b/src/components/proxy-hosts/HostDialogs.tsx
@@ -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({
+
@@ -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({
+
diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts
index e796307f..ea35e71e 100644
--- a/src/lib/caddy.ts
+++ b/src/lib/caddy.ts
@@ -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[] = [];
const meta = parseJson(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[] = [
+ { 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
+ ],
+ match: [
+ {
+ not: [{ vars: { [`{http.reverse_proxy.header.${headerName}}`]: [""] } }]
+ }
+ ]
+ });
+ }
+
+ // Forward auth handler — subrequest to CPM verify endpoint
+ const cpmForwardAuthHandler: Record = {
+ 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[] = [...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;
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 97d01174..11205734 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -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,
};
/**
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 044bdc72..d69a0c16 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -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(),
diff --git a/src/lib/models/forward-auth.ts b/src/lib/models/forward-auth.ts
new file mode 100644
index 00000000..fd5472bc
--- /dev/null
+++ b/src/lib/models/forward-auth.ts
@@ -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 {
+ 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 {
+ await db.delete(forwardAuthSessions).where(eq(forwardAuthSessions.id, id));
+}
+
+export async function deleteUserForwardAuthSessions(userId: number): Promise {
+ 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 {
+ // 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 {
+ 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 {
+ // 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 {
+ 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;
+}
diff --git a/src/lib/models/groups.ts b/src/lib/models/groups.ts
new file mode 100644
index 00000000..f4b2aa7d
--- /dev/null
+++ b/src/lib/models/groups.ts
@@ -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 {
+ 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();
+ 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 {
+ const [row] = await db.select({ value: count() }).from(groups);
+ return row?.value ?? 0;
+}
+
+export async function getGroup(id: number): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+}
diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts
index 96d29ea9..46f5c87c 100644
--- a/src/lib/models/proxy-hosts.ts
+++ b/src/lib/models/proxy-hosts.ts
@@ -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): 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
...(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);
diff --git a/tests/helpers/proxy-api.ts b/tests/helpers/proxy-api.ts
index c8d98999..c3d12831 100644
--- a/tests/helpers/proxy-api.ts
+++ b/tests/helpers/proxy-api.ts
@@ -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:
diff --git a/tests/integration/forward-auth.test.ts b/tests/integration/forward-auth.test.ts
new file mode 100644
index 00000000..0cfa3eb8
--- /dev/null
+++ b/tests/integration/forward-auth.test.ts
@@ -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 = {}) {
+ 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 = {}) {
+ 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);
+ });
+});
diff --git a/tests/integration/groups.test.ts b/tests/integration/groups.test.ts
new file mode 100644
index 00000000..00074805
--- /dev/null
+++ b/tests/integration/groups.test.ts
@@ -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 = {}) {
+ 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 = {}) {
+ 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);
+ });
+});