import { betterAuth, BetterAuthOptions } from "better-auth"; import { prismaAdapter } from 'better-auth/adapters/prisma' import { username, admin, organization, captcha, twoFactor, jwt } from "better-auth/plugins"; import { env } from "@/lib/env"; import { ac, roles } from "@/lib/auth/permissions"; import { stripe } from "@better-auth/stripe"; import Stripe from "stripe"; import { createOrGetCustomer, getOrCreateStripeCustomer, getPlanNameFromPriceId } from "./stripe-helpers"; import { queryClient } from "./react-query/query-client"; import { cache } from "./redis/client"; import { emailTemplates } from "./email/templates/templates"; import { sendEmail } from "./email/client"; import { APIError, createAuthMiddleware } from "better-auth/api"; import { getActiveOrganizationId } from "@/hooks/auth/organizations/useOrganizations"; import { db } from "./prisma"; import { sendOrganizationInvitation } from "./email/send-organization-invitation"; import slugify from "slugify"; interface WebhookEvent { id: string; object: string; api_version: string; created: number; data: { object: Stripe.Subscription; previous_attributes?: Partial; }; livemode: boolean; pending_webhooks: number; request: { id: string | null; idempotency_key: string; }; type: string; } interface DatabaseSubscription { id: string; plan: string; referenceId: string; stripeCustomerId: string; stripeSubscriptionId: string; status: string; periodStart: Date | null; periodEnd: Date | null; cancelAtPeriodEnd: boolean; seats: number; } interface SubscriptionUpdateData { plan: string; status: Stripe.Subscription.Status; stripeSubscriptionId: string; cancelAtPeriodEnd: boolean; billingCycleAnchor?: Date | null; periodStart?: Date; periodEnd?: Date; trialStart?: Date; trialEnd?: Date; metadata: { planGroup: 'monthly' | 'quarterly' | 'annual'; priceId: string; lastUpdated: string; eventType: string; webhookEventId: string; stripeCustomerId: string; }; updatedAt: Date; } export type ExtendedBetterAuthOptions = BetterAuthOptions & { authRedirect?: string; }; function safeParseDate(value: string | Date | null | undefined): Date | null { //[...] } const ALL_PLANS = [ //[...] ]; function calculatePeriodEnd(anchor: Date, interval: Stripe.Plan.Interval, intervalCount: number): Date { //[...] } async function syncSubscriptionState(subscriptionId: string) { //[...] } export const stripeClient = new Stripe(env.STRIPE_SECRET_KEY!, { apiVersion: "2025-07-30.basil", }); export const auth = betterAuth({ baseURL: env.BETTER_AUTH_URL, basePath: "/api/auth", secret: env.BETTER_AUTH_SECRET, trustedOrigins: [ "http://localhost:3000" ], allowedOrigins: ["https://localhost:3000"], database: prismaAdapter(db, { provider: 'postgresql', }), socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID!, clientSecret: env.GOOGLE_CLIENT_SECRET!, accessType: "offline", prompt: "select_account+consent", }, apple: { clientId: env.APPLE_CLIENT_ID!, clientSecret: env.APPLE_CLIENT_SECRET! }, discord: { clientId: env.DISCORD_CLIENT_ID!, clientSecret: env.DISCORD_CLIENT_SECRET! } }, user: { additionalFields: { role: { type: "string", required: false, defaultValue: "member", input: false, }, }, deleteUser: { enabled: true, beforeDelete: async (user, request) => { if (user.email?.includes("admin")) { throw new APIError("BAD_REQUEST", { message: "admin" }); } }, afterDelete: async (user, request) => { console.log(`User ${user.id} deleted.`); }, }, }, session: { expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24, cookieCache: { enabled: true, maxAge: 5 * 60, }, freshAge: 60 * 60, }, account: { accountLinking: { enabled: true, trustedProviders: ["discord"], allowDifferentEmails: false, updateUserInfoOnLink: true, allowUnlinkingAll: false, }, }, rateLimit: { enabled: env.NODE_ENV === "production" ? true : true, storage: "database", window: 60, max: 100, customRules: { "/sign-in/email": { window: 10, max: 3, }, "/sign-up/email": { window: 10, max: 5, }, "/api-key/verify": { window: 5, max: 5, }, }, }, advanced: { ipAddress: { ipAddressHeaders: ["x-forwarded-for", "cf-connecting-ip", 'x-real-ip', "x-client-ip"], disableIpTracking: false, }, useSecureCookies: process.env.NODE_ENV === "production", disableCSRFCheck: false, crossSubDomainCookies: { enabled: false, }, cookiePrefix: "holy-auth", database: { generateId: () => crypto.randomUUID(), }, }, databaseHooks: { session: { create: { before: async (session) => { const activeOrganizationId = await getActiveOrganizationId(session.userId); return { data: { ...session, activeOrganizationId } }; }, }, }, }, onAPIError: { throw: true, onError: (error, ctx) => { console.error("API Authentication Error:", error.message, error.status, ctx.path); }, errorURL: "/auth/error", }, plugins: [ username({ usernameValidator: (username) => { if (username === "admin") { return false } }, minUsernameLength: 5, maxUsernameLength: 100 }), admin({ ac, roles, defaultRole: "reader", adminRoles: ["admin"], impersonationSessionDuration: 60 * 60 * 2, defaultBanReason: "Violation of Terms of Use", defaultBanExpiresIn: 60 * 60 * 24 * 7, bannedUserMessage: "banned", }), stripe({ stripeClient, stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET!, createCustomerOnSignUp: true, getCustomer: async (referenceId) => { if (!referenceId) return null; const customers = await stripeClient.customers.search({ query: `metadata['referenceId']:'${referenceId}'`, limit: 1, }); if (customers.data.length > 0) { return customers.data[0].id; } let name: string | undefined; let email: string | undefined; try { const org = await db.organization.findUnique({ where: { id: referenceId }, include: { members: { include: { user: true }, where: { role: 'leader' } } } }); if (org) { name = org.name; email = org.email || org.members[0]?.user.email; } else { const user = await db.user.findUnique({ where: { id: referenceId } }); if (user) { name = user.name ?? undefined; email = user.email ?? undefined; } } } catch (e) { console.warn("error", e); } const newCustomer = await stripeClient.customers.create({ name, email, metadata: { referenceId: referenceId, }, }); return newCustomer.id; }, onCustomerCreate: async ({ customer, stripeCustomer, user }) => { await cache.delete(`${user.id}`); queryClient.invalidateQueries({ queryKey: ["auth-session"] }); }, getCustomerCreateParams: async ({ user, session }, request) => { return { name: user.name, email: user.email, metadata: { userId: user.id, signupDate: new Date().toISOString(), source: 'site_signup' } }; }, subscription: { enabled: true, requireEmailVerification: true, plans: ALL_PLANS, authorizeReference: async ({ user, referenceId }) => { if (!user) { return false; } if (!referenceId) { return false; } if (referenceId === user.id) { return true; } try { const member = await db.member.findFirst({ where: { organizationId: referenceId, userId: user.id, }, }); if (!member) { return false; } const isAuthorized = member.role === "leader"; return isAuthorized; } catch (error) { return false; } }, onSubscriptionComplete: async ({ subscription, event, plan }) => { const session = event.data.object as Stripe.Checkout.Session; if (!session.subscription) { return; } const stripeSubscription = await stripeClient.subscriptions.retrieve(session.subscription as string); if (plan.group === 'organization') { await db.organization.update({ where: { id: subscription.referenceId }, data: { subscriptionPlan: plan.name, subscriptionStatus: stripeSubscription.status, }, }); } }, onSubscriptionCancel: async ({ event, subscription, stripeSubscription, cancellationDetails }) => { const plan = ALL_PLANS.find(p => p.name === subscription.plan); if (!plan) { return; } if (plan.group === 'organization') { await db.organization.update({ where: { id: subscription.referenceId }, data: { subscriptionStatus: stripeSubscription.status, }, }); } }, onSubscriptionUpdate: async ({ subscription, event }) => { const stripeSubscription = event.data.object as Stripe.Subscription; const priceId = stripeSubscription.items.data[0]?.price.id; const plan = ALL_PLANS.find(p => p.priceId === priceId); if (!plan) { return; } if (plan.group === 'organization') { await db.organization.update({ where: { id: subscription.referenceId }, data: { subscriptionPlan: plan.name, subscriptionStatus: stripeSubscription.status, }, }); } }, getCheckoutSessionParams: async ({ user, plan, referenceId }) => { if (!referenceId) { referenceId = user.id; } const customerId = await getOrCreateStripeCustomer(referenceId); return { params: { customer: customerId, allow_promotion_codes: true, metadata: { userId: user.id, referenceId: referenceId, planName: plan.name, }, }, }; }, }, onEvent: async (event) => { let subscriptionId: string | null = null; switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session; if (session.mode === 'subscription' && session.subscription) { subscriptionId = session.subscription as string; } break; } case 'customer.subscription.created': case 'customer.subscription.updated': case 'customer.subscription.deleted': case 'customer.subscription.resumed': { const subscription = event.data.object as Stripe.Subscription; subscriptionId = subscription.id; break; } } if (subscriptionId) { await syncSubscriptionState(subscriptionId); } }, }), organization({ schema: { organization: { additionalFields: { email: { type: "string", input: true, required: true } } } }, creatorRole: "leader", membershipLimit: 100, async sendInvitationEmail(data) { const inviteLink = `${process.env.NEXT_PUBLIC_APP_URL}/invitations/${data.id}`; await sendOrganizationInvitation({ email: data.email, inviterName: data.inviter.user.name || 'Team Member', inviterEmail: data.inviter.user.email, organizationName: data.organization.name, organizationLogo: data.organization.logo || undefined, inviteLink, role: data.role || 'member', }); }, invitationExpiresIn: 7 * 24 * 60 * 60, cancelPendingInvitationsOnReInvite: true, invitationLimit: 50, ac, roles, teams: { enabled: true, maximumMembersPerTeam: 50, allowRemovingAllTeams: false }, organizationDeletion: { beforeDelete: async ({ organization }) => { await db.organizationProfile.deleteMany({ where: { organizationId: organization.id, }, }); }, // afterDelete: async ({ organization }) => { // } } }), twoFactor({ issuer: "holy", skipVerificationOnEnable: false, totpOptions: { digits: 6, period: 30, }, otpOptions: { period: 3, async sendOTP({ user, otp }, request) { const { twoFactorCode } = emailTemplates; const template = twoFactorCode(user.name || 'Usuário', otp); await sendEmail({ to: user.email, subject: template.subject, html: template.html, }); }, }, backupCodeOptions: { amount: 10, length: 10, }, }), captcha({ provider: "cloudflare-turnstile", secretKey: env.TURNSTILE_SECRET_KEY!, endpoints: [ "/sign-up/email", "/sign-in/email", "/forget-password", ], ...(env.CAPTCHA_PROVIDER === 'hcaptcha' && { siteKey: env.HCAPTCHA_SITE_KEY, }), ...(env.CAPTCHA_PROVIDER === 'google-recaptcha' && { minScore: 0.5, }), }), jwt({ jwt: { expirationTime: "1h", issuer: process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000", audience: process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000", definePayload: ({ user, session }) => { return { id: user.id, email: user.email, name: user.name, username: user.username, image: user.image, role: user.role, sessionId: session.id }; } }, jwks: { keyPairConfig: { alg: "EdDSA", crv: "Ed25519" } } }), ], onCreateSession: async ({ session, request }) => { if (request) { const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip") ?? (request as any).socket?.remoteAddress ?? "unknown"; await db.session.update({ where: { id: session.id }, data: { ipAddress: ip }, }); } }, telemetry: { enabled: false }, debug: env.NODE_ENV === 'development', } as BetterAuthOptions); export const toClientAuthOptions = (authOptions: BetterAuthOptions) => { const options: BetterAuthOptions = JSON.parse(JSON.stringify(authOptions)); const removeSecrets = (obj: any) => { if (!obj || typeof obj !== "object") return; for (const key of Object.keys(obj)) { if (key.toLowerCase().includes("secret")) { delete obj[key]; } else if (typeof obj[key] === "object") { removeSecrets(obj[key]); } } }; removeSecrets(options); return options; }; export type Auth = typeof auth;