import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { organization } from "better-auth/plugins"; import { username } from "better-auth/plugins/username"; import { eq } from "drizzle-orm"; import { db } from "./db/index.js"; import * as authSchema from "./db/schema/auth.js"; import { env } from "./env.js"; import { ac, roles } from "./lib/access.js"; import { sendEmail } from "./lib/email.js"; import { isAllowedOrigin } from "./lib/origins.js"; const WEEK = 60 * 60 * 24 * 7; const DAY = 60 * 60 * 24; export const auth = betterAuth({ appName: "temetro", baseURL: env.BETTER_AUTH_URL, secret: env.BETTER_AUTH_SECRET, // Trust the configured frontend origin plus localhost/LAN hosts so staff can // sign in over the network (mirrors CORS; see src/lib/origins.ts). Reflecting // the request's own origin (when allowed) keeps Better Auth's CSRF check happy // without a per-deployment rebuild. trustedOrigins: (request) => { const origins = [env.FRONTEND_URL]; const origin = request?.headers.get("origin"); if (origin && isAllowedOrigin(origin) && !origins.includes(origin)) { origins.push(origin); } return origins; }, database: drizzleAdapter(db, { provider: "pg", schema: authSchema, }), emailAndPassword: { enabled: true, // TODO(verification): Email verification is intentionally NOT enforced for // now so users can sign in immediately. Verification emails are still sent // (sendOnSignUp below) and the /verify-email flow still works — flip this // back to `true` to make verification mandatory before sign-in. requireEmailVerification: false, minPasswordLength: 12, maxPasswordLength: 256, revokeSessionsOnPasswordReset: true, sendResetPassword: async ({ user, url }) => { // With a provider configured, email the reset link. Otherwise fall back to // alerting the clinic admin(s) so they can set a new password (dynamic // imports keep the Better Auth CLI's static graph minimal at generate time). const { isEmailConfigured } = await import("./services/email-config.js"); if (await isEmailConfigured()) { await sendEmail({ to: user.email, subject: "Reset your temetro password", text: `Reset your password by opening this link:\n\n${url}\n\nIf you didn't request this, you can ignore this email.`, }); return; } const { notifyAdminsPasswordReset } = await import( "./services/auth-fallback.js" ); await notifyAdminsPasswordReset({ id: user.id, name: user.name, email: user.email, }); }, }, user: { // Lets a signed-in user delete their own account from Settings. The // frontend confirms with the user's password (authClient.deleteUser). deleteUser: { enabled: true, }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { await sendEmail({ to: user.email, subject: "Verify your email for temetro", text: `Welcome to temetro. Verify your email address by opening this link:\n\n${url}`, }); }, }, plugins: [ // Lets staff sign in with a username (in addition to email). Admin-created // staff accounts (see src/routes/staff.ts) set a username + password the // employee uses to log in. Adds `username` + `displayUsername` to `user`. username({ minUsernameLength: 3, maxUsernameLength: 32, }), organization({ ac, roles, creatorRole: "owner", // Verification isn't enforced right now (see emailAndPassword above), so // any signed-in user may create a clinic. Re-tie this to // `user.emailVerified === true` when verification is re-enabled. allowUserToCreateOrganization: true, membershipLimit: 200, invitationExpiresIn: WEEK, sendInvitationEmail: async (data) => { const url = `${env.FRONTEND_URL}/accept-invite?id=${data.invitation.id}`; await sendEmail({ to: data.email, subject: `${data.inviter.user.name} invited you to join ${data.organization.name} on temetro`, text: `${data.inviter.user.name} invited you to join the clinic "${data.organization.name}".\n\nAccept the invitation:\n\n${url}`, }); }, }), ], rateLimit: { enabled: true, storage: "database", customRules: { "/sign-in/email": { window: 60, max: 5 }, "/sign-up/email": { window: 60, max: 3 }, "/request-password-reset": { window: 60, max: 3 }, }, }, session: { expiresIn: WEEK, updateAge: DAY, cookieCache: { enabled: true, maxAge: 300 }, }, advanced: { // Secure cookies only when actually served over HTTPS — otherwise the // browser silently drops them over http://localhost and login "does // nothing". This is correct regardless of NODE_ENV. useSecureCookies: env.BETTER_AUTH_URL.startsWith("https://"), defaultCookieAttributes: { sameSite: "lax" }, ipAddress: { ipAddressHeaders: ["x-forwarded-for", "x-real-ip"] }, }, databaseHooks: { session: { create: { before: async (session) => { // Default the active organization to the user's first clinic so // returning members skip onboarding on sign-in. const rows = await db .select({ organizationId: authSchema.member.organizationId }) .from(authSchema.member) .where(eq(authSchema.member.userId, session.userId)) .limit(1); const organizationId = rows[0]?.organizationId; if (organizationId) { return { data: { ...session, activeOrganizationId: organizationId } }; } return undefined; }, after: async (session) => { // Lightweight audit trail; swap console for a real sink in prod. console.info( `[audit] session.created user=${session.userId} ip=${session.ipAddress ?? "-"}`, ); }, }, }, }, }); export type Auth = typeof auth;