Files
caddy-proxy-manager/app/(dashboard)/certificates/ca-actions.ts
fuomag9 3a16d6e9b1 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>
2026-04-12 21:11:48 +02:00

189 lines
7.1 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/src/lib/auth";
import { createCaCertificate, deleteCaCertificate, updateCaCertificate, getCaCertificatePrivateKey } from "@/src/lib/models/ca-certificates";
import { createIssuedClientCertificate, revokeIssuedClientCertificate } from "@/src/lib/models/issued-client-certificates";
import { X509Certificate } from "node:crypto";
import forge from "node-forge";
function validatePem(pem: string): void {
try {
new X509Certificate(pem);
} catch {
throw new Error("Invalid certificate PEM: could not parse as X.509 certificate");
}
}
export async function createCaCertificateAction(formData: FormData) {
const session = await requireAdmin();
const userId = Number(session.user.id);
const name = String(formData.get("name") ?? "").trim();
const certificatePem = String(formData.get("certificate_pem") ?? "").trim();
if (!name) throw new Error("Name is required");
if (!certificatePem) throw new Error("Certificate PEM is required");
validatePem(certificatePem);
await createCaCertificate({ name, certificatePem: certificatePem }, userId);
revalidatePath("/certificates");
}
export async function updateCaCertificateAction(id: number, formData: FormData) {
const session = await requireAdmin();
const userId = Number(session.user.id);
const name = formData.get("name") ? String(formData.get("name")).trim() : undefined;
const certificatePem = formData.get("certificate_pem") ? String(formData.get("certificate_pem")).trim() : undefined;
if (certificatePem) {
validatePem(certificatePem);
}
await updateCaCertificate(id, {
...(name ? { name } : {}),
...(certificatePem ? { certificatePem: certificatePem } : {})
}, userId);
revalidatePath("/certificates");
}
export async function deleteCaCertificateAction(id: number): Promise<{ success: boolean; error?: string }> {
const session = await requireAdmin();
const userId = Number(session.user.id);
try {
await deleteCaCertificate(id, userId);
revalidatePath("/certificates");
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete CA certificate" };
}
}
export async function generateCaCertificateAction(formData: FormData): Promise<{ id: number }> {
const session = await requireAdmin();
const userId = Number(session.user.id);
const name = String(formData.get("name") ?? "").trim();
const commonName = String(formData.get("common_name") ?? name).trim() || name;
const validityDays = Math.min(3650, Math.max(1, parseInt(String(formData.get("validity_days") ?? "3650"), 10) || 3650));
if (!name) throw new Error("Name is required");
const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096 });
const cert = forge.pki.createCertificate();
cert.publicKey = keypair.publicKey;
cert.serialNumber = "01";
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + validityDays);
const attrs = [
{ name: "commonName", value: commonName },
{ name: "organizationName", value: "Caddy Proxy Manager" },
];
cert.setSubject(attrs);
cert.setIssuer(attrs);
cert.setExtensions([
{ name: "basicConstraints", cA: true, critical: true },
{ name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true },
{ name: "subjectKeyIdentifier" },
]);
cert.sign(keypair.privateKey, forge.md.sha256.create());
const certificatePem = forge.pki.certificateToPem(cert);
const privateKeyPem = forge.pki.privateKeyToPem(keypair.privateKey);
const record = await createCaCertificate({ name, certificatePem: certificatePem, privateKeyPem: privateKeyPem }, userId);
revalidatePath("/certificates");
return { id: record.id };
}
export type IssuedClientCert = {
pkcs12Base64: string;
passwordProtected: boolean;
exportAlgorithm: "3des" | "aes256";
};
export async function issueClientCertificateAction(
caCertId: number,
formData: FormData
): Promise<IssuedClientCert> {
const session = await requireAdmin();
const userId = Number(session.user.id);
const commonName = String(formData.get("common_name") ?? "").trim();
const validityDays = Math.min(3650, Math.max(1, parseInt(String(formData.get("validity_days") ?? "365"), 10) || 365));
const exportPassword = String(formData.get("export_password") ?? "");
const compatibilityMode = formData.get("compatibility_mode") === "on";
const exportAlgorithm: IssuedClientCert["exportAlgorithm"] = compatibilityMode ? "3des" : "aes256";
if (!commonName) throw new Error("Common name is required");
if (!exportPassword) throw new Error("Export password is required");
const caPrivateKeyPem = await getCaCertificatePrivateKey(caCertId);
if (!caPrivateKeyPem) throw new Error("This CA has no stored private key — cannot issue client certificates");
const caCertRecord = await import("@/src/lib/models/ca-certificates").then(m => m.getCaCertificate(caCertId));
if (!caCertRecord) throw new Error("CA certificate not found");
const caKey = forge.pki.privateKeyFromPem(caPrivateKeyPem);
const caCert = forge.pki.certificateFromPem(caCertRecord.certificatePem);
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
const cert = forge.pki.createCertificate();
cert.publicKey = keypair.publicKey;
cert.serialNumber = Date.now().toString(16);
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + validityDays);
cert.setSubject([{ name: "commonName", value: commonName }]);
cert.setIssuer(caCert.subject.attributes);
cert.setExtensions([
{ name: "basicConstraints", cA: false },
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
{ name: "extKeyUsage", clientAuth: true },
]);
cert.sign(caKey, forge.md.sha256.create());
const certificatePem = forge.pki.certificateToPem(cert);
const certificate = new X509Certificate(certificatePem);
await createIssuedClientCertificate(
{
caCertificateId: caCertId,
commonName: commonName,
serialNumber: cert.serialNumber.toUpperCase(),
fingerprintSha256: certificate.fingerprint256,
certificatePem: certificatePem,
validFrom: new Date(certificate.validFrom).toISOString(),
validTo: new Date(certificate.validTo).toISOString()
},
userId
);
revalidatePath("/certificates");
const pkcs12Asn1 = forge.pkcs12.toPkcs12Asn1(
keypair.privateKey,
[cert, caCert],
exportPassword,
{
algorithm: exportAlgorithm,
friendlyName: commonName,
}
);
const pkcs12Der = forge.asn1.toDer(pkcs12Asn1).getBytes();
return {
pkcs12Base64: forge.util.encode64(pkcs12Der),
passwordProtected: true,
exportAlgorithm,
};
}
export async function revokeIssuedClientCertificateAction(id: number): Promise<{ revokedAt: string }> {
const session = await requireAdmin();
const userId = Number(session.user.id);
const record = await revokeIssuedClientCertificate(id, userId);
revalidatePath("/certificates");
return { revokedAt: record.revokedAt! };
}