import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { magicLink, admin } from 'better-auth/plugins'; import { APIError, createAuthMiddleware } from 'better-auth/api'; import { eq, and } from 'drizzle-orm'; import { db, dbGet, dbAll, isPostgres } from '../db/index.js'; import { authUsers, authSessions, authAccounts, authVerifications, authRateLimits, } from '../db/auth-schema.js'; import { generateId } from './utils.js'; import { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js'; import { sendEmail } from './email.js'; import { getLoginLockout } from './stores/loginLockout.js'; const isProduction = process.env.NODE_ENV === 'production'; const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002'; // Cookie domain shared across subdomains (e.g. ".spanglishcommunity.com") so a session // issued by api.* is also sent to the site origin, where the frontend's Next middleware // reads it to gate /admin and /dashboard. Leave unset in dev: localhost is single-host // and needs a host-only cookie. const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim(); const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret'; const rawSecret = process.env.BETTER_AUTH_SECRET; // Never allow a weak/default secret in production: the secret signs session // cookies, so a guessable value means forgeable sessions = account takeover. if (isProduction && (!rawSecret || rawSecret.length < 32 || rawSecret === DEFAULT_DEV_SECRET)) { throw new Error( 'BETTER_AUTH_SECRET must be set to a strong value (32+ characters) in production. Refusing to start.' ); } if (!rawSecret) { console.warn('[auth] BETTER_AUTH_SECRET is not set; using an insecure development default.'); } // The site origin plus its www/non-www alias, mirroring the CORS allowlist in // index.ts. Requests whose Origin is not listed here are rejected by Better // Auth's CSRF origin check. function computeTrustedOrigins(): string[] { const origins = new Set([frontendUrl]); try { const url = new URL(frontendUrl); const alias = url.hostname.startsWith('www.') ? url.hostname.slice(4) : `www.${url.hostname}`; origins.add(`${url.protocol}//${alias}${url.port ? `:${url.port}` : ''}`); } catch { /* keep frontendUrl as-is */ } if (process.env.API_URL) origins.add(process.env.API_URL); if (!isProduction) { // Dev is frequently reached through a forwarded or proxied port (SSH tunnel, editor // port forwarding), so the browser's Origin is http://localhost: and every // POST would fail the CSRF origin check. Trust any loopback port rather than pinning // FRONTEND_URL to a port that changes between sessions. Wildcard patterns are matched // per better-auth's trusted-origins helper; production stays on the exact allowlist. origins.add('http://localhost:*'); origins.add('http://127.0.0.1:*'); } return [...origins]; } // Paths whose request body carries a new password that must satisfy the policy // (Better Auth's minPasswordLength alone is weaker than the app's policy). const PASSWORD_SETTING_PATHS = new Set([ '/sign-up/email', '/reset-password', '/change-password', '/set-password', ]); async function getCredentialAccount(userId: string): Promise { return dbGet( (db as any) .select() .from(authAccounts) .where( and( eq((authAccounts as any).userId, userId), eq((authAccounts as any).providerId, 'credential') ) ) ); } export const auth = betterAuth({ appName: 'Spanglish', baseURL: process.env.BETTER_AUTH_URL || frontendUrl, basePath: '/api/auth', secret: rawSecret || DEFAULT_DEV_SECRET, trustedOrigins: computeTrustedOrigins(), telemetry: { enabled: false }, database: drizzleAdapter(db as any, { provider: isPostgres() ? 'pg' : 'sqlite', schema: { user: authUsers, session: authSessions, account: authAccounts, verification: authVerifications, rateLimit: authRateLimits, }, // better-sqlite3 cannot run Drizzle's async transactions; operations run // sequentially instead (also the adapter default). transaction: false, }), advanced: { cookiePrefix: 'spanglish', useSecureCookies: isProduction, // Only adds a `Domain=` attribute — name, sameSite, secure and path are unchanged, // so the cookie names hardcoded in the frontend middleware and the Go photo-api // stay valid. ...(cookieDomain ? { crossSubDomainCookies: { enabled: true, domain: cookieDomain } } : {}), ipAddress: { // Set by the /api/auth/* mount in index.ts from getClientIp(), which // anchors trust in the TCP peer address (only our own proxies may speak // for the client via X-Real-IP / X-Forwarded-For). Never read the raw // forwarded headers here: without the socket they are spoofable, and // Better Auth would fall back to one shared rate-limit bucket. ipAddressHeaders: ['x-client-ip'], }, database: { // Match the app's existing ID convention (uuid on pg, nanoid on sqlite) // so Better Auth rows are indistinguishable from legacy rows. generateId: () => generateId(), }, }, user: { additionalFields: { // `role`, `banned`, `banReason`, `banExpires` come from the admin plugin. phone: { type: 'string', required: false, input: true }, languagePreference: { type: 'string', required: false, input: true }, rucNumber: { type: 'string', required: false, input: false }, isClaimed: { type: 'boolean', required: false, input: false, defaultValue: true }, accountStatus: { type: 'string', required: false, input: false, defaultValue: 'active' }, }, }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days, rolling updateAge: 60 * 60 * 24, // refresh expiry at most once a day freshAge: 60 * 60 * 24, // sensitive operations require a session younger than this // Disabled deliberately: every request validates against the session table, // so ban/suspend/password-reset revocations apply instantly — and the Go // photo-api (which reads the same table) can never disagree with us. cookieCache: { enabled: false }, storeSessionInDatabase: true, }, emailAndPassword: { enabled: true, minPasswordLength: 10, maxPasswordLength: 128, autoSignIn: true, requireEmailVerification: false, revokeSessionsOnPasswordReset: true, resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes, matches the legacy flow sendResetPassword: async ({ user, url }) => { try { await sendEmail({ to: user.email, subject: 'Reset Your Spanglish Password', html: `

Reset Your Password

Click the link below to reset your password. This link expires in 30 minutes.

Reset Password

Or copy this link: ${url}

If you didn't request this, you can safely ignore this email.

`, }); } catch (error) { console.error('Failed to send password reset email:', error); } }, password: { // Keep the existing argon2id parameters; legacy bcrypt hashes (migrated // into auth_accounts) still verify and are upgraded on login below. hash: (password) => hashPassword(password), verify: ({ hash, password }) => verifyPassword(password, hash), }, }, ...(process.env.GOOGLE_CLIENT_ID ? { socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID, // Not required for ID-token (Google Identity Services) sign-in, // only for the redirect OAuth flow. clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', }, }, } : {}), account: { accountLinking: { enabled: true, // Google verifies email ownership, so linking by email is safe — this // matches the legacy /api/auth/google auto-link behavior. trustedProviders: ['google'], }, }, rateLimit: { // Explicitly enabled so dev behaves like production (off in dev by default). enabled: true, window: 60, max: 100, // DB-backed rather than Redis: our Redis layer is fail-open by design, // which is the wrong default for auth rate limiting. The per-email login // lockout below is already Redis-shared across replicas. storage: 'database', modelName: 'rateLimit', customRules: { '/sign-in/email': { window: 900, max: 10 }, '/sign-up/email': { window: 900, max: 10 }, '/sign-in/magic-link': { window: 900, max: 5 }, '/magic-link/verify': { window: 900, max: 30 }, '/request-password-reset': { window: 900, max: 5 }, '/reset-password': { window: 900, max: 10 }, '/sign-in/social': { window: 900, max: 20 }, '/change-password': { window: 900, max: 10 }, }, }, databaseHooks: { user: { create: { before: async (user) => { // First user to register becomes admin (replaces isFirstUser()) const existing = await dbAll((db as any).select().from(authUsers).limit(1)); if (!existing || existing.length === 0) { return { data: { ...user, role: 'admin' } }; } }, }, }, }, hooks: { before: createAuthMiddleware(async (ctx) => { // Enforce the full password policy (character classes + blocklist) on // every password-setting path, for both client and server-side calls. if (PASSWORD_SETTING_PATHS.has(ctx.path)) { const password = ctx.body?.password ?? ctx.body?.newPassword; if (typeof password === 'string') { const result = validatePassword(password); if (!result.valid) { throw new APIError('BAD_REQUEST', { message: result.error }); } } } // Per-email lockout: 5 failures / 15 min, Redis-shared when configured. // Kept as defense-in-depth on top of Better Auth's per-IP rate limits. if (ctx.path === '/sign-in/email' && typeof ctx.body?.email === 'string') { const lockout = await getLoginLockout().isLocked(ctx.body.email); if (lockout.locked) { throw new APIError('TOO_MANY_REQUESTS', { message: 'Too many login attempts. Please try again later.', }); } } }), after: createAuthMiddleware(async (ctx) => { if (ctx.path !== '/sign-in/email' || typeof ctx.body?.email !== 'string') return; const email = ctx.body.email as string; if (ctx.context.returned instanceof APIError) { // Failed sign-in attempt counts toward the per-email lockout await getLoginLockout().recordFailure(email); return; } await getLoginLockout().clear(email); // Transparently upgrade legacy bcrypt hashes to argon2 now that we have // the verified plaintext. Best-effort: never block the login. try { const password = ctx.body?.password; const userId = (ctx.context.newSession?.user as any)?.id; if (typeof password === 'string' && userId) { const account = await getCredentialAccount(userId); if (account?.password && !String(account.password).startsWith('$argon2')) { const upgraded = await hashPassword(password); await (db as any) .update(authAccounts) .set({ password: upgraded, updatedAt: new Date() }) .where(eq((authAccounts as any).id, account.id)); } } } catch (err: any) { console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err); } }), }, plugins: [ magicLink({ expiresIn: 60 * 10, // 10 minutes, matches the legacy flow // Magic links never create accounts (parity with the legacy behavior; // account creation is register / Google / guest booking only). disableSignUp: true, // Hashed at rest: a leaked verification table cannot be replayed. storeToken: 'hashed', sendMagicLink: async ({ email, url, token }) => { // Email a frontend URL (not the raw API verify URL) so the login // completes on the site, preserving the legacy UX. The page calls // authClient.magicLink.verify with the token. let callbackURL = '/'; try { callbackURL = new URL(url).searchParams.get('callbackURL') || '/'; } catch { /* default */ } const link = `${frontendUrl}/auth/magic-link?token=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callbackURL)}`; const isClaim = callbackURL.startsWith('/auth/claim-account'); try { await sendEmail({ to: email, subject: isClaim ? 'Claim Your Spanglish Account' : 'Your Spanglish Login Link', html: isClaim ? `

Claim Your Account

An account was created for you during booking. Click below to set up your login credentials. This link expires in 10 minutes.

Claim Account

Or copy this link: ${link}

If you didn't request this, you can safely ignore this email.

` : `

Login to Spanglish

Click the link below to log in. This link expires in 10 minutes.

Log In

Or copy this link: ${link}

If you didn't request this, you can safely ignore this email.

`, }); } catch (error) { console.error('Failed to send magic link email:', error); } }, }), admin({ defaultRole: 'user', adminRoles: ['admin'], bannedUserMessage: 'Account is suspended. Please contact support.', }), ], }); export type Auth = typeof auth;