Files

128 lines
4.4 KiB
TypeScript
Raw Permalink Normal View History

import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { nextCookies } from "better-auth/next-js"
import { admin } from "better-auth/plugins"
import { db } from "@/lib/db"
import { user, session, account, verification, profiles, consent_log } from "@/lib/db/schema"
import { sendEmail, resetPasswordHtml, verifyEmailHtml } from "@/lib/email/send"
import { LEGAL } from "@/lib/legal"
// Bootstrap superadmins from env — no API path lets a user self-promote.
const ADMIN_USER_IDS = (process.env.ADMIN_USER_IDS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL,
secret: process.env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, {
provider: "pg",
schema: { user, session, account, verification },
}),
emailAndPassword: {
enabled: true,
2026-07-01 13:56:34 -04:00
// Env-gated so production can require a verified email without breaking
// local dev (where SMTP is typically unconfigured). Set
2026-07-01 13:56:34 -04:00
// REQUIRE_EMAIL_VERIFICATION=true in production to enforce.
requireEmailVerification: process.env.REQUIRE_EMAIL_VERIFICATION === "true",
minPasswordLength: 8,
sendResetPassword: async ({ user: u, url }) => {
await sendEmail({
to: u.email,
subject: "Reset your Property Management Network password",
html: resetPasswordHtml(url),
})
},
},
2026-07-01 13:56:34 -04:00
// Send a verification email on sign-up. Enforcement of verified-email login
// is gated by REQUIRE_EMAIL_VERIFICATION (see emailAndPassword above).
emailVerification: {
sendOnSignUp: true,
// After the user clicks the verification link, sign them in and send them
// to the callbackURL (set to /dashboard on sign-up).
autoSignInAfterVerification: true,
2026-07-01 13:56:34 -04:00
sendVerificationEmail: async ({ user: u, url }) => {
await sendEmail({
to: u.email,
subject: "Verify your email — Property Management Network",
html: verifyEmailHtml(url),
})
},
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
},
},
// Throttle auth endpoints (per IP) to slow brute-force / credential stuffing.
rateLimit: {
enabled: true,
window: 60, // seconds
max: 20, // requests per window per IP for auth endpoints
2026-07-01 13:56:34 -04:00
customRules: {
// Tighter limit on the password sign-in endpoint to slow credential
// stuffing / brute-force attempts.
"/sign-in/email": { window: 60, max: 10 },
},
},
// Auto-create the app `profiles` row whenever Better Auth creates a user
// (replaces the old `handle_new_user` Postgres trigger).
databaseHooks: {
user: {
create: {
after: async (u) => {
try {
await db
.insert(profiles)
.values({ id: u.id, email: u.email, full_name: u.name ?? null })
.onConflictDoNothing()
} catch {
// Never block sign-up on profile creation.
}
// GDPR proof of acceptance: the signup form states that creating an
// account means agreeing to the Terms and Privacy Policy.
try {
await db.insert(consent_log).values([
{
user_id: u.id,
email: u.email,
kind: "terms" as const,
granted: true,
policy_version: LEGAL.lastUpdated,
source: "signup",
},
{
user_id: u.id,
email: u.email,
kind: "privacy" as const,
granted: true,
policy_version: LEGAL.lastUpdated,
source: "signup",
},
])
} catch {
// Never block sign-up on consent logging.
}
},
},
},
},
// `admin` enables role/ban/impersonation; `nextCookies` MUST stay last.
plugins: [
admin({ adminUserIds: ADMIN_USER_IDS }),
nextCookies(),
],
})
/**
* Whether Google OAuth is configured. The auth pages hide the "Continue with
* Google" button unless BOTH credentials are present, so users never see a
* social option that can't complete. Mirrors socialProviders.google above.
*/
export function isGoogleConfigured(): boolean {
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET)
}