UNPKG

better-auth

Version:

The most comprehensive authentication library for TypeScript.

1,558 lines (1,547 loc) • 49.7 kB
import { g as generateRandomString } from '../../shared/better-auth.B4Qoxdgc.mjs'; import * as z from 'zod/v4'; import { k as getSessionFromCtx, j as createAuthEndpoint, l as sessionMiddleware, B as BASE_ERROR_CODES, i as createAuthMiddleware } from '../../shared/better-auth.z3dsxLxE.mjs'; import { APIError } from 'better-call'; import { setSessionCookie, deleteSessionCookie } from '../../cookies/index.mjs'; import { m as mergeSchema } from '../../shared/better-auth.n2KFGwjY.mjs'; import '../../shared/better-auth.8zoxzg-F.mjs'; import '../../shared/better-auth.DBGfIDnh.mjs'; import 'defu'; import { symmetricEncrypt, symmetricDecrypt } from '../../crypto/index.mjs'; import { base64Url } from '@better-auth/utils/base64'; import { createHMAC } from '@better-auth/utils/hmac'; import '@better-auth/utils/binary'; import { createHash } from '@better-auth/utils/hash'; import { createOTP } from '@better-auth/utils/otp'; import { v as validatePassword } from '../../shared/better-auth.YwDQhoPc.mjs'; export { t as twoFactorClient } from '../../shared/better-auth.Ddw8bVyV.mjs'; import '@better-auth/utils/random'; import '../../shared/better-auth.CW6D9eSx.mjs'; import '@better-fetch/fetch'; import 'jose'; import '@noble/ciphers/chacha'; import '@noble/ciphers/utils'; import '@noble/ciphers/webcrypto'; import '@noble/hashes/scrypt'; import '@better-auth/utils'; import '@better-auth/utils/hex'; import '@noble/hashes/utils'; import '../../shared/better-auth.VTXNLFMT.mjs'; import '../../shared/better-auth.DdzSJf-n.mjs'; import '../../shared/better-auth.tB5eU6EY.mjs'; import 'jose/errors'; const TWO_FACTOR_ERROR_CODES = { OTP_NOT_ENABLED: "OTP not enabled", OTP_HAS_EXPIRED: "OTP has expired", TOTP_NOT_ENABLED: "TOTP not enabled", TWO_FACTOR_NOT_ENABLED: "Two factor isn't enabled", BACKUP_CODES_NOT_ENABLED: "Backup codes aren't enabled", INVALID_BACKUP_CODE: "Invalid backup code", INVALID_CODE: "Invalid code", TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: "Too many attempts. Please request a new code.", INVALID_TWO_FACTOR_COOKIE: "Invalid two factor cookie" }; const TWO_FACTOR_COOKIE_NAME = "two_factor"; const TRUST_DEVICE_COOKIE_NAME = "trust_device"; async function verifyTwoFactor(ctx) { const session = await getSessionFromCtx(ctx); if (!session) { const cookieName = ctx.context.createAuthCookie(TWO_FACTOR_COOKIE_NAME); const twoFactorCookie = await ctx.getSignedCookie( cookieName.name, ctx.context.secret ); if (!twoFactorCookie) { throw new APIError("UNAUTHORIZED", { message: TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE }); } const verificationToken = await ctx.context.internalAdapter.findVerificationValue(twoFactorCookie); if (!verificationToken) { throw new APIError("UNAUTHORIZED", { message: TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE }); } const user = await ctx.context.internalAdapter.findUserById( verificationToken.value ); if (!user) { throw new APIError("UNAUTHORIZED", { message: TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE }); } const dontRememberMe = await ctx.getSignedCookie( ctx.context.authCookies.dontRememberToken.name, ctx.context.secret ); return { valid: async (ctx2) => { const session2 = await ctx2.context.internalAdapter.createSession( verificationToken.value, ctx2, !!dontRememberMe ); if (!session2) { throw new APIError("INTERNAL_SERVER_ERROR", { message: "failed to create session" }); } await setSessionCookie(ctx2, { session: session2, user }); if (ctx2.body.trustDevice) { const trustDeviceCookie = ctx2.context.createAuthCookie( TRUST_DEVICE_COOKIE_NAME, { maxAge: 30 * 24 * 60 * 60 // 30 days, it'll be refreshed on sign in requests } ); const token = await createHMAC("SHA-256", "base64urlnopad").sign( ctx2.context.secret, `${user.id}!${session2.token}` ); await ctx2.setSignedCookie( trustDeviceCookie.name, `${token}!${session2.token}`, ctx2.context.secret, trustDeviceCookie.attributes ); ctx2.setCookie(ctx2.context.authCookies.dontRememberToken.name, "", { maxAge: 0 }); ctx2.setCookie(cookieName.name, "", { maxAge: 0 }); } return ctx2.json({ token: session2.token, user: { id: user.id, email: user.email, emailVerified: user.emailVerified, name: user.name, image: user.image, createdAt: user.createdAt, updatedAt: user.updatedAt } }); }, invalid: async (errorKey) => { throw new APIError("UNAUTHORIZED", { message: TWO_FACTOR_ERROR_CODES[errorKey] }); }, session: { session: null, user }, key: twoFactorCookie }; } return { valid: async (ctx2) => { return ctx2.json({ token: session.session.token, user: { id: session.user.id, email: session.user.email, emailVerified: session.user.emailVerified, name: session.user.name, image: session.user.image, createdAt: session.user.createdAt, updatedAt: session.user.updatedAt } }); }, invalid: async () => { throw new APIError("UNAUTHORIZED", { message: TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE }); }, session, key: `${session.user.id}!${session.session.id}` }; } function generateBackupCodesFn(options) { return Array.from({ length: options?.amount ?? 10 }).fill(null).map(() => generateRandomString(options?.length ?? 10, "a-z", "0-9", "A-Z")).map((code) => `${code.slice(0, 5)}-${code.slice(5)}`); } async function generateBackupCodes(secret, options) { const key = secret; const backupCodes = options?.customBackupCodesGenerate ? options.customBackupCodesGenerate() : generateBackupCodesFn(options); const encCodes = await symmetricEncrypt({ data: JSON.stringify(backupCodes), key }); return { backupCodes, encryptedBackupCodes: encCodes }; } async function verifyBackupCode(data, key) { const codes = await getBackupCodes(data.backupCodes, key); if (!codes) { return { status: false, updated: null }; } return { status: codes.includes(data.code), updated: codes.filter((code) => code !== data.code) }; } async function getBackupCodes(backupCodes, key) { const secret = new TextDecoder("utf-8").decode( new TextEncoder().encode( await symmetricDecrypt({ key, data: backupCodes }) ) ); const data = JSON.parse(secret); const result = z.array(z.string()).safeParse(data); if (result.success) { return result.data; } return null; } const backupCode2fa = (options) => { const twoFactorTable = "twoFactor"; async function storeBackupCodes(ctx, backupCodes) { if (options?.storeBackupCodes === "encrypted") { return await symmetricEncrypt({ key: ctx.context.secret, data: backupCodes }); } if (typeof options?.storeBackupCodes === "object" && "encrypt" in options?.storeBackupCodes) { return await options?.storeBackupCodes.encrypt(backupCodes); } return backupCodes; } async function decryptBackupCodes(ctx, backupCodes) { if (options?.storeBackupCodes === "encrypted") { return await symmetricDecrypt({ key: ctx.context.secret, data: backupCodes }); } if (typeof options?.storeBackupCodes === "object" && "decrypt" in options?.storeBackupCodes) { return await options?.storeBackupCodes.decrypt(backupCodes); } return backupCodes; } return { id: "backup_code", endpoints: { /** * ### Endpoint * * POST `/two-factor/verify-backup-code` * * ### API Methods * * **server:** * `auth.api.verifyBackupCode` * * **client:** * `authClient.twoFactor.verifyBackupCode` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-verify-backup-code) */ verifyBackupCode: createAuthEndpoint( "/two-factor/verify-backup-code", { method: "POST", body: z.object({ code: z.string().meta({ description: `A backup code to verify. Eg: "123456"` }), /** * Disable setting the session cookie */ disableSession: z.boolean().meta({ description: "If true, the session cookie will not be set." }).optional(), /** * if true, the device will be trusted * for 30 days. It'll be refreshed on * every sign in request within this time. */ trustDevice: z.boolean().meta({ description: "If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time. Eg: true" }).optional() }), metadata: { openapi: { description: "Verify a backup code for two-factor authentication", responses: { "200": { description: "Backup code verified successfully", content: { "application/json": { schema: { type: "object", properties: { user: { type: "object", properties: { id: { type: "string", description: "Unique identifier of the user" }, email: { type: "string", format: "email", nullable: true, description: "User's email address" }, emailVerified: { type: "boolean", nullable: true, description: "Whether the email is verified" }, name: { type: "string", nullable: true, description: "User's name" }, image: { type: "string", format: "uri", nullable: true, description: "User's profile image URL" }, twoFactorEnabled: { type: "boolean", description: "Whether two-factor authentication is enabled for the user" }, createdAt: { type: "string", format: "date-time", description: "Timestamp when the user was created" }, updatedAt: { type: "string", format: "date-time", description: "Timestamp when the user was last updated" } }, required: [ "id", "twoFactorEnabled", "createdAt", "updatedAt" ], description: "The authenticated user object with two-factor details" }, session: { type: "object", properties: { token: { type: "string", description: "Session token" }, userId: { type: "string", description: "ID of the user associated with the session" }, createdAt: { type: "string", format: "date-time", description: "Timestamp when the session was created" }, expiresAt: { type: "string", format: "date-time", description: "Timestamp when the session expires" } }, required: [ "token", "userId", "createdAt", "expiresAt" ], description: "The current session object, included unless disableSession is true" } }, required: ["user", "session"] } } } } } } } }, async (ctx) => { const { session, valid } = await verifyTwoFactor(ctx); const user = session.user; const twoFactor = await ctx.context.adapter.findOne({ model: twoFactorTable, where: [ { field: "userId", value: user.id } ] }); if (!twoFactor) { throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED }); } const decryptedBackupCodes = await decryptBackupCodes( ctx, twoFactor.backupCodes ); const validate = await verifyBackupCode( { backupCodes: decryptedBackupCodes, code: ctx.body.code }, ctx.context.secret ); if (!validate.status) { throw new APIError("UNAUTHORIZED", { message: TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE }); } const updatedBackupCodes = await symmetricEncrypt({ key: ctx.context.secret, data: JSON.stringify(validate.updated) }); await ctx.context.adapter.updateMany({ model: twoFactorTable, update: { backupCodes: updatedBackupCodes }, where: [ { field: "userId", value: user.id } ] }); if (!ctx.body.disableSession) { return valid(ctx); } return ctx.json({ token: session.session?.token, user: { id: session.user?.id, email: session.user.email, emailVerified: session.user.emailVerified, name: session.user.name, image: session.user.image, createdAt: session.user.createdAt, updatedAt: session.user.updatedAt } }); } ), /** * ### Endpoint * * POST `/two-factor/generate-backup-codes` * * ### API Methods * * **server:** * `auth.api.generateBackupCodes` * * **client:** * `authClient.twoFactor.generateBackupCodes` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-generate-backup-codes) */ generateBackupCodes: createAuthEndpoint( "/two-factor/generate-backup-codes", { method: "POST", body: z.object({ password: z.string().meta({ description: "The users password." }) }), use: [sessionMiddleware], metadata: { openapi: { description: "Generate new backup codes for two-factor authentication", responses: { "200": { description: "Backup codes generated successfully", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", description: "Indicates if the backup codes were generated successfully", enum: [true] }, backupCodes: { type: "array", items: { type: "string" }, description: "Array of generated backup codes in plain text" } }, required: ["status", "backupCodes"] } } } } } } } }, async (ctx) => { const user = ctx.context.session.user; if (!user.twoFactorEnabled) { throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.TWO_FACTOR_NOT_ENABLED }); } await ctx.context.password.checkPassword(user.id, ctx); const backupCodes = await generateBackupCodes( ctx.context.secret, options ); const storedBackupCodes = await storeBackupCodes( ctx, backupCodes.encryptedBackupCodes ); await ctx.context.adapter.update({ model: twoFactorTable, update: { backupCodes: storedBackupCodes }, where: [ { field: "userId", value: ctx.context.session.user.id } ] }); return ctx.json({ status: true, backupCodes: backupCodes.backupCodes }); } ), /** * ### Endpoint * * GET `/two-factor/view-backup-codes` * * ### API Methods * * **server:** * `auth.api.viewBackupCodes` * * **client:** * `authClient.twoFactor.viewBackupCodes` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-view-backup-codes) */ viewBackupCodes: createAuthEndpoint( "/two-factor/view-backup-codes", { method: "GET", body: z.object({ userId: z.coerce.string().meta({ description: `The user ID to view all backup codes. Eg: "user-id"` }) }), metadata: { SERVER_ONLY: true } }, async (ctx) => { const twoFactor = await ctx.context.adapter.findOne({ model: twoFactorTable, where: [ { field: "userId", value: ctx.body.userId } ] }); if (!twoFactor) { throw new APIError("BAD_REQUEST", { message: "Backup codes aren't enabled" }); } const backupCodes = await getBackupCodes( twoFactor.backupCodes, ctx.context.secret ); if (!backupCodes) { throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED }); } const decryptedBackupCodes = await decryptBackupCodes( ctx, twoFactor.backupCodes ); return ctx.json({ status: true, backupCodes: decryptedBackupCodes }); } ) } }; }; const defaultKeyHasher = async (token) => { const hash = await createHash("SHA-256").digest( new TextEncoder().encode(token) ); const hashed = base64Url.encode(new Uint8Array(hash), { padding: false }); return hashed; }; const otp2fa = (options) => { const opts = { storeOTP: "plain", digits: 6, ...options, period: (options?.period || 3) * 60 * 1e3 }; async function storeOTP(ctx, otp) { if (opts.storeOTP === "hashed") { return await defaultKeyHasher(otp); } if (typeof opts.storeOTP === "object" && "hash" in opts.storeOTP) { return await opts.storeOTP.hash(otp); } if (typeof opts.storeOTP === "object" && "encrypt" in opts.storeOTP) { return await opts.storeOTP.encrypt(otp); } if (opts.storeOTP === "encrypted") { return await symmetricEncrypt({ key: ctx.context.secret, data: otp }); } return otp; } async function decryptOTP(ctx, otp) { if (opts.storeOTP === "hashed") { return await defaultKeyHasher(otp); } if (opts.storeOTP === "encrypted") { return await symmetricDecrypt({ key: ctx.context.secret, data: otp }); } if (typeof opts.storeOTP === "object" && "encrypt" in opts.storeOTP) { return await opts.storeOTP.decrypt(otp); } if (typeof opts.storeOTP === "object" && "hash" in opts.storeOTP) { return await opts.storeOTP.hash(otp); } return otp; } const send2FaOTP = createAuthEndpoint( "/two-factor/send-otp", { method: "POST", body: z.object({ /** * if true, the device will be trusted * for 30 days. It'll be refreshed on * every sign in request within this time. */ trustDevice: z.boolean().optional().meta({ description: "If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time. Eg: true" }) }).optional(), metadata: { openapi: { summary: "Send two factor OTP", description: "Send two factor OTP to the user", responses: { 200: { description: "Successful response", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean" } } } } } } } } } }, async (ctx) => { if (!options || !options.sendOTP) { ctx.context.logger.error( "send otp isn't configured. Please configure the send otp function on otp options." ); throw new APIError("BAD_REQUEST", { message: "otp isn't configured" }); } const { session, key } = await verifyTwoFactor(ctx); if (!session.user.twoFactorEnabled) { throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.OTP_NOT_ENABLED }); } const code = generateRandomString(opts.digits, "0-9"); const hashedCode = await storeOTP(ctx, code); await ctx.context.internalAdapter.createVerificationValue( { value: `${hashedCode}:0`, identifier: `2fa-otp-${key}`, expiresAt: new Date(Date.now() + opts.period) }, ctx ); await options.sendOTP( { user: session.user, otp: code }, ctx.request ); return ctx.json({ status: true }); } ); const verifyOTP = createAuthEndpoint( "/two-factor/verify-otp", { method: "POST", body: z.object({ code: z.string().meta({ description: 'The otp code to verify. Eg: "012345"' }), /** * if true, the device will be trusted * for 30 days. It'll be refreshed on * every sign in request within this time. */ trustDevice: z.boolean().optional().meta({ description: "If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time. Eg: true" }) }), metadata: { openapi: { summary: "Verify two factor OTP", description: "Verify two factor OTP", responses: { "200": { description: "Two-factor OTP verified successfully", content: { "application/json": { schema: { type: "object", properties: { token: { type: "string", description: "Session token for the authenticated session" }, user: { type: "object", properties: { id: { type: "string", description: "Unique identifier of the user" }, email: { type: "string", format: "email", nullable: true, description: "User's email address" }, emailVerified: { type: "boolean", nullable: true, description: "Whether the email is verified" }, name: { type: "string", nullable: true, description: "User's name" }, image: { type: "string", format: "uri", nullable: true, description: "User's profile image URL" }, createdAt: { type: "string", format: "date-time", description: "Timestamp when the user was created" }, updatedAt: { type: "string", format: "date-time", description: "Timestamp when the user was last updated" } }, required: ["id", "createdAt", "updatedAt"], description: "The authenticated user object" } }, required: ["token", "user"] } } } } } } } }, async (ctx) => { const { session, key, valid, invalid } = await verifyTwoFactor(ctx); const toCheckOtp = await ctx.context.internalAdapter.findVerificationValue( `2fa-otp-${key}` ); const [otp, counter] = toCheckOtp?.value?.split(":") ?? []; const decryptedOtp = await decryptOTP(ctx, otp); if (!toCheckOtp || toCheckOtp.expiresAt < /* @__PURE__ */ new Date()) { if (toCheckOtp) { await ctx.context.internalAdapter.deleteVerificationValue( toCheckOtp.id ); } throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.OTP_HAS_EXPIRED }); } const allowedAttempts = options?.allowedAttempts || 5; if (parseInt(counter) >= allowedAttempts) { await ctx.context.internalAdapter.deleteVerificationValue( toCheckOtp.id ); throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE }); } if (decryptedOtp === ctx.body.code) { if (!session.user.twoFactorEnabled) { if (!session.session) { throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION }); } const updatedUser = await ctx.context.internalAdapter.updateUser( session.user.id, { twoFactorEnabled: true } ); const newSession = await ctx.context.internalAdapter.createSession( session.user.id, ctx, false, session.session ); await ctx.context.internalAdapter.deleteSession( session.session.token ); await setSessionCookie(ctx, { session: newSession, user: updatedUser }); return ctx.json({ token: newSession.token, user: { id: updatedUser.id, email: updatedUser.email, emailVerified: updatedUser.emailVerified, name: updatedUser.name, image: updatedUser.image, createdAt: updatedUser.createdAt, updatedAt: updatedUser.updatedAt } }); } return valid(ctx); } else { await ctx.context.internalAdapter.updateVerificationValue( toCheckOtp.id, { value: `${otp}:${(parseInt(counter, 10) || 0) + 1}` } ); return invalid("INVALID_CODE"); } } ); return { id: "otp", endpoints: { /** * ### Endpoint * * POST `/two-factor/send-otp` * * ### API Methods * * **server:** * `auth.api.send2FaOTP` * * **client:** * `authClient.twoFactor.sendOtp` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-send-otp) */ sendTwoFactorOTP: send2FaOTP, /** * ### Endpoint * * POST `/two-factor/verify-otp` * * ### API Methods * * **server:** * `auth.api.verifyOTP` * * **client:** * `authClient.twoFactor.verifyOtp` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-verify-otp) */ verifyTwoFactorOTP: verifyOTP } }; }; const totp2fa = (options) => { const opts = { ...options, digits: options?.digits || 6, period: options?.period || 30 }; const twoFactorTable = "twoFactor"; const generateTOTP = createAuthEndpoint( "/totp/generate", { method: "POST", body: z.object({ secret: z.string().meta({ description: "The secret to generate the TOTP code" }) }), metadata: { openapi: { summary: "Generate TOTP code", description: "Use this endpoint to generate a TOTP code", responses: { 200: { description: "Successful response", content: { "application/json": { schema: { type: "object", properties: { code: { type: "string" } } } } } } } }, SERVER_ONLY: true } }, async (ctx) => { if (options?.disable) { ctx.context.logger.error( "totp isn't configured. please pass totp option on two factor plugin to enable totp" ); throw new APIError("BAD_REQUEST", { message: "totp isn't configured" }); } const code = await createOTP(ctx.body.secret, { period: opts.period, digits: opts.digits }).totp(); return { code }; } ); const getTOTPURI = createAuthEndpoint( "/two-factor/get-totp-uri", { method: "POST", use: [sessionMiddleware], body: z.object({ password: z.string().meta({ description: "User password" }) }), metadata: { openapi: { summary: "Get TOTP URI", description: "Use this endpoint to get the TOTP URI", responses: { 200: { description: "Successful response", content: { "application/json": { schema: { type: "object", properties: { totpURI: { type: "string" } } } } } } } } } }, async (ctx) => { if (options?.disable) { ctx.context.logger.error( "totp isn't configured. please pass totp option on two factor plugin to enable totp" ); throw new APIError("BAD_REQUEST", { message: "totp isn't configured" }); } const user = ctx.context.session.user; const twoFactor = await ctx.context.adapter.findOne({ model: twoFactorTable, where: [ { field: "userId", value: user.id } ] }); if (!twoFactor) { throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED }); } const secret = await symmetricDecrypt({ key: ctx.context.secret, data: twoFactor.secret }); await ctx.context.password.checkPassword(user.id, ctx); const totpURI = createOTP(secret, { digits: opts.digits, period: opts.period }).url(options?.issuer || ctx.context.appName, user.email); return { totpURI }; } ); const verifyTOTP = createAuthEndpoint( "/two-factor/verify-totp", { method: "POST", body: z.object({ code: z.string().meta({ description: 'The otp code to verify. Eg: "012345"' }), /** * if true, the device will be trusted * for 30 days. It'll be refreshed on * every sign in request within this time. */ trustDevice: z.boolean().meta({ description: "If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time. Eg: true" }).optional() }), metadata: { openapi: { summary: "Verify two factor TOTP", description: "Verify two factor TOTP", responses: { 200: { description: "Successful response", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean" } } } } } } } } } }, async (ctx) => { if (options?.disable) { ctx.context.logger.error( "totp isn't configured. please pass totp option on two factor plugin to enable totp" ); throw new APIError("BAD_REQUEST", { message: "totp isn't configured" }); } const { session, valid, invalid } = await verifyTwoFactor(ctx); const user = session.user; const twoFactor = await ctx.context.adapter.findOne({ model: twoFactorTable, where: [ { field: "userId", value: user.id } ] }); if (!twoFactor) { throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED }); } const decrypted = await symmetricDecrypt({ key: ctx.context.secret, data: twoFactor.secret }); const status = await createOTP(decrypted, { period: opts.period, digits: opts.digits }).verify(ctx.body.code); if (!status) { return invalid("INVALID_CODE"); } if (!user.twoFactorEnabled) { if (!session.session) { throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION }); } const updatedUser = await ctx.context.internalAdapter.updateUser( user.id, { twoFactorEnabled: true }, ctx ); const newSession = await ctx.context.internalAdapter.createSession(user.id, ctx, false, session.session).catch((e) => { throw e; }); await ctx.context.internalAdapter.deleteSession(session.session.token); await setSessionCookie(ctx, { session: newSession, user: updatedUser }); } return valid(ctx); } ); return { id: "totp", endpoints: { /** * ### Endpoint * * POST `/totp/generate` * * ### API Methods * * **server:** * `auth.api.generateTOTP` * * **client:** * `authClient.totp.generate` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/totp#api-method-totp-generate) */ generateTOTP, /** * ### Endpoint * * POST `/two-factor/get-totp-uri` * * ### API Methods * * **server:** * `auth.api.getTOTPURI` * * **client:** * `authClient.twoFactor.getTotpUri` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/two-factor#api-method-two-factor-get-totp-uri) */ getTOTPURI, verifyTOTP } }; }; const schema = { user: { fields: { twoFactorEnabled: { type: "boolean", required: false, defaultValue: false, input: false } } }, twoFactor: { fields: { secret: { type: "string", required: true, returned: false }, backupCodes: { type: "string", required: true, returned: false }, userId: { type: "string", required: true, returned: false, references: { model: "user", field: "id" } } } } }; const twoFactor = (options) => { const opts = { twoFactorTable: "twoFactor" }; const totp = totp2fa(options?.totpOptions); const backupCode = backupCode2fa(options?.backupCodeOptions); const otp = otp2fa(options?.otpOptions); return { id: "two-factor", endpoints: { ...totp.endpoints, ...otp.endpoints, ...backupCode.endpoints, /** * ### Endpoint * * POST `/two-factor/enable` * * ### API Methods * * **server:** * `auth.api.enableTwoFactor` * * **client:** * `authClient.twoFactor.enable` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-enable) */ enableTwoFactor: createAuthEndpoint( "/two-factor/enable", { method: "POST", body: z.object({ password: z.string().meta({ description: "User password" }), issuer: z.string().meta({ description: "Custom issuer for the TOTP URI" }).optional() }), use: [sessionMiddleware], metadata: { openapi: { summary: "Enable two factor authentication", description: "Use this endpoint to enable two factor authentication. This will generate a TOTP URI and backup codes. Once the user verifies the TOTP URI, the two factor authentication will be enabled.", responses: { 200: { description: "Successful response", content: { "application/json": { schema: { type: "object", properties: { totpURI: { type: "string", description: "TOTP URI" }, backupCodes: { type: "array", items: { type: "string" }, description: "Backup codes" } } } } } } } } } }, async (ctx) => { const user = ctx.context.session.user; const { password, issuer } = ctx.body; const isPasswordValid = await validatePassword(ctx, { password, userId: user.id }); if (!isPasswordValid) { throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.INVALID_PASSWORD }); } const secret = generateRandomString(32); const encryptedSecret = await symmetricEncrypt({ key: ctx.context.secret, data: secret }); const backupCodes = await generateBackupCodes( ctx.context.secret, options?.backupCodeOptions ); if (options?.skipVerificationOnEnable) { const updatedUser = await ctx.context.internalAdapter.updateUser( user.id, { twoFactorEnabled: true }, ctx ); const newSession = await ctx.context.internalAdapter.createSession( updatedUser.id, ctx, false, ctx.context.session.session ); await setSessionCookie(ctx, { session: newSession, user: updatedUser }); await ctx.context.internalAdapter.deleteSession( ctx.context.session.session.token ); } await ctx.context.adapter.deleteMany({ model: opts.twoFactorTable, where: [ { field: "userId", value: user.id } ] }); await ctx.context.adapter.create({ model: opts.twoFactorTable, data: { secret: encryptedSecret, backupCodes: backupCodes.encryptedBackupCodes, userId: user.id } }); const totpURI = createOTP(secret, { digits: options?.totpOptions?.digits || 6, period: options?.totpOptions?.period }).url(issuer || options?.issuer || ctx.context.appName, user.email); return ctx.json({ totpURI, backupCodes: backupCodes.backupCodes }); } ), /** * ### Endpoint * * POST `/two-factor/disable` * * ### API Methods * * **server:** * `auth.api.disableTwoFactor` * * **client:** * `authClient.twoFactor.disable` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-disable) */ disableTwoFactor: createAuthEndpoint( "/two-factor/disable", { method: "POST", body: z.object({ password: z.string().meta({ description: "User password" }) }), use: [sessionMiddleware], metadata: { openapi: { summary: "Disable two factor authentication", description: "Use this endpoint to disable two factor authentication.", responses: { 200: { description: "Successful response", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean" } } } } } } } } } }, async (ctx) => { const user = ctx.context.session.user; const { password } = ctx.body; const isPasswordValid = await validatePassword(ctx, { password, userId: user.id }); if (!isPasswordValid) { throw new APIError("BAD_REQUEST", { message: "Invalid password" }); } const updatedUser = await ctx.context.internalAdapter.updateUser( user.id, { twoFactorEnabled: false }, ctx ); await ctx.context.adapter.delete({ model: opts.twoFactorTable, where: [ { field: "userId", value: updatedUser.id } ] }); const newSession = await ctx.context.internalAdapter.createSession( updatedUser.id, ctx, false, ctx.context.session.session ); await setSessionCookie(ctx, { session: newSession, user: updatedUser }); await ctx.context.internalAdapter.deleteSession( ctx.context.session.session.token ); return ctx.json({ status: true }); } ) }, options, hooks: { after: [ { matcher(context) { return context.path === "/sign-in/email" || context.path === "/sign-in/username" || context.path === "/sign-in/phone-number"; }, handler: createAuthMiddleware(async (ctx) => { const data = ctx.context.newSession; if (!data) { return; } if (!data?.user.twoFactorEnabled) { return; } const trustDeviceCookieName = ctx.context.createAuthCookie( TRUST_DEVICE_COOKIE_NAME ); const trustDeviceCookie = await ctx.getSignedCookie( trustDeviceCookieName.name, ctx.context.secret ); if (trustDeviceCookie) { const [token, sessionToken] = trustDeviceCookie.split("!"); const expectedToken = await createHMAC( "SHA-256", "base64urlnopad" ).sign(ctx.context.secret, `${data.user.id}!${sessionToken}`); if (token === expectedToken) { const newToken = await createHMAC( "SHA-256", "base64urlnopad" ).sign(ctx.context.secret, `${data.user.id}!${sessionToken}`); await ctx.setSignedCookie( trustDeviceCookieName.name, `${newToken}!${data.session.token}`, ctx.context.secret, trustDeviceCookieName.attributes ); return; } } deleteSessionCookie(ctx, true); await ctx.context.internalAdapter.deleteSession(data.session.token); const maxAge = (options?.otpOptions?.period ?? 3) * 60; const twoFactorCookie = ctx.context.createAuthCookie( TWO_FACTOR_COOKIE_NAME, { maxAge } ); const identifier = `2fa-${generateRandomString(20)}`; await ctx.context.internalAdapter.createVerificationValue( { value: data.user.id, identifier, expiresAt: new Date(Date.now() + maxAge * 1e3) }, ctx ); await ctx.setSignedCookie( twoFactorCookie.name, identifier, ctx.context.secret, twoFactorCookie.attributes ); return ctx.json({ twoFactorRedirect: true }); }) } ] }, schema: mergeSchema(schema, options?.schema), rateLimit: [ { pathMatcher(path) { return path.startsWith("/two-factor/"); }, window: 10, max: 3 } ], $ERROR_CODES: TWO_FACTOR_ERROR_CODES }; }; export { TWO_FACTOR_ERROR_CODES, twoFactor };