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
@@ -0,0 +1,464 @@
"use client";
import { useState, useCallback } from "react";
import { Copy, Pencil, Plus, Trash2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
import {
createOAuthProviderAction,
updateOAuthProviderAction,
deleteOAuthProviderAction,
} from "./actions";
interface OAuthProvidersSectionProps {
initialProviders: OAuthProvider[];
baseUrl: string;
}
type FormData = {
name: string;
type: string;
clientId: string;
clientSecret: string;
issuer: string;
authorizationUrl: string;
tokenUrl: string;
userinfoUrl: string;
scopes: string;
autoLink: boolean;
};
const emptyForm: FormData = {
name: "",
type: "oidc",
clientId: "",
clientSecret: "",
issuer: "",
authorizationUrl: "",
tokenUrl: "",
userinfoUrl: "",
scopes: "openid email profile",
autoLink: false,
};
export default function OAuthProvidersSection({ initialProviders, baseUrl }: OAuthProvidersSectionProps) {
const [providers, setProviders] = useState(initialProviders);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<OAuthProvider | null>(null);
const [form, setForm] = useState<FormData>(emptyForm);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const callbackUrl = useCallback(
(providerId: string) => `${baseUrl}/api/auth/oauth2/callback/${providerId}`,
[baseUrl]
);
function openAddDialog() {
setEditingProvider(null);
setForm(emptyForm);
setError(null);
setDialogOpen(true);
}
function openEditDialog(provider: OAuthProvider) {
setEditingProvider(provider);
setForm({
name: provider.name,
type: provider.type,
clientId: provider.clientId,
clientSecret: provider.clientSecret,
issuer: provider.issuer ?? "",
authorizationUrl: provider.authorizationUrl ?? "",
tokenUrl: provider.tokenUrl ?? "",
userinfoUrl: provider.userinfoUrl ?? "",
scopes: provider.scopes,
autoLink: provider.autoLink,
});
setError(null);
setDialogOpen(true);
}
async function handleSave() {
if (!form.name.trim() || !form.clientId.trim() || !form.clientSecret.trim()) {
setError("Name, Client ID, and Client Secret are required.");
return;
}
setSaving(true);
setError(null);
try {
if (editingProvider) {
const updated = await updateOAuthProviderAction(editingProvider.id, {
name: form.name.trim(),
type: form.type,
clientId: form.clientId.trim(),
clientSecret: form.clientSecret.trim(),
issuer: form.issuer.trim() || null,
authorizationUrl: form.authorizationUrl.trim() || null,
tokenUrl: form.tokenUrl.trim() || null,
userinfoUrl: form.userinfoUrl.trim() || null,
scopes: form.scopes.trim() || "openid email profile",
autoLink: form.autoLink,
});
if (updated) {
setProviders((prev) =>
prev.map((p) => (p.id === editingProvider.id ? updated : p))
);
}
} else {
const created = await createOAuthProviderAction({
name: form.name.trim(),
type: form.type,
clientId: form.clientId.trim(),
clientSecret: form.clientSecret.trim(),
issuer: form.issuer.trim() || undefined,
authorizationUrl: form.authorizationUrl.trim() || undefined,
tokenUrl: form.tokenUrl.trim() || undefined,
userinfoUrl: form.userinfoUrl.trim() || undefined,
scopes: form.scopes.trim() || undefined,
autoLink: form.autoLink,
});
setProviders((prev) => [...prev, created]);
}
setDialogOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setSaving(false);
}
}
async function handleToggleEnabled(provider: OAuthProvider) {
try {
const updated = await updateOAuthProviderAction(provider.id, {
enabled: !provider.enabled,
});
if (updated) {
setProviders((prev) =>
prev.map((p) => (p.id === provider.id ? updated : p))
);
}
} catch (err) {
console.error("Failed to toggle provider:", err);
}
}
async function handleDelete(id: string) {
try {
await deleteOAuthProviderAction(id);
setProviders((prev) => prev.filter((p) => p.id !== id));
setDeleteConfirmId(null);
} catch (err) {
console.error("Failed to delete provider:", err);
}
}
function copyToClipboard(text: string, providerId: string) {
navigator.clipboard.writeText(text).then(() => {
setCopiedId(providerId);
setTimeout(() => setCopiedId(null), 2000);
});
}
function updateField<K extends keyof FormData>(field: K, value: FormData[K]) {
setForm((prev) => ({ ...prev, [field]: value }));
}
return (
<div className="flex flex-col gap-3">
{providers.length === 0 && (
<Alert className="border-blue-500/30 bg-blue-500/5 text-blue-700 dark:text-blue-400 [&>svg]:text-blue-500">
<AlertDescription>
No OAuth providers configured. Add a provider to enable single sign-on.
</AlertDescription>
</Alert>
)}
{providers.map((provider) => (
<div
key={provider.id}
className="flex flex-col gap-2 rounded-md border px-4 py-3"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">{provider.name}</p>
<Badge variant="muted">{provider.type.toUpperCase()}</Badge>
<Badge variant={provider.source === "env" ? "info" : "secondary"}>
{provider.source === "env" ? "ENV" : "UI"}
</Badge>
{!provider.enabled && (
<Badge variant="warning">Disabled</Badge>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Label htmlFor={`toggle-${provider.id}`} className="text-xs text-muted-foreground">
Enabled
</Label>
<Switch
id={`toggle-${provider.id}`}
checked={provider.enabled}
onCheckedChange={() => handleToggleEnabled(provider)}
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => openEditDialog(provider)}
disabled={provider.source === "env"}
title={provider.source === "env" ? "Environment-sourced providers cannot be edited" : "Edit provider"}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{deleteConfirmId === provider.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(provider.id)}
>
Confirm
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/50"
onClick={() => setDeleteConfirmId(provider.id)}
disabled={provider.source === "env"}
title={provider.source === "env" ? "Environment-sourced providers cannot be deleted" : "Delete provider"}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-muted-foreground break-all">
{callbackUrl(provider.id)}
</code>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
onClick={() => copyToClipboard(callbackUrl(provider.id), provider.id)}
title="Copy callback URL"
>
<Copy className="h-3 w-3" />
</Button>
{copiedId === provider.id && (
<span className="text-xs text-emerald-600">Copied!</span>
)}
</div>
</div>
))}
<div className="flex justify-end">
<Button size="sm" onClick={openAddDialog}>
<Plus className="h-4 w-4 mr-1" />
Add Provider
</Button>
</div>
{/* Add / Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingProvider ? "Edit OAuth Provider" : "Add OAuth Provider"}
</DialogTitle>
<DialogDescription>
{editingProvider
? "Update the OAuth provider configuration."
: "Configure a new OAuth or OIDC provider for single sign-on."}
</DialogDescription>
</DialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-name">Name *</Label>
<Input
id="oauth-name"
value={form.name}
onChange={(e) => updateField("name", e.target.value)}
placeholder="e.g. Google, Keycloak"
className="h-8 text-sm"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-type">Type</Label>
<Select
value={form.type}
onValueChange={(v) => updateField("type", v)}
>
<SelectTrigger id="oauth-type" className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="oidc">OIDC (OpenID Connect)</SelectItem>
<SelectItem value="oauth2">OAuth2</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-client-id">Client ID *</Label>
<Input
id="oauth-client-id"
value={form.clientId}
onChange={(e) => updateField("clientId", e.target.value)}
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-client-secret">Client Secret *</Label>
<Input
id="oauth-client-secret"
type="password"
autoComplete="new-password"
value={form.clientSecret}
onChange={(e) => updateField("clientSecret", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-issuer">Issuer URL</Label>
<Input
id="oauth-issuer"
value={form.issuer}
onChange={(e) => updateField("issuer", e.target.value)}
placeholder="https://accounts.google.com"
className="h-8 text-sm font-mono"
/>
<p className="text-xs text-muted-foreground">
For OIDC providers, the issuer URL enables automatic discovery of endpoints.
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-auth-url">Authorization URL</Label>
<Input
id="oauth-auth-url"
value={form.authorizationUrl}
onChange={(e) => updateField("authorizationUrl", e.target.value)}
placeholder="Override discovered endpoint"
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-token-url">Token URL</Label>
<Input
id="oauth-token-url"
value={form.tokenUrl}
onChange={(e) => updateField("tokenUrl", e.target.value)}
placeholder="Override discovered endpoint"
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-userinfo-url">Userinfo URL</Label>
<Input
id="oauth-userinfo-url"
value={form.userinfoUrl}
onChange={(e) => updateField("userinfoUrl", e.target.value)}
placeholder="Override discovered endpoint"
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-scopes">Scopes</Label>
<Input
id="oauth-scopes"
value={form.scopes}
onChange={(e) => updateField("scopes", e.target.value)}
placeholder="openid email profile"
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex items-center gap-2 pt-1">
<Switch
id="oauth-auto-link"
checked={form.autoLink}
onCheckedChange={(v) => updateField("autoLink", v)}
/>
<Label htmlFor="oauth-auto-link">
Auto-link accounts
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-1">
Automatically link OAuth accounts to existing users with the same email address.
</p>
{editingProvider && (
<div className="flex flex-col gap-1.5 pt-1">
<Label className="text-xs text-muted-foreground">Callback URL</Label>
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-muted-foreground break-all">
{callbackUrl(editingProvider.id)}
</code>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
onClick={() => copyToClipboard(callbackUrl(editingProvider.id), editingProvider.id)}
title="Copy callback URL"
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : editingProvider ? "Update Provider" : "Create Provider"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+25 -8
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useFormState } from "react-dom";
import {
Cloud, Globe, Network, Pin, Activity,
ScrollText, Settings2, UserCheck, MapPin,
ScrollText, Settings2, UserCheck, MapPin, KeyRound,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -24,6 +24,8 @@ import type {
GeoBlockSettings,
} from "@/lib/settings";
import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields";
import OAuthProvidersSection from "./OAuthProvidersSection";
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
import {
updateCloudflareSettingsAction,
updateGeneralSettingsAction,
@@ -117,6 +119,7 @@ const A: Record<string, AccentConfig> = {
metrics: { border: "border-l-rose-500", icon: "border-rose-500/30 bg-rose-500/10 text-rose-500" },
logging: { border: "border-l-amber-500", icon: "border-amber-500/30 bg-amber-500/10 text-amber-500" },
geoblock: { border: "border-l-teal-500", icon: "border-teal-500/30 bg-teal-500/10 text-teal-500" },
oauth: { border: "border-l-indigo-500", icon: "border-indigo-500/30 bg-indigo-500/10 text-indigo-500" },
};
// ─── Props ────────────────────────────────────────────────────────────────────
@@ -134,6 +137,8 @@ type Props = {
dns: DnsSettings | null;
upstreamDnsResolution: UpstreamDnsResolutionSettings | null;
globalGeoBlock?: GeoBlockSettings | null;
oauthProviders: OAuthProvider[];
baseUrl: string;
instanceSync: {
mode: "standalone" | "master" | "slave";
modeFromEnv: boolean;
@@ -156,10 +161,10 @@ type Props = {
instances: Array<{
id: number;
name: string;
base_url: string;
baseUrl: string;
enabled: boolean;
last_sync_at: string | null;
last_sync_error: string | null;
lastSyncAt: string | null;
lastSyncError: string | null;
}>;
envInstances: Array<{
name: string;
@@ -180,6 +185,8 @@ export default function SettingsClient({
dns,
upstreamDnsResolution,
globalGeoBlock,
oauthProviders,
baseUrl,
instanceSync
}: Props) {
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
@@ -376,12 +383,12 @@ export default function SettingsClient({
>
<div>
<p className="text-sm font-semibold">{instance.name}</p>
<p className="text-xs text-muted-foreground font-mono">{instance.base_url}</p>
<p className="text-xs text-muted-foreground font-mono">{instance.baseUrl}</p>
<span className="text-xs text-muted-foreground">
{instance.last_sync_at ? `Last sync: ${instance.last_sync_at}` : "No sync yet"}
{instance.lastSyncAt ? `Last sync: ${instance.lastSyncAt}` : "No sync yet"}
</span>
{instance.last_sync_error && (
<span className="block text-xs text-destructive">{instance.last_sync_error}</span>
{instance.lastSyncError && (
<span className="block text-xs text-destructive">{instance.lastSyncError}</span>
)}
</div>
<div className="flex gap-2">
@@ -853,6 +860,16 @@ export default function SettingsClient({
</div>
</form>
</SettingSection>
{/* ── OAuth Providers ── */}
<SettingSection
icon={<KeyRound className="h-4 w-4" />}
title="OAuth Providers"
description="Configure OAuth/OIDC providers for single sign-on. Users can log in via these providers in addition to local credentials."
accent={A.oauth}
>
<OAuthProvidersSection initialProviders={oauthProviders} baseUrl={baseUrl} />
</SettingSection>
</div>
);
}
+70
View File
@@ -676,6 +676,76 @@ export async function suppressWafRuleGloballyAction(ruleId: number): Promise<Act
}
}
export async function getOAuthProvidersAction() {
await requireAdmin();
const { listOAuthProviders } = await import("@/src/lib/models/oauth-providers");
return listOAuthProviders();
}
export async function createOAuthProviderAction(data: {
name: string;
type: string;
clientId: string;
clientSecret: string;
issuer?: string;
authorizationUrl?: string;
tokenUrl?: string;
userinfoUrl?: string;
scopes?: string;
autoLink?: boolean;
}) {
const session = await requireAdmin();
const { createOAuthProvider } = await import("@/src/lib/models/oauth-providers");
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
const provider = await createOAuthProvider({ ...data, source: "ui" });
invalidateProviderCache();
const { createAuditEvent } = await import("@/src/lib/models/audit");
await createAuditEvent({
userId: Number(session.user.id),
action: "oauth_provider_created",
entityType: "oauth_provider",
entityId: null,
summary: `OAuth provider "${data.name}" created`,
data: JSON.stringify({ providerId: provider.id }),
});
revalidatePath("/settings");
return provider;
}
export async function updateOAuthProviderAction(
id: string,
data: Partial<{
name: string;
type: string;
clientId: string;
clientSecret: string;
issuer: string | null;
authorizationUrl: string | null;
tokenUrl: string | null;
userinfoUrl: string | null;
scopes: string;
autoLink: boolean;
enabled: boolean;
}>
) {
await requireAdmin();
const { updateOAuthProvider } = await import("@/src/lib/models/oauth-providers");
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
const updated = await updateOAuthProvider(id, data);
invalidateProviderCache();
revalidatePath("/settings");
return updated;
}
export async function deleteOAuthProviderAction(id: string) {
await requireAdmin();
const { deleteOAuthProvider } = await import("@/src/lib/models/oauth-providers");
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
await deleteOAuthProvider(id);
invalidateProviderCache();
revalidatePath("/settings");
}
export async function suppressWafRuleForHostAction(ruleId: number, hostname: string): Promise<ActionResult> {
try {
const session = await requireAdmin();
+6 -1
View File
@@ -2,6 +2,8 @@ import SettingsClient from "./SettingsClient";
import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getSetting, getUpstreamDnsResolutionSettings, getGeoBlockSettings } from "@/src/lib/settings";
import { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync";
import { listInstances } from "@/src/lib/models/instances";
import { listOAuthProviders } from "@/src/lib/models/oauth-providers";
import { config } from "@/src/lib/config";
import { requireAdmin } from "@/src/lib/auth";
export default async function SettingsPage() {
@@ -11,7 +13,7 @@ export default async function SettingsPage() {
const modeFromEnv = isInstanceModeFromEnv();
const tokenFromEnv = isSyncTokenFromEnv();
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock] = await Promise.all([
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock, oauthProviders] = await Promise.all([
getGeneralSettings(),
getCloudflareSettings(),
getAuthentikSettings(),
@@ -21,6 +23,7 @@ export default async function SettingsPage() {
getUpstreamDnsResolutionSettings(),
getInstanceMode(),
getGeoBlockSettings(),
listOAuthProviders(),
]);
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
@@ -57,6 +60,8 @@ export default async function SettingsPage() {
dns={dns}
upstreamDnsResolution={upstreamDnsResolution}
globalGeoBlock={globalGeoBlock}
oauthProviders={oauthProviders}
baseUrl={config.baseUrl}
instanceSync={{
mode: instanceMode,
modeFromEnv,