import { createHmac } from "node:crypto"; import type { Pool } from "pg"; import type { BetterAuthOptions } from "better-auth"; import { admin, emailOTP, twoFactor, type EmailOTPOptions } from "better-auth/plugins"; import type { SelfHostedIdentityConfig } from "./config.ts"; import type { EmailOtpSender } from "./contracts.ts"; import { identityModelMapping } from "./model.ts"; export type AdminUserAuthorizer = (userId: string) => Promise; interface BuildAuthOptionsInput { config: SelfHostedIdentityConfig; database: Pool; emailSender: EmailOtpSender; } function otpIdempotencyKey( secret: string, email: string, otp: string, type: string, ): string { const digest = createHmac("sha256", secret) .update(type) .update("\0") .update(email.trim().toLowerCase()) .update("\0") .update(otp) .digest("hex"); return `otp-${digest}`; } export function createEmailOtpOptions( sender: EmailOtpSender, secret: string, disableSignUp: boolean, testOtp: string | null = null, ): EmailOTPOptions { return { otpLength: 6, expiresIn: 300, allowedAttempts: 3, resendStrategy: "rotate", storeOTP: "hashed", disableSignUp, rateLimit: { window: 60, max: 3 }, ...(testOtp ? { generateOTP: () => testOtp, async sendVerificationOTP({ email, otp, type }) { // Test channel: no real email is delivered; the fixed code is // surfaced by the login UI when IDENTITY_TEST_OTP is configured. await sender.send({ email, otp, type, idempotencyKey: otpIdempotencyKey(secret, email, otp, type), }); }, } : { async sendVerificationOTP({ email, otp, type }) { await sender.send({ email, otp, type, idempotencyKey: otpIdempotencyKey(secret, email, otp, type), }); }, }), }; } export function buildAuthOptions({ config, database, emailSender, }: BuildAuthOptionsInput): BetterAuthOptions { return { appName: "Jyotisha", baseURL: config.userOrigin, basePath: "/api/auth", secret: config.userSecret, database, trustedOrigins: [config.userOrigin, config.adminOrigin], telemetry: { enabled: false }, user: identityModelMapping.user, session: identityModelMapping.session, account: identityModelMapping.account, verification: identityModelMapping.verification, rateLimit: { storage: "database", window: 60, max: 30, ...identityModelMapping.rateLimit, }, advanced: { database: { generateId: "uuid" }, cookiePrefix: "jyotisha-user", defaultCookieAttributes: { secure: true, httpOnly: true, sameSite: "lax", path: "/", }, }, emailAndPassword: { enabled: true, disableSignUp: true, minPasswordLength: 8, maxPasswordLength: 128, revokeSessionsOnPasswordReset: true, }, plugins: [ emailOTP(createEmailOtpOptions(emailSender, config.userSecret, false, config.testOtp)), twoFactor({ issuer: "Jyotisha Admin", twoFactorTable: "two_factors", twoFactorCookieMaxAge: 300, accountLockout: { enabled: true, maxFailedAttempts: 5, durationSeconds: 900, }, schema: identityModelMapping.twoFactor, }), admin({ defaultRole: "user", adminRoles: ["admin"], schema: identityModelMapping.admin, }), ], }; }