import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { admin, organization } from "better-auth/plugins"; import { nextCookies } from "better-auth/next-js"; import { prisma } from "@/lib/db"; import { sendEmail, emailLayout } from "@/lib/email"; 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)." ); } 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, // SECURITY GATE: unverified emails CANNOT sign in. Verification emails are sent // on signup (see emailVerification.sendOnSignUp below), so users can verify before // their first login. requireEmailVerification: true, 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"] }, }, 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, }), // Must remain last: lets Server Actions / route handlers set auth cookies. nextCookies(), ], }); export type Session = typeof auth.$Infer.Session;