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, requireEmailVerification: false, autoSignIn: true, minPasswordLength: 6, maxPasswordLength: 128, resetPasswordTokenExpiresIn: 3600, sendResetPassword: async ({ user, url }) => { await sendAuthEmail(user.email, "ตั้งรหัสผ่านใหม่สำหรับบัญชี Buzz Guide", [ `เราได้รับคำขอตั้งรหัสผ่านใหม่สำหรับบัญชีที่ใช้อีเมล ${user.email}`, `เปิดลิงก์นี้เพื่อตั้งรหัสผ่านใหม่:\n${url}`, "ลิงก์นี้หมดอายุใน 1 ชั่วโมง หากหมดอายุ ให้ขอลิงก์ใหม่จากหน้า “ลืมรหัสผ่าน”", "รหัสผ่านของคุณจะเปลี่ยนเมื่อคุณตั้งรหัสผ่านใหม่และกดยืนยันบนเว็บไซต์เท่านั้น", "อย่าแชร์หรือส่งต่อลิงก์นี้ให้ผู้อื่น ทีมงาน Buzz Guide จะไม่ขอรหัสผ่านของคุณ", "หากคุณไม่ได้ขอเปลี่ยนรหัสผ่าน ไม่ต้องเปิดลิงก์นี้ รหัสผ่านเดิมของคุณจะยังใช้งานได้", "อีเมลนี้ส่งอัตโนมัติจาก no-reply@sudloh.com กรุณาอย่าตอบกลับ", ].join("\n\n")); }, }, user: { changeEmail: { enabled: true, sendChangeEmailConfirmation: async ({ user, newEmail, url }) => { await sendAuthEmail(user.email, "อนุมัติคำขอเปลี่ยนอีเมลบัญชี Buzz Guide", [ "เราได้รับคำขอเปลี่ยนอีเมลสำหรับบัญชี Buzz Guide ของคุณ", `อีเมลปัจจุบัน: ${user.email}\nอีเมลใหม่: ${newEmail}`, `หากคุณเป็นผู้ขอเปลี่ยน เปิดลิงก์นี้เพื่ออนุมัติ:\n${url}`, `หลังอนุมัติ เราจะส่งลิงก์ยืนยันไปที่ ${newEmail} คุณต้องเปิดลิงก์ในอีเมลใหม่เพื่อเปลี่ยนอีเมลให้เสร็จสมบูรณ์`, "ลิงก์อนุมัตินี้หมดอายุใน 1 ชั่วโมง หากหมดอายุ ให้ขอเปลี่ยนอีเมลอีกครั้งจากหน้าโปรไฟล์", "อย่าแชร์หรือส่งต่อลิงก์นี้ให้ผู้อื่น", "หากคุณไม่ได้ขอเปลี่ยนอีเมล อย่าเปิดลิงก์นี้ และตรวจสอบความปลอดภัยของบัญชีจากหน้าโปรไฟล์", "อีเมลนี้ส่งอัตโนมัติจาก no-reply@sudloh.com กรุณาอย่าตอบกลับ", ].join("\n\n")); }, }, }, emailVerification: { sendOnSignUp: false, autoSignInAfterVerification: true, expiresIn: 3600, sendVerificationEmail: async ({ user, url }) => { await sendAuthEmail(user.email, "ยืนยันอีเมลสำหรับบัญชี Buzz Guide", [ `ยืนยันว่า ${user.email} เป็นอีเมลที่คุณต้องการใช้กับบัญชี Buzz Guide`, `เปิดลิงก์นี้เพื่อยืนยันอีเมล:\n${url}`, "หากคุณกำลังเปลี่ยนอีเมลบัญชี การเปิดลิงก์นี้จะยืนยันอีเมลใหม่และทำให้การเปลี่ยนอีเมลเสร็จสมบูรณ์", "ลิงก์นี้หมดอายุใน 1 ชั่วโมง หากหมดอายุ ให้ขอยืนยันอีเมลอีกครั้งบนเว็บไซต์", "อย่าแชร์หรือส่งต่อลิงก์นี้ให้ผู้อื่น", "หากคุณไม่ได้สมัครบัญชีหรือขอเปลี่ยนอีเมล อย่าเปิดลิงก์นี้ และไม่ต้องดำเนินการใด ๆ", "อีเมลนี้ส่งอัตโนมัติจาก no-reply@sudloh.com กรุณาอย่าตอบกลับ", ].join("\n\n")); }, }, 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", expiresIn: 300, sendVerificationOTP: async ({ email, otp }) => { await sendAuthEmail(email, "รหัส OTP สำหรับยืนยันอีเมล Buzz Guide", [ "ใช้รหัสนี้เพื่อยืนยันอีเมลของคุณใน Buzz Guide", `รหัส OTP: ${otp}`, "รหัสนี้หมดอายุใน 5 นาที หากหมดอายุ ให้กดส่งรหัสใหม่บนหน้ายืนยันอีเมล", "อย่าแชร์รหัสนี้กับผู้อื่น ทีมงาน Buzz Guide จะไม่ขอรหัส OTP ของคุณ", "หากคุณไม่ได้ขอรหัสนี้ ไม่ต้องดำเนินการใด ๆ และอย่าส่งรหัสให้ใคร", "อีเมลนี้ส่งอัตโนมัติจาก no-reply@sudloh.com กรุณาอย่าตอบกลับ", ].join("\n\n")); }, }), ...(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 getVerifiedSession = cache(async () => { if (!hasAuthConfiguration()) return null; const session = await getAuth().api.getSession({ headers: await headers() }); if (!session?.session) return null; return session; }); 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 getVerifiedSession(); 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 () => { const session = await getVerifiedSession(); if (!session?.session) return null; return { user: session.user, session: { id: session.session.id } }; });