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, genericOAuth } 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]), ); } export function isSudlohOidcEnabled(): boolean { if (process.env.SUDLOH_OIDC_PAUSED === "true") return false; const clientId = process.env.SUDLOH_OIDC_CLIENT_ID; const clientSecret = process.env.SUDLOH_OIDC_CLIENT_SECRET; if (!clientId && !clientSecret) return false; if (!clientId || !clientSecret || !process.env.SUDLOH_OIDC_REDIRECT_URI) { throw new Error("Sudloh OIDC requires a client ID, client secret, and exact redirect URI."); } return true; } function createAuth() { const oidcEnabled = isSudlohOidcEnabled(); const oidcOnly = oidcEnabled && process.env.SUDLOH_OIDC_ONLY === "true"; if (oidcEnabled) { const callback = `${required("BETTER_AUTH_URL").replace(/\/$/, "")}/api/auth/callback/sudloh`; if (process.env.SUDLOH_OIDC_REDIRECT_URI !== callback) { throw new Error(`SUDLOH_OIDC_REDIRECT_URI must be ${callback}`); } } 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: !oidcOnly, disableSignUp: oidcOnly, 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 ชั่วโมง`); }, }, }, account: { accountLinking: { disableImplicitLinking: true } }, 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: [ ...(oidcEnabled ? [genericOAuth({ config: [{ providerId: "sudloh", clientId: process.env.SUDLOH_OIDC_CLIENT_ID!, clientSecret: process.env.SUDLOH_OIDC_CLIENT_SECRET!, redirectURI: process.env.SUDLOH_OIDC_REDIRECT_URI!, discoveryUrl: `${(process.env.SUDLOH_OIDC_ISSUER || "https://account.sudloh.com/api/auth").replace(/\/$/, "")}/.well-known/openid-configuration`, scopes: ["openid", "profile", "email"], authentication: "basic", requireIdTokenVerification: true, requireEmailVerification: true, disableProviderLogout: true, overrideUserInfo: true, }] })] : []), ...(!oidcOnly ? [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 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 } }; });