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

@@ -82,14 +82,14 @@ type ProxyHostRow = {
name: string;
domains: string;
upstreams: string;
certificate_id: number | null;
access_list_id: number | null;
ssl_forced: number;
hsts_enabled: number;
hsts_subdomains: number;
allow_websocket: number;
preserve_host_header: number;
skip_https_hostname_validation: number;
certificateId: number | null;
accessListId: number | null;
sslForced: number;
hstsEnabled: number;
hstsSubdomains: number;
allowWebsocket: number;
preserveHostHeader: number;
skipHttpsHostnameValidation: number;
meta: string | null;
enabled: number;
};
@@ -216,20 +216,20 @@ type LoadBalancerRouteConfig = {
};
type AccessListEntryRow = {
access_list_id: number;
accessListId: number;
username: string;
password_hash: string;
passwordHash: string;
};
type CertificateRow = {
id: number;
name: string;
type: string;
domain_names: string;
certificate_pem: string | null;
private_key_pem: string | null;
auto_renew: number;
provider_options: string | null;
domainNames: string;
certificatePem: string | null;
privateKeyPem: string | null;
autoRenew: number;
providerOptions: string | null;
};
type CaddyHttpRoute = Record<string, unknown>;
@@ -507,15 +507,15 @@ function collectCertificateUsage(rows: ProxyHostRow[], certificates: Map<number,
continue;
}
// Handle auto-managed certificates (certificate_id is null)
if (!row.certificate_id) {
// Handle auto-managed certificates (certificateId is null)
if (!row.certificateId) {
for (const domain of filteredDomains) {
autoManagedDomains.add(domain);
}
continue;
}
const cert = certificates.get(row.certificate_id);
const cert = certificates.get(row.certificateId);
if (!cert) {
continue;
}
@@ -681,9 +681,9 @@ async function buildProxyRoutes(
continue;
}
// Allow hosts with certificate_id = null (Caddy Auto) or with valid certificate IDs
const isAutoManaged = !row.certificate_id;
const hasValidCertificate = row.certificate_id && tlsReadyCertificates.has(row.certificate_id);
// Allow hosts with certificateId = null (Caddy Auto) or with valid certificate IDs
const isAutoManaged = !row.certificateId;
const hasValidCertificate = row.certificateId && tlsReadyCertificates.has(row.certificateId);
if (!isAutoManaged && !hasValidCertificate) {
continue;
@@ -720,11 +720,11 @@ async function buildProxyRoutes(
meta.waf
);
if (effectiveWaf?.enabled && effectiveWaf.mode !== 'Off') {
handlers.unshift(buildWafHandler(effectiveWaf, Boolean(row.allow_websocket)));
handlers.unshift(buildWafHandler(effectiveWaf, Boolean(row.allowWebsocket)));
}
if (row.hsts_enabled) {
const value = row.hsts_subdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
if (row.hstsEnabled) {
const value = row.hstsSubdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
handlers.push({
handler: "headers",
response: {
@@ -735,7 +735,7 @@ async function buildProxyRoutes(
});
}
if (row.ssl_forced) {
if (row.sslForced) {
for (const domainGroup of domainGroups) {
hostRoutes.push({
match: [
@@ -774,8 +774,8 @@ async function buildProxyRoutes(
});
}
if (row.access_list_id) {
const accounts = accessAccounts.get(row.access_list_id) ?? [];
if (row.accessListId) {
const accounts = accessAccounts.get(row.accessListId) ?? [];
if (accounts.length > 0) {
handlers.push({
handler: "authentication",
@@ -783,7 +783,7 @@ async function buildProxyRoutes(
http_basic: {
accounts: accounts.map((entry) => ({
username: entry.username,
password: entry.password_hash
password: entry.passwordHash
}))
}
}
@@ -856,7 +856,7 @@ async function buildProxyRoutes(
};
}
if (row.preserve_host_header) {
if (row.preserveHostHeader) {
reverseProxyHandler.headers = {
request: {
set: {
@@ -868,7 +868,7 @@ async function buildProxyRoutes(
// Configure TLS transport for HTTPS upstreams
if (resolvedUpstreams.hasHttpsUpstream) {
const tlsTransport: Record<string, unknown> = row.skip_https_hostname_validation
const tlsTransport: Record<string, unknown> = row.skipHttpsHostnameValidation
? {
insecure_skip_verify: true
}
@@ -1068,8 +1068,8 @@ async function buildProxyRoutes(
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
@@ -1104,8 +1104,8 @@ async function buildProxyRoutes(
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
@@ -1255,8 +1255,8 @@ async function buildProxyRoutes(
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
@@ -1286,8 +1286,8 @@ async function buildProxyRoutes(
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
@@ -1318,8 +1318,8 @@ async function buildProxyRoutes(
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
@@ -1410,7 +1410,7 @@ function buildTlsConnectionPolicies(
}
};
// Add policy for auto-managed domains (certificate_id = null)
// Add policy for auto-managed domains (certificateId = null)
if (autoManagedDomains.size > 0) {
const domains = Array.from(autoManagedDomains);
// Split first so mTLS domains always get their own policy, regardless of auth result.
@@ -1432,14 +1432,14 @@ function buildTlsConnectionPolicies(
}
if (entry.certificate.type === "imported") {
if (!entry.certificate.certificate_pem || !entry.certificate.private_key_pem) {
if (!entry.certificate.certificatePem || !entry.certificate.privateKeyPem) {
continue;
}
// Collect PEMs for tls.certificates.load_pem (inline, no shared filesystem needed)
importedCertPems.push({
certificate: entry.certificate.certificate_pem.trim(),
key: entry.certificate.private_key_pem.trim()
certificate: entry.certificate.certificatePem.trim(),
key: entry.certificate.privateKeyPem.trim()
});
const mTlsDomains = domains.filter(d => mTlsDomainMap.has(d));
@@ -1488,7 +1488,7 @@ async function buildTlsAutomation(
options: { acmeEmail?: string; dnsSettings?: DnsSettings | null }
) {
const managedEntries = Array.from(usage.values()).filter(
(entry) => entry.certificate.type === "managed" && Boolean(entry.certificate.auto_renew)
(entry) => entry.certificate.type === "managed" && Boolean(entry.certificate.autoRenew)
);
const hasAutoManagedDomains = autoManagedDomains.size > 0;
@@ -1517,7 +1517,7 @@ async function buildTlsAutomation(
const managedCertificateIds = new Set<number>();
const policies: Record<string, unknown>[] = [];
// Add policy for auto-managed domains (certificate_id = null)
// Add policy for auto-managed domains (certificateId = null)
if (hasAutoManagedDomains) {
for (const subjects of groupHostPatternsByPriority(Array.from(autoManagedDomains))) {
const issuer: Record<string, unknown> = {
@@ -1894,14 +1894,14 @@ async function buildCaddyDocument() {
name: h.name,
domains: h.domains,
upstreams: h.upstreams,
certificate_id: h.certificateId,
access_list_id: h.accessListId,
ssl_forced: h.sslForced ? 1 : 0,
hsts_enabled: h.hstsEnabled ? 1 : 0,
hsts_subdomains: h.hstsSubdomains ? 1 : 0,
allow_websocket: h.allowWebsocket ? 1 : 0,
preserve_host_header: h.preserveHostHeader ? 1 : 0,
skip_https_hostname_validation: h.skipHttpsHostnameValidation ? 1 : 0,
certificateId: h.certificateId,
accessListId: h.accessListId,
sslForced: h.sslForced ? 1 : 0,
hstsEnabled: h.hstsEnabled ? 1 : 0,
hstsSubdomains: h.hstsSubdomains ? 1 : 0,
allowWebsocket: h.allowWebsocket ? 1 : 0,
preserveHostHeader: h.preserveHostHeader ? 1 : 0,
skipHttpsHostnameValidation: h.skipHttpsHostnameValidation ? 1 : 0,
meta: h.meta,
enabled: h.enabled ? 1 : 0
}));
@@ -1910,17 +1910,17 @@ async function buildCaddyDocument() {
id: c.id,
name: c.name,
type: c.type as "managed" | "imported",
domain_names: c.domainNames,
certificate_pem: c.certificatePem,
private_key_pem: c.privateKeyPem,
auto_renew: c.autoRenew ? 1 : 0,
provider_options: c.providerOptions
domainNames: c.domainNames,
certificatePem: c.certificatePem,
privateKeyPem: c.privateKeyPem,
autoRenew: c.autoRenew ? 1 : 0,
providerOptions: c.providerOptions
}));
const accessListEntryRows: AccessListEntryRow[] = accessListEntryRecords.map((entry) => ({
access_list_id: entry.accessListId,
accessListId: entry.accessListId,
username: entry.username,
password_hash: entry.passwordHash
passwordHash: entry.passwordHash
}));
const certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert]));
@@ -1933,10 +1933,10 @@ async function buildCaddyDocument() {
}, new Map());
const cAsWithAnyIssuedCerts = new Set(allIssuedCaCertIds.map(r => r.caCertificateId));
const accessMap = accessListEntryRows.reduce<Map<number, AccessListEntryRow[]>>((map, entry) => {
if (!map.has(entry.access_list_id)) {
map.set(entry.access_list_id, []);
if (!map.has(entry.accessListId)) {
map.set(entry.accessListId, []);
}
map.get(entry.access_list_id)!.push(entry);
map.get(entry.accessListId)!.push(entry);
return map;
}, new Map());
@@ -2187,7 +2187,7 @@ export async function applyCaddyConfig() {
const document = await buildCaddyDocument();
const payload = JSON.stringify(document);
const hash = crypto.createHash("sha256").update(payload).digest("hex");
setSetting("caddy_config_hash", { hash, updated_at: nowIso() });
setSetting("caddy_config_hash", { hash, updatedAt: nowIso() });
try {
const response = await caddyRequest(`${config.caddyApiUrl}/load`, "POST", payload);