Replace next-auth with Better Auth, migrate DB columns to camelCase

- Replace next-auth v5 beta with better-auth v1.6.2 (stable releases)
- Add multi-provider OAuth support with admin UI configuration
- New oauthProviders table with encrypted secrets (AES-256-GCM)
- Env var bootstrap (OAUTH_*) syncs to DB, UI-created providers fully editable
- OAuth provider REST API: GET/POST/PUT/DELETE /api/v1/oauth-providers
- Settings page "Authentication Providers" section for admin management
- Account linking uses new accounts table (multi-provider per user)
- Username plugin for credentials sign-in (replaces email@localhost pattern)
- bcrypt password compatibility (existing hashes work)
- Database-backed sessions via Kysely adapter (bun:sqlite direct)
- Configurable rate limiting via AUTH_RATE_LIMIT_* env vars
- All DB columns migrated from snake_case to camelCase
- All TypeScript types/models migrated to camelCase properties
- Removed casing: "snake_case" from Drizzle config
- Callback URL format: {baseUrl}/api/auth/oauth2/callback/{providerId}
- package-lock.json removed and gitignored (using bun.lock)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-04-12 21:11:48 +02:00
parent eb78b64c2f
commit 3a16d6e9b1
100 changed files with 3390 additions and 14495 deletions

View File

@@ -32,7 +32,7 @@ export function CpmForwardAuthFields({
groups = [],
currentAccess,
}: {
cpmForwardAuth?: ProxyHost["cpm_forward_auth"] | null;
cpmForwardAuth?: ProxyHost["cpmForwardAuth"] | null;
users?: UserEntry[];
groups?: GroupEntry[];
currentAccess?: ForwardAuthAccessData | null;

View File

@@ -9,7 +9,7 @@ import { ProxyHost } from "@/lib/models/proxy-hosts";
export function DnsResolverFields({
dnsResolver
}: {
dnsResolver?: ProxyHost["dns_resolver"] | null;
dnsResolver?: ProxyHost["dnsResolver"] | null;
}) {
const initial = dnsResolver ?? null;
const [enabled, setEnabled] = useState(initial?.enabled ?? false);

View File

@@ -87,8 +87,8 @@ export function CreateHostDialog({
</Alert>
)}
<SettingsToggles
hstsSubdomains={initialData?.hsts_subdomains}
skipHttpsValidation={initialData?.skip_https_hostname_validation}
hstsSubdomains={initialData?.hstsSubdomains}
skipHttpsValidation={initialData?.skipHttpsHostnameValidation}
enabled={true}
/>
<div>
@@ -118,7 +118,7 @@ export function CreateHostDialog({
<UpstreamInput defaultUpstreams={initialData?.upstreams} />
<div>
<label className="text-sm font-medium mb-1 block">Certificate</label>
<Select name="certificate_id" defaultValue={String(initialData?.certificate_id ?? "__none__")}>
<Select name="certificate_id" defaultValue={String(initialData?.certificateId ?? "__none__")}>
<SelectTrigger aria-label="Certificate">
<SelectValue placeholder="Managed by Caddy (Auto)" />
</SelectTrigger>
@@ -134,7 +134,7 @@ export function CreateHostDialog({
</div>
<div>
<label className="text-sm font-medium mb-1 block">Access List</label>
<Select name="access_list_id" defaultValue={String(initialData?.access_list_id ?? "__none__")}>
<Select name="access_list_id" defaultValue={String(initialData?.accessListId ?? "__none__")}>
<SelectTrigger aria-label="Access List">
<SelectValue placeholder="None" />
</SelectTrigger>
@@ -149,14 +149,14 @@ export function CreateHostDialog({
</Select>
</div>
<RedirectsFields initialData={initialData?.redirects} />
<LocationRulesFields initialData={initialData?.location_rules} />
<LocationRulesFields initialData={initialData?.locationRules} />
<RewriteFields initialData={initialData?.rewrite} />
<div>
<label className="text-sm font-medium mb-1 block">Custom Pre-Handlers (JSON)</label>
<Textarea
name="custom_pre_handlers_json"
placeholder='[{"handler": "headers", ...}]'
defaultValue={initialData?.custom_pre_handlers_json ?? ""}
defaultValue={initialData?.customPreHandlersJson ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">Optional JSON array of Caddy handlers</p>
@@ -166,7 +166,7 @@ export function CreateHostDialog({
<Textarea
name="custom_reverse_proxy_json"
placeholder='{"headers": {"request": {...}}}'
defaultValue={initialData?.custom_reverse_proxy_json ?? ""}
defaultValue={initialData?.customReverseProxyJson ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">
@@ -175,13 +175,13 @@ export function CreateHostDialog({
</div>
<AuthentikFields defaults={authentikDefaults} authentik={initialData?.authentik} />
<CpmForwardAuthFields
cpmForwardAuth={initialData?.cpm_forward_auth}
cpmForwardAuth={initialData?.cpmForwardAuth}
users={forwardAuthUsers}
groups={forwardAuthGroups}
/>
<LoadBalancerFields loadBalancer={initialData?.load_balancer} />
<DnsResolverFields dnsResolver={initialData?.dns_resolver} />
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstream_dns_resolution} />
<LoadBalancerFields loadBalancer={initialData?.loadBalancer} />
<DnsResolverFields dnsResolver={initialData?.dnsResolver} />
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstreamDnsResolution} />
<GeoBlockFields />
<WafFields value={initialData?.waf} />
<MtlsFields
@@ -246,8 +246,8 @@ export function EditHostDialog({
</Alert>
)}
<SettingsToggles
hstsSubdomains={host.hsts_subdomains}
skipHttpsValidation={host.skip_https_hostname_validation}
hstsSubdomains={host.hstsSubdomains}
skipHttpsValidation={host.skipHttpsHostnameValidation}
enabled={host.enabled}
/>
<div>
@@ -269,7 +269,7 @@ export function EditHostDialog({
<UpstreamInput defaultUpstreams={host.upstreams} />
<div>
<label className="text-sm font-medium mb-1 block">Certificate</label>
<Select name="certificate_id" defaultValue={String(host.certificate_id ?? "__none__")}>
<Select name="certificate_id" defaultValue={String(host.certificateId ?? "__none__")}>
<SelectTrigger aria-label="Certificate">
<SelectValue placeholder="Managed by Caddy (Auto)" />
</SelectTrigger>
@@ -285,7 +285,7 @@ export function EditHostDialog({
</div>
<div>
<label className="text-sm font-medium mb-1 block">Access List</label>
<Select name="access_list_id" defaultValue={String(host.access_list_id ?? "__none__")}>
<Select name="access_list_id" defaultValue={String(host.accessListId ?? "__none__")}>
<SelectTrigger aria-label="Access List">
<SelectValue placeholder="None" />
</SelectTrigger>
@@ -300,13 +300,13 @@ export function EditHostDialog({
</Select>
</div>
<RedirectsFields initialData={host.redirects} />
<LocationRulesFields initialData={host.location_rules} />
<LocationRulesFields initialData={host.locationRules} />
<RewriteFields initialData={host.rewrite} />
<div>
<label className="text-sm font-medium mb-1 block">Custom Pre-Handlers (JSON)</label>
<Textarea
name="custom_pre_handlers_json"
defaultValue={host.custom_pre_handlers_json ?? ""}
defaultValue={host.customPreHandlersJson ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">Optional JSON array of Caddy handlers</p>
@@ -315,7 +315,7 @@ export function EditHostDialog({
<label className="text-sm font-medium mb-1 block">Custom Reverse Proxy (JSON)</label>
<Textarea
name="custom_reverse_proxy_json"
defaultValue={host.custom_reverse_proxy_json ?? ""}
defaultValue={host.customReverseProxyJson ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">
@@ -324,18 +324,18 @@ export function EditHostDialog({
</div>
<AuthentikFields authentik={host.authentik} />
<CpmForwardAuthFields
cpmForwardAuth={host.cpm_forward_auth}
cpmForwardAuth={host.cpmForwardAuth}
users={forwardAuthUsers}
groups={forwardAuthGroups}
currentAccess={forwardAuthAccess}
/>
<LoadBalancerFields loadBalancer={host.load_balancer} />
<DnsResolverFields dnsResolver={host.dns_resolver} />
<UpstreamDnsResolutionFields upstreamDnsResolution={host.upstream_dns_resolution} />
<LoadBalancerFields loadBalancer={host.loadBalancer} />
<DnsResolverFields dnsResolver={host.dnsResolver} />
<UpstreamDnsResolutionFields upstreamDnsResolution={host.upstreamDnsResolution} />
<GeoBlockFields
initialValues={{
geoblock: host.geoblock,
geoblock_mode: host.geoblock_mode,
geoblock_mode: host.geoblockMode,
}}
/>
<WafFields value={host.waf} />

View File

@@ -19,7 +19,7 @@ const LOAD_BALANCING_POLICIES = [
export function LoadBalancerFields({
loadBalancer
}: {
loadBalancer?: ProxyHost["load_balancer"] | null;
loadBalancer?: ProxyHost["loadBalancer"] | null;
}) {
const initial = loadBalancer ?? null;
const [enabled, setEnabled] = useState(initial?.enabled ?? false);

View File

@@ -37,13 +37,13 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
const [editRule, setEditRule] = useState<MtlsAccessRule | null>(null);
const isEditMode = !!proxyHostId;
const activeCerts = issuedClientCerts.filter(c => !c.revoked_at);
const activeCerts = issuedClientCerts.filter(c => !c.revokedAt);
const certsByCA = new Map<number, IssuedClientCertificate[]>();
for (const cert of activeCerts) {
const list = certsByCA.get(cert.ca_certificate_id) ?? [];
const list = certsByCA.get(cert.caCertificateId) ?? [];
list.push(cert);
certsByCA.set(cert.ca_certificate_id, list);
certsByCA.set(cert.caCertificateId, list);
}
const loadRules = useCallback(() => {
@@ -143,7 +143,7 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
<span className="text-sm font-medium">{role.name}</span>
{role.description && <span className="text-xs text-muted-foreground ml-2"> {role.description}</span>}
</label>
<Badge variant="outline" className="text-xs shrink-0">{role.certificate_count} certs</Badge>
<Badge variant="outline" className="text-xs shrink-0">{role.certificateCount} certs</Badge>
</div>
))}
</div>
@@ -201,10 +201,10 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
className="ml-4"
/>
<label className="min-w-0 flex-1 cursor-pointer" onClick={() => toggleCert(cert.id)}>
<span className="text-sm">{cert.common_name}</span>
<span className="text-sm">{cert.commonName}</span>
</label>
<span className="text-xs text-muted-foreground shrink-0">
expires {new Date(cert.valid_to).toLocaleDateString()}
expires {new Date(cert.validTo).toLocaleDateString()}
</span>
</div>
))}
@@ -249,20 +249,20 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
<div className="flex flex-col gap-1.5">
{rules.map(rule => (
<div key={rule.id} className="group flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
<code className="shrink-0 text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{rule.path_pattern}</code>
{rule.deny_all ? (
<code className="shrink-0 text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{rule.pathPattern}</code>
{rule.denyAll ? (
<Badge variant="destructive" className="text-xs gap-1"><Ban className="h-3 w-3" /> Deny</Badge>
) : (
<div className="flex flex-wrap gap-1 flex-1 min-w-0">
{rule.allowed_role_ids.map(roleId => {
{rule.allowedRoleIds.map(roleId => {
const role = mtlsRoles.find(r => r.id === roleId);
return <Badge key={`r-${roleId}`} variant="secondary" className="text-xs">{role?.name ?? `#${roleId}`}</Badge>;
})}
{rule.allowed_cert_ids.map(certId => {
{rule.allowedCertIds.map(certId => {
const cert = issuedClientCerts.find(c => c.id === certId);
return <Badge key={`c-${certId}`} variant="outline" className="text-xs">{cert?.common_name ?? `#${certId}`}</Badge>;
return <Badge key={`c-${certId}`} variant="outline" className="text-xs">{cert?.commonName ?? `#${certId}`}</Badge>;
})}
{rule.allowed_role_ids.length === 0 && rule.allowed_cert_ids.length === 0 && (
{rule.allowedRoleIds.length === 0 && rule.allowedCertIds.length === 0 && (
<span className="text-xs text-destructive italic">No roles/certs effectively denied</span>
)}
</div>
@@ -297,12 +297,12 @@ function RuleDialog({ onClose, proxyHostId, roles, activeCerts, title, submitLab
onClose: () => void; proxyHostId: number; roles: MtlsRole[]; activeCerts: IssuedClientCertificate[];
title: string; submitLabel: string; existing?: MtlsAccessRule; onSaved: () => void;
}) {
const [pathPattern, setPathPattern] = useState(existing?.path_pattern ?? "*");
const [pathPattern, setPathPattern] = useState(existing?.pathPattern ?? "*");
const [priority, setPriority] = useState(String(existing?.priority ?? 0));
const [description, setDescription] = useState(existing?.description ?? "");
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>(existing?.allowed_role_ids ?? []);
const [selectedCertIds, setSelectedCertIds] = useState<number[]>(existing?.allowed_cert_ids ?? []);
const [denyAll, setDenyAll] = useState(existing?.deny_all ?? false);
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>(existing?.allowedRoleIds ?? []);
const [selectedCertIds, setSelectedCertIds] = useState<number[]>(existing?.allowedCertIds ?? []);
const [denyAll, setDenyAll] = useState(existing?.denyAll ?? false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
@@ -373,7 +373,7 @@ function RuleDialog({ onClose, proxyHostId, roles, activeCerts, title, submitLab
{activeCerts.map(cert => (
<div key={cert.id} className="flex items-center gap-2 py-1 rounded hover:bg-muted/50 px-1">
<Checkbox checked={selectedCertIds.includes(cert.id)} onCheckedChange={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])} />
<label className="text-sm cursor-pointer flex-1" onClick={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])}>{cert.common_name}</label>
<label className="text-sm cursor-pointer flex-1" onClick={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])}>{cert.commonName}</label>
</div>
))}
</div>

View File

@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils";
import { useState } from "react";
type ToggleSetting = {
name: "hsts_subdomains" | "skip_https_hostname_validation";
name: "hstsSubdomains" | "skipHttpsHostnameValidation";
label: string;
description: string;
defaultChecked: boolean;
@@ -21,8 +21,8 @@ export function SettingsToggles({
enabled = true
}: SettingsTogglesProps) {
const [values, setValues] = useState({
hsts_subdomains: hstsSubdomains,
skip_https_hostname_validation: skipHttpsValidation,
hstsSubdomains: hstsSubdomains,
skipHttpsHostnameValidation: skipHttpsValidation,
enabled: enabled
});
@@ -32,16 +32,16 @@ export function SettingsToggles({
const settings: ToggleSetting[] = [
{
name: "hsts_subdomains",
name: "hstsSubdomains",
label: "HSTS Subdomains",
description: "Include subdomains in the Strict-Transport-Security header",
defaultChecked: values.hsts_subdomains,
defaultChecked: values.hstsSubdomains,
},
{
name: "skip_https_hostname_validation",
name: "skipHttpsHostnameValidation",
label: "Skip HTTPS Validation",
description: "Skip SSL certificate hostname verification for backend connections",
defaultChecked: values.skip_https_hostname_validation,
defaultChecked: values.skipHttpsHostnameValidation,
}
];

View File

@@ -25,7 +25,7 @@ function toFamilyMode(family: "ipv6" | "ipv4" | "both" | null | undefined): Fami
export function UpstreamDnsResolutionFields({
upstreamDnsResolution
}: {
upstreamDnsResolution?: ProxyHost["upstream_dns_resolution"] | null;
upstreamDnsResolution?: ProxyHost["upstreamDnsResolution"] | null;
}) {
const mode = toResolutionMode(upstreamDnsResolution?.enabled);
const family = toFamilyMode(upstreamDnsResolution?.family);