import { betterAuth } from "better-auth"; import { genericOAuth, twoFactor } from "better-auth/plugins"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db, users, sessions, accounts, verifications, twoFactors, eq, count } from "@epicure/db"; import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email"; import { isSignupsDisabled } from "@/lib/site-settings"; import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites"; import { gravatarUrl } from "@/lib/gravatar"; import { generateUniqueUsername } from "@/lib/username"; export const auth = betterAuth({ trustedOrigins: [process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"], // Explicit rather than relying on the isProduction default so dev/staging // are protected too. Sign-in/sign-up/change-password/change-email get a // strict built-in 3-req/10s-per-IP rule (better-auth's default special // rules) — everything else on /api/auth falls back to 100/10s. rateLimit: { enabled: true, // A 6-digit TOTP/backup code has far fewer combinations than a password — // the generic 100-req/10s default is too loose to meaningfully slow down // guessing it. customRules: { "/two-factor/verify-totp": { window: 60, max: 5 }, "/two-factor/verify-otp": { window: 60, max: 5 }, "/two-factor/verify-backup-code": { window: 60, max: 5 }, }, }, database: drizzleAdapter(db, { provider: "pg", schema: { user: users, session: sessions, account: accounts, verification: verifications, twoFactor: twoFactors }, }), emailAndPassword: { enabled: true, requireEmailVerification: true, sendResetPassword: async ({ user, url }) => { await sendEmail({ to: user.email, subject: "Reset your Epicure password", html: resetPasswordHtml(url), }); }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { await sendEmail({ to: user.email, subject: "Verify your Epicure email", html: verifyEmailHtml(url), }); }, }, socialProviders: { google: { clientId: process.env["GOOGLE_CLIENT_ID"] ?? "", clientSecret: process.env["GOOGLE_CLIENT_SECRET"] ?? "", }, ...(process.env["GITHUB_CLIENT_ID"] && { github: { clientId: process.env["GITHUB_CLIENT_ID"], clientSecret: process.env["GITHUB_CLIENT_SECRET"] ?? "", }, }), ...(process.env["DISCORD_CLIENT_ID"] && { discord: { clientId: process.env["DISCORD_CLIENT_ID"], clientSecret: process.env["DISCORD_CLIENT_SECRET"] ?? "", }, }), }, plugins: [ // allowPasswordless: OAuth-only accounts (no credential/password account) // would otherwise never be able to enable or manage 2FA, since the // endpoint requires a password to confirm identity by default. twoFactor({ issuer: "Epicure", allowPasswordless: true }), ...(process.env["AUTHENTIK_CLIENT_ID"] && process.env["AUTHENTIK_BASE_URL"] ? [ genericOAuth({ config: [ { providerId: "authentik", clientId: process.env["AUTHENTIK_CLIENT_ID"], clientSecret: process.env["AUTHENTIK_CLIENT_SECRET"] ?? "", // Authentik OIDC discovery URL: https:///application/o// discoveryUrl: `${process.env["AUTHENTIK_BASE_URL"]}/.well-known/openid-configuration`, scopes: ["openid", "email", "profile"], }, ], }), ] : []), ], session: { cookieCache: { enabled: true, maxAge: 60 * 5, }, }, databaseHooks: { user: { create: { before: async (user, context) => { // No signup form or settings page ever lets someone set a username, // yet profiles, follows, and people-search all key on it — so every // account needs one generated here, or those features silently see // nobody. OAuth signups may already have one (mapped from the // provider profile); email/password never does. const existingUsername = (user as { username?: string | null }).username; const username = existingUsername || (await generateUniqueUsername(user.name || user.email)); if (!(await isSignupsDisabled())) return { data: { ...user, username } }; const token = context?.getCookie(INVITE_COOKIE); const invite = token ? await findValidInvite(token, user.email) : null; if (!invite) return false; return { data: { ...user, username, role: invite.role, tier: invite.tier } }; }, after: async (user, context) => { // First registered user becomes admin const result = await db.select({ total: count() }).from(users); if ((result[0]?.total ?? 0) === 1) { await db.update(users).set({ role: "admin" }).where(eq(users.id, user.id)); } // Only email/password signups land here without an avatar already // set (OAuth providers set `image` — mapped to avatarUrl — before // this hook runs) — give them a Gravatar-backed default. if (!user.image) { await db.update(users).set({ avatarUrl: gravatarUrl(user.email) }).where(eq(users.id, user.id)); } // Consume the invite that gated this signup, if any (regardless of // whether signups have since been re-enabled/disabled). const token = context?.getCookie(INVITE_COOKIE); const invite = token ? await findValidInvite(token, user.email) : null; if (invite) await consumeInvite(invite.id, user.id); // Welcome email (fire and forget) sendEmail({ to: user.email, subject: "Welcome to Epicure", html: welcomeHtml(user.name), }).catch(() => {}); }, }, }, }, user: { fields: { image: "avatarUrl", }, additionalFields: { role: { type: "string", defaultValue: "user", input: false, }, tier: { type: "string", defaultValue: "free", input: false, }, username: { type: "string", required: false, }, bio: { type: "string", required: false, }, unitPref: { type: "string", defaultValue: "metric", }, locale: { type: "string", defaultValue: "en", }, }, changeEmail: { enabled: true, sendChangeEmailVerification: async ({ newEmail, url }: { newEmail: string; url: string }) => { await sendEmail({ to: newEmail, subject: "Verify your new Epicure email", html: verifyEmailHtml(url), }); }, }, }, }); export type Session = typeof auth.$Infer.Session; export type User = typeof auth.$Infer.Session.user;