import "server-only"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { nextCookies } from "better-auth/next-js"; import { admin, captcha, emailOTP } from "better-auth/plugins"; import { headers } from "next/headers"; import { cache } from "react"; import { getDb } from "@/db"; import { accounts, sessions, users, verifications, } from "@/db/schema"; import { isAuthorizedAdmin, type SessionUserLike } from "./authorization"; import { HttpError } from "@/lib/security/http"; import { consumeRateLimit } from "@/lib/security/rate-limit"; import { sendAuthEmail } from "./email"; function required(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is required for authentication.`); return value; } function hasAuthConfiguration(): boolean { return ["DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET"].every( (name) => Boolean(process.env[name]), ); } function createAuth() { return betterAuth({ appName: "Buzz Guide", database: drizzleAdapter(getDb(), { provider: "pg", schema: { user: users, session: sessions, account: accounts, verification: verifications, }, transaction: true, }), baseURL: required("BETTER_AUTH_URL"), trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS ?.split(",") .map((origin) => origin.trim()) .filter(Boolean), secret: required("BETTER_AUTH_SECRET"), emailAndPassword: { enabled: true, disableSignUp: false, requireEmailVerification: true, minPasswordLength: 6, maxPasswordLength: 128, sendResetPassword: async ({ user, url }) => { await sendAuthEmail(user.email, "รีเซ็ตรหัสผ่าน Buzz Guide", `เปิดลิงก์นี้เพื่อรีเซ็ตรหัสผ่าน: ${url}\n\nลิงก์นี้หมดอายุใน 1 ชั่วโมง`); }, }, user: { changeEmail: { enabled: true, sendChangeEmailConfirmation: async ({ user, newEmail, url }) => { await sendAuthEmail(user.email, "ยืนยันการเปลี่ยนอีเมล Buzz Guide", `เปิดลิงก์นี้เพื่ออนุมัติการเปลี่ยนอีเมลเป็น ${newEmail}: ${url}\n\nลิงก์นี้หมดอายุใน 1 ชั่วโมง`); }, }, }, emailVerification: { sendOnSignUp: false, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { await sendAuthEmail(user.email, "ยืนยันอีเมล Buzz Guide", `เปิดลิงก์นี้เพื่อยืนยันอีเมล: ${url}\n\nลิงก์นี้หมดอายุใน 1 ชั่วโมง`); }, }, advanced: { ipAddress: { ipAddressHeaders: process.env.TRUSTED_CLIENT_IP_HEADER ? [process.env.TRUSTED_CLIENT_IP_HEADER] : [], }, }, rateLimit: { enabled: true, customStorage: { consume: (key, rule) => consumeRateLimit(`auth:${key}`, rule) }, customRules: { "/sign-in/email": { window: 60, max: 10 }, "/sign-up/email": { window: 3600, max: 5 }, "/send-verification-email": { window: 3600, max: 5 }, "/change-email": { window: 3600, max: 5 }, "/request-password-reset": { window: 3600, max: 5 }, }, }, plugins: [ emailOTP({ sendVerificationOnSignUp: true, storeOTP: "hashed", sendVerificationOTP: async ({ email, otp }) => { await sendAuthEmail(email, "รหัสยืนยัน Buzz Guide", `รหัสยืนยันอีเมลของคุณคือ ${otp}\n\nรหัสนี้หมดอายุใน 5 นาที`); }, }), ...(process.env.NODE_ENV === "development" ? [] : [ captcha({ provider: "cloudflare-turnstile", secretKey: required("TURNSTILE_SECRET_KEY"), endpoints: ["/sign-in/email", "/sign-up/email"], }), ]), admin({ defaultRole: "user" }), nextCookies(), ], }); } type AuthInstance = ReturnType; let authInstance: AuthInstance | undefined; export function getAuth(): AuthInstance { if (!authInstance) authInstance = createAuth(); return authInstance; } export interface AdminSession { user: SessionUserLike; session: { id: string }; } export const getAdminSession = cache(async (): Promise => { // Public pages can be prerendered without the runtime auth secret. In that // case the header simply omits the admin link; auth routes still fail loudly // through getAuth() when authentication is actually used. if (!hasAuthConfiguration()) return null; const session = await getAuth().api.getSession({ headers: await headers() }); if ( !session?.session || !isAuthorizedAdmin(session.user) ) { return null; } return { user: session.user, session: { id: session.session.id }, }; }); export class AdminAuthorizationError extends HttpError { constructor() { super(401, "unauthorized"); this.name = "AdminAuthorizationError"; } } export async function requireAdmin(): Promise { const session = await getAdminSession(); if (!session) throw new AdminAuthorizationError(); return session; } export const getCustomerSession = cache(async () => { if (!hasAuthConfiguration()) return null; const session = await getAuth().api.getSession({ headers: await headers() }); if (!session?.session) return null; return { user: session.user, session: { id: session.session.id } }; });