import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { admin, captcha, organization } from "better-auth/plugins"; import { nextCookies } from "better-auth/next-js"; import { createAuthMiddleware, APIError } from "better-auth/api"; import { prisma } from "@/lib/db"; import { sendEmail, emailLayout } from "@/lib/email"; /** * Enforce the `signups_enabled` kill-switch on the REAL registration path. * * The sign-up page also checks this flag, but that only hides the form — a * client can still POST /api/auth/sign-up/email directly. This is the actual * boundary. `@/lib/flags` is imported dynamically because it pulls in * `server-only`, which throws when lib/* is loaded outside Next's RSC bundler * (the worker runs under plain tsx); a lazy import keeps that cost off the * module graph until an actual sign-up is attempted. */ async function assertSignupsEnabled(): Promise { const { isFlagEnabled } = await import("@/lib/flags"); if (!(await isFlagEnabled("signups_enabled"))) { throw new APIError("FORBIDDEN", { message: "Sign-ups are currently paused. Please check back soon.", }); } } const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; const googleConfigured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); // Fail fast in production if the signing secret is missing, too short, or a known // placeholder — sessions/cookies are only secure when BETTER_AUTH_SECRET is a strong, // non-default value. Stay frictionless in dev/test. const KNOWN_WEAK_SECRETS = new Set([ "dev-secret-please-change-0123456789abcdef", ]); const authSecret = process.env.BETTER_AUTH_SECRET; const secretIsWeak = !authSecret || authSecret.length < 32 || KNOWN_WEAK_SECRETS.has(authSecret); if (secretIsWeak && process.env.NODE_ENV === "production") { throw new Error( "BETTER_AUTH_SECRET must be set in production to a strong value (>= 32 chars, not a known placeholder)." ); } /** * Guard impersonation — the single most powerful action in the platform. * * Two problems with leaving this to the better-auth admin plugin alone: * 1. It is the ONLY admin mutation with no entry in our audit log, so a * compromised admin account can read every user's data untraceably. * 2. Nothing stops one admin impersonating another, which is lateral * movement between privileged accounts rather than support access. * * Enforcing it here (rather than in the server action) means a direct POST to * /api/auth/admin/impersonate-user is covered too. */ async function guardImpersonation(headers: Headers, targetUserId: unknown): Promise { if (typeof targetUserId !== "string" || !targetUserId) { throw new APIError("BAD_REQUEST", { message: "A target user id is required." }); } // `auth` is referenced lazily: this runs per-request, long after module init. const session = await auth.api.getSession({ headers }); if (!session || session.user.role !== "admin") { throw new APIError("FORBIDDEN", { message: "Not allowed." }); } const target = await prisma.user.findUnique({ where: { id: targetUserId }, select: { id: true, role: true, email: true }, }); if (!target) throw new APIError("NOT_FOUND", { message: "User not found." }); if (target.role === "admin") { throw new APIError("FORBIDDEN", { message: "Admins cannot impersonate other admins.", }); } await prisma.auditLog.create({ data: { actorId: session.user.id, actorType: "admin", action: "user.impersonate", target: target.id, metadata: { targetEmail: target.email }, }, }); } /** * Cloudflare Turnstile — bot protection on the public auth endpoints. * * The plugin verifies the `x-captcha-response` header server-side against * Cloudflare's siteverify API BEFORE the handler runs, so it is the real * boundary: hiding/!rendering the widget client-side proves nothing, and a * bot POSTing straight to /api/auth/sign-in/email is rejected here. * * Enabled whenever TURNSTILE_SECRET_KEY is set. Required in production so a * misconfigured deploy cannot silently ship with bot protection switched off * (same fail-fast posture as BETTER_AUTH_SECRET above); left optional in * dev/test so local work stays frictionless without Cloudflare keys. */ const turnstileSecretKey = process.env.TURNSTILE_SECRET_KEY?.trim(); const turnstileEnabled = !!turnstileSecretKey; if (!turnstileEnabled && process.env.NODE_ENV === "production") { throw new Error( "TURNSTILE_SECRET_KEY must be set in production — Turnstile guards the sign-in, sign-up and password-reset endpoints." ); } // Guard the asymmetric misconfiguration: with a secret but no site key the server // would demand a captcha token that no form can produce, locking every user out // of sign-in. Fail the boot (and the production build) instead of shipping that. if ( turnstileEnabled && !process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim() && process.env.NODE_ENV === "production" ) { throw new Error( "NEXT_PUBLIC_TURNSTILE_SITE_KEY must be set alongside TURNSTILE_SECRET_KEY — without it the auth forms cannot render the Turnstile widget and every sign-in would be rejected." ); } export const auth = betterAuth({ appName: "Podcast Distribution AI", secret: process.env.BETTER_AUTH_SECRET, baseURL: process.env.BETTER_AUTH_URL ?? appUrl, database: prismaAdapter(prisma, { provider: "postgresql" }), // Built-in brute-force protection for auth endpoints (login, password reset, etc). // 30 requests per 60s window per IP. rateLimit: { enabled: true, window: 60, max: 30 }, emailAndPassword: { enabled: true, // Sign-in does NOT require a verified email. This is deliberate: verification // mail only sends when RESEND_API_KEY is configured, and without it sendEmail() // silently no-ops — gating sign-in on verification would lock out every new // account. Verification mail is still sent when email is configured (see // emailVerification.sendOnSignUp below); it just isn't a barrier to logging in. // Turn this back on once transactional email is live and proven. requireEmailVerification: false, minPasswordLength: 8, async sendResetPassword({ user, url }) { await sendEmail({ to: user.email, subject: "Reset your Podcast Distribution AI password", html: emailLayout( "Reset your password", "Click the button below to choose a new password.", { label: "Reset password", url } ), text: `Reset your password: ${url}`, }); }, }, emailVerification: { sendOnSignUp: true, async sendVerificationEmail({ user, url }) { await sendEmail({ to: user.email, subject: "Verify your email for Podcast Distribution AI", html: emailLayout( "Confirm your email", "Confirm your email address to secure your account.", { label: "Verify email", url } ), text: `Verify your email: ${url}`, }); }, }, socialProviders: googleConfigured ? { google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }, } : undefined, session: { expiresIn: 60 * 60 * 24 * 30, // 30 days updateAge: 60 * 60 * 24, // refresh daily // Cache the session in a signed cookie to avoid a DB hit on every request. // Tradeoff: a banned/demoted user keeps cached access until the cache expires, // so we keep the window short (60s). Shorter = faster revocation but more DB hits. cookieCache: { enabled: true, maxAge: 60 }, }, account: { accountLinking: { enabled: true, trustedProviders: ["google"] }, }, hooks: { before: createAuthMiddleware(async (ctx) => { if (ctx.path === "/sign-up/email") await assertSignupsEnabled(); if (ctx.path === "/admin/impersonate-user") { await guardImpersonation(ctx.headers ?? new Headers(), (ctx.body as { userId?: unknown } | undefined)?.userId); } }), }, // Catch-all: a user row is only ever created by registration, so this also // covers first-time Google sign-in, which does not hit /sign-up/email. // (scripts/create-admin.ts and scripts/seed-demo.ts write via Prisma directly // and are intentionally unaffected.) databaseHooks: { user: { create: { before: async (user) => { await assertSignupsEnabled(); return { data: user }; }, }, }, }, plugins: [ admin({ defaultRole: "user", adminRoles: ["admin"] }), organization({ teams: { enabled: true, maximumTeams: 1 }, // Agency seat cap is enforced in app logic against the subscription's seat count. membershipLimit: 5, }), // Bot protection. Endpoints listed explicitly rather than relying on the // plugin default so adding one is a deliberate, reviewable change. // `/reset-password` is intentionally absent: it is reached only with a // single-use token emailed to a verified address, and challenging it would // break the emailed-link flow. ...(turnstileEnabled ? [ captcha({ provider: "cloudflare-turnstile", secretKey: turnstileSecretKey!, endpoints: ["/sign-in/email", "/sign-up/email", "/request-password-reset"], }), ] : []), // Must remain last: lets Server Actions / route handlers set auth cookies. nextCookies(), ], }); export type Session = typeof auth.$Infer.Session;