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:
218
app/(auth)/portal/PortalLoginForm.tsx
Normal file
218
app/(auth)/portal/PortalLoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
app/(auth)/portal/page.tsx
Normal file
33
app/(auth)/portal/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
245
app/(dashboard)/groups/GroupsClient.tsx
Normal file
245
app/(dashboard)/groups/GroupsClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
app/(dashboard)/groups/actions.ts
Normal file
63
app/(dashboard)/groups/actions.ts
Normal 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");
|
||||
}
|
||||
16
app/(dashboard)/groups/page.tsx
Normal file
16
app/(dashboard)/groups/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
37
app/api/forward-auth/callback/route.ts
Normal file
37
app/api/forward-auth/callback/route.ts
Normal 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;
|
||||
}
|
||||
113
app/api/forward-auth/login/route.ts
Normal file
113
app/api/forward-auth/login/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
72
app/api/forward-auth/session-login/route.ts
Normal file
72
app/api/forward-auth/session-login/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
53
app/api/forward-auth/verify/route.ts
Normal file
53
app/api/forward-auth/verify/route.ts
Normal 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)
|
||||
}
|
||||
});
|
||||
}
|
||||
16
app/api/v1/forward-auth-sessions/[id]/route.ts
Normal file
16
app/api/v1/forward-auth-sessions/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
app/api/v1/forward-auth-sessions/route.ts
Normal file
30
app/api/v1/forward-auth-sessions/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
app/api/v1/groups/[id]/members/[userId]/route.ts
Normal file
16
app/api/v1/groups/[id]/members/[userId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
app/api/v1/groups/[id]/members/route.ts
Normal file
20
app/api/v1/groups/[id]/members/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
42
app/api/v1/groups/[id]/route.ts
Normal file
42
app/api/v1/groups/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
app/api/v1/groups/route.ts
Normal file
24
app/api/v1/groups/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
35
app/api/v1/proxy-hosts/[id]/forward-auth-access/route.ts
Normal file
35
app/api/v1/proxy-hosts/[id]/forward-auth-access/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
65
drizzle/0017_forward_auth.sql
Normal file
65
drizzle/0017_forward_auth.sql
Normal 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`);
|
||||
@@ -120,6 +120,13 @@
|
||||
"when": 1775400000000,
|
||||
"tag": "0016_mtls_rbac",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1775500000000,
|
||||
"tag": "0017_forward_auth",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
4
proxy.ts
4
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();
|
||||
}
|
||||
|
||||
206
src/components/proxy-hosts/CpmForwardAuthFields.tsx
Normal file
206
src/components/proxy-hosts/CpmForwardAuthFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
224
src/lib/caddy.ts
224
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<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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
296
src/lib/models/forward-auth.ts
Normal file
296
src/lib/models/forward-auth.ts
Normal 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
249
src/lib/models/groups.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
288
tests/integration/forward-auth.test.ts
Normal file
288
tests/integration/forward-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
130
tests/integration/groups.test.ts
Normal file
130
tests/integration/groups.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user