import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { admin, username } from "better-auth/plugins"; import { emailOTP } from "better-auth/plugins"; import { expo } from "@better-auth/expo"; import { db } from "../db"; import * as schema from "../db/schema/auth"; import { sendOTPEmail } from "../services/notifications/email"; import { DEFAULT_ALLOWED_ORIGINS } from "../constants/app"; import { generateUniqueUsername } from "../utils/username"; export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "mysql", schema: schema, }), trustedOrigins: process.env.NODE_ENV === "production" ? [ ...DEFAULT_ALLOWED_ORIGINS, "lately://", "lately:///", "lately://*", "https://appleid.apple.com" ] : [ "*" ], logger: { disabled: false, level: "error", log: (level, message, ...args) => { console.log(`[${level}] ${message}`, ...args); } }, emailAndPassword: { enabled: true, autoSignInAfterVerification: true, // Enable auto sign-in after verification sendOnSignUp: true }, socialProviders: { google: { prompt: "select_account", clientId: process.env.GOOGLE_CLIENT_ID! as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET! as string, scope: ["openid", "email", "profile"], mapProfileToUser: async (profile) => { // Generate username from email or name const emailUsername = profile.email?.split('@')[0] || ''; const nameUsername = profile.name?.toLowerCase().replace(/\s+/g, '_') || ''; const fallbackUsername = profile.given_name?.toLowerCase() || 'google_user'; const baseUsername = emailUsername || nameUsername || fallbackUsername; const uniqueUsername = await generateUniqueUsername(baseUsername); return { id: profile.sub, name: profile.name || `${profile.given_name || ''} ${profile.family_name || ''}`.trim(), username: uniqueUsername, displayUsername: profile.name || profile.given_name, email: profile.email!, image: profile.picture, emailVerified: profile.email_verified || false, }; }, }, twitter: { clientId: process.env.TWITTER_CLIENT_ID! as string, clientSecret: process.env.TWITTER_CLIENT_SECRET! as string, scope: ["tweet.read", "users.read"], mapProfileToUser: async (profile) => { // Twitter provides username directly const emailUsername = typeof profile.email === 'string' ? profile.email.split('@')[0] : ''; const twitterUsername = typeof profile.username === 'string' ? profile.username : ''; const screenName = typeof profile.screen_name === 'string' ? profile.screen_name : ''; const baseUsername = twitterUsername || screenName || emailUsername || 'twitter_user'; const uniqueUsername = await generateUniqueUsername(baseUsername); return { id: (profile.id_str as string) || (profile.id as string), name: (profile.name as string) || (profile.display_name as string), username: uniqueUsername, displayUsername: (profile.name as string) || (profile.display_name as string), email: profile.email as string, image: (profile.profile_image_url as string) || (profile.profile_image_url_https as string), emailVerified: Boolean(profile.verified), }; }, }, apple: { clientId: process.env.APPLE_CLIENT_ID! as string, clientSecret: process.env.APPLE_CLIENT_SECRET! as string, appBundleIdentifier: process.env.APPLE_APP_BUNDLE_IDENTIFIER as string, scope: ["name", "email"], mapProfileToUser: async (profile) => { // Apple doesn't provide username, generate from email or name const emailUsername = profile.email?.split('@')[0] || ''; const nameUsername = profile.name?.toLowerCase().replace(/\s+/g, '_') || ''; const fallbackUsername = `apple_user_${profile.sub?.slice(-8)}`; const baseUsername = emailUsername || nameUsername || fallbackUsername; const uniqueUsername = await generateUniqueUsername(baseUsername); return { id: profile.sub, name: profile.name || 'Apple User', username: uniqueUsername, displayUsername: profile.name, email: profile.email!, image: null, // Apple doesn't provide profile images emailVerified: profile.email_verified || true, // Apple emails are typically verified }; }, }, facebook: { clientId: process.env.FACEBOOK_CLIENT_ID! as string, clientSecret: process.env.FACEBOOK_CLIENT_SECRET! as string, scope: ["email", "public_profile"], mapProfileToUser: async (profile) => { // Generate username from email or name const emailUsername = profile.email?.split('@')[0] || ''; const nameUsername = profile.name?.toLowerCase().replace(/\s+/g, '_') || ''; const fallbackUsername = `fb_${profile.id?.slice(-8)}`; const baseUsername = emailUsername || nameUsername || fallbackUsername; const uniqueUsername = await generateUniqueUsername(baseUsername); return { id: profile.id, name: profile.name, username: uniqueUsername, displayUsername: profile.name, email: profile.email!, image: profile.picture?.data?.url, emailVerified: true, // Facebook emails are typically verified }; }, }, microsoft: { clientId: process.env.MICROSOFT_CLIENT_ID! as string, clientSecret: process.env.MICROSOFT_CLIENT_SECRET! as string, prompt: "select_account", // Forces account selection scope: ["openid", "email", "profile"], mapProfileToUser: async (profile) => { // Generate username from email or name const emailUsername = profile.email?.split('@')[0] || ''; const nameUsername = profile.name?.toLowerCase().replace(/\s+/g, '_') || ''; const fallbackUsername = `ms_${profile.sub?.slice(-8)}`; const baseUsername = emailUsername || nameUsername || fallbackUsername; const uniqueUsername = await generateUniqueUsername(baseUsername); return { id: profile.sub, name: profile.name || 'Microsoft User', username: uniqueUsername, displayUsername: profile.name, email: profile.email!, image: null, // Microsoft Graph API doesn't provide profile images in basic scope emailVerified: profile.email_verified || true, // Microsoft emails are typically verified }; }, }, }, plugins: [ expo({ overrideOrigin: true, // Enable this for Expo API routes CORS issues }), username({ minUsernameLength: 3, maxUsernameLength: 30, usernameValidator: (username) => { // Prevent admin and superadmin usernames const forbiddenUsernames = ['admin', 'superadmin']; if (forbiddenUsernames.includes(username.toLowerCase())) { return false; } // Only allow letters, numbers, underscores, and dots const validUsernameRegex = /^[a-zA-Z0-9._]+$/; if (!validUsernameRegex.test(username)) { return false; } return true; }, }), admin({ defaultRole: "user", adminRoles: ["admin"], }), emailOTP({ async sendVerificationOTP({ email, otp, type }) { // Get user name if available const user = await db.query.user.findFirst({ where: (users, { eq }) => eq(users.email, email), }); await sendOTPEmail({ email, otp, type, userName: user?.name || 'User', }); }, otpLength: 6, expiresIn: 300, // 5 minutes sendVerificationOnSignUp: true, // Send OTP when user signs up disableSignUp: false, // Allow automatic signup with OTP }), ], // Ensure cookies work in cross-site setups (separate frontend & backend domains) advanced: { defaultCookieAttributes: { sameSite: "none", secure: true, }, useSecureCookies: true, cookiePrefix: "lately_auth", cookies: { state: { attributes: { secure: true, sameSite: "none" } } } }, });