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:
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} />
|
||||
|
||||
Reference in New Issue
Block a user