diff --git a/.cspell/auth-terms.txt b/.cspell/auth-terms.txt index 2dbe8bbc5c..b090e3954f 100644 --- a/.cspell/auth-terms.txt +++ b/.cspell/auth-terms.txt @@ -1,4 +1,5 @@ totp +otps pkce unban siwe diff --git a/docs/content/docs/plugins/email-otp.mdx b/docs/content/docs/plugins/email-otp.mdx index aa84cd5c74..ac9b11b714 100644 --- a/docs/content/docs/plugins/email-otp.mdx +++ b/docs/content/docs/plugins/email-otp.mdx @@ -411,6 +411,26 @@ export const auth = betterAuth({ When the maximum attempts are exceeded, the `verifyOTP`, `signIn.emailOtp`, `verifyEmail`, and `resetPassword` methods will return an error with code `TOO_MANY_ATTEMPTS`. +- `resendStrategy`: Controls what happens when a user requests a new OTP while an existing one is still valid. Defaults to `"rotate"`. + - `"rotate"`: Always generates a new OTP (default behavior). + - `"reuse"`: Resends the same OTP and extends its expiry. This prevents multiple valid codes from existing simultaneously when emails are delayed. Only works when the OTP is recoverable (`plain`, `encrypted`, or custom encrypt/decrypt). Falls back to `"rotate"` when the OTP is hashed. If the allowed attempts have been exhausted, a fresh OTP is generated instead of reusing the exhausted one. + +```ts title="auth.ts" +import { betterAuth } from "better-auth" +import { emailOTP } from "better-auth/plugins" + +export const auth = betterAuth({ + plugins: [ + emailOTP({ + resendStrategy: "reuse", // [!code highlight] + async sendVerificationOTP({ email, otp, type }) { + // send the OTP + }, + }) + ] +}) +``` + - `storeOTP`: The method used to transform the OTP before it is stored by Better Auth's verification layer, whether `encrypted`, `hashed` or `plain` text. Default is `plain` text. diff --git a/packages/better-auth/src/plugins/email-otp/email-otp.test.ts b/packages/better-auth/src/plugins/email-otp/email-otp.test.ts index 658e67f574..77876ed670 100644 --- a/packages/better-auth/src/plugins/email-otp/email-otp.test.ts +++ b/packages/better-auth/src/plugins/email-otp/email-otp.test.ts @@ -1978,3 +1978,227 @@ describe("race condition protection", async () => { expect(res2.error?.code).toBe("INVALID_OTP"); }); }); + +describe("email-otp-resendStrategy", async () => { + afterEach(() => { + vi.useRealTimers(); + }); + + const otps: string[] = []; + const { client, testUser } = await getTestInstance( + { + plugins: [ + emailOTP({ + async sendVerificationOTP({ otp }) { + otps.push(otp); + }, + resendStrategy: "reuse", + }), + ], + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + it("should reuse existing OTP when resendStrategy is reuse", async () => { + otps.length = 0; + await client.emailOtp.sendVerificationOtp({ + email: testUser.email, + type: "email-verification", + }); + const firstOtp = otps[0]; + expect(firstOtp).toBeDefined(); + + await client.emailOtp.sendVerificationOtp({ + email: testUser.email, + type: "email-verification", + }); + const secondOtp = otps[1]; + expect(secondOtp).toBeDefined(); + expect(secondOtp).toBe(firstOtp); + }); + + it("should generate new OTP after previous one expires", async () => { + otps.length = 0; + vi.useFakeTimers(); + + await client.emailOtp.sendVerificationOtp({ + email: testUser.email, + type: "sign-in", + }); + const firstOtp = otps[0]; + expect(firstOtp).toBeDefined(); + + // Advance past expiry (default 5 minutes) + await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + + await client.emailOtp.sendVerificationOtp({ + email: testUser.email, + type: "sign-in", + }); + const secondOtp = otps[1]; + expect(secondOtp).toBeDefined(); + expect(secondOtp).not.toBe(firstOtp); + }); + + it("should generate new OTP when resendStrategy is reuse but storeOTP is hashed", async () => { + const hashedOtps: string[] = []; + const { client: hashedClient, testUser: hashedUser } = + await getTestInstance( + { + plugins: [ + emailOTP({ + async sendVerificationOTP({ otp }) { + hashedOtps.push(otp); + }, + resendStrategy: "reuse", + storeOTP: "hashed", + }), + ], + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + await hashedClient.emailOtp.sendVerificationOtp({ + email: hashedUser.email, + type: "email-verification", + }); + const firstOtp = hashedOtps[0]; + expect(firstOtp).toBeDefined(); + + // Second request - should get NEW OTP since hashed OTP cannot be retrieved + await hashedClient.emailOtp.sendVerificationOtp({ + email: hashedUser.email, + type: "email-verification", + }); + const secondOtp = hashedOtps[1]; + expect(secondOtp).toBeDefined(); + expect(secondOtp).not.toBe(firstOtp); + }); + + it("should generate new OTP when resendStrategy is reuse but storeOTP is custom hash", async () => { + const customHashOtps: string[] = []; + const { client: hashClient, testUser: hashUser } = await getTestInstance( + { + plugins: [ + emailOTP({ + async sendVerificationOTP({ otp }) { + customHashOtps.push(otp); + }, + resendStrategy: "reuse", + storeOTP: { + hash: async (otp) => `hashed-${otp}`, + }, + }), + ], + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + await hashClient.emailOtp.sendVerificationOtp({ + email: hashUser.email, + type: "email-verification", + }); + const firstOtp = customHashOtps[0]; + expect(firstOtp).toBeDefined(); + + await hashClient.emailOtp.sendVerificationOtp({ + email: hashUser.email, + type: "email-verification", + }); + const secondOtp = customHashOtps[1]; + expect(secondOtp).toBeDefined(); + expect(secondOtp).not.toBe(firstOtp); + }); + + it("should not send OTP for non-existent user on email-verification type", async () => { + otps.length = 0; + const { client: noSignUpClient } = await getTestInstance( + { + plugins: [ + emailOTP({ + async sendVerificationOTP({ otp }) { + otps.push(otp); + }, + resendStrategy: "reuse", + disableSignUp: true, + }), + ], + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + const res = await noSignUpClient.emailOtp.sendVerificationOtp({ + email: "nonexistent@test.com", + type: "email-verification", + }); + expect(res.data?.success).toBe(true); + // OTP should not be sent since user doesn't exist + expect(otps.length).toBe(0); + }); + + it("should generate fresh OTP when attempts are exhausted", async () => { + otps.length = 0; + const { client: attemptClient, testUser: attemptUser } = + await getTestInstance( + { + plugins: [ + emailOTP({ + async sendVerificationOTP({ otp }) { + otps.push(otp); + }, + resendStrategy: "reuse", + allowedAttempts: 2, + }), + ], + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + // Send first OTP + await attemptClient.emailOtp.sendVerificationOtp({ + email: attemptUser.email, + type: "email-verification", + }); + const firstOtp = otps[0]; + expect(firstOtp).toBeDefined(); + + // Exhaust attempts by verifying with wrong OTP + await attemptClient.emailOtp.verifyEmail({ + email: attemptUser.email, + otp: "wrong1", + }); + await attemptClient.emailOtp.verifyEmail({ + email: attemptUser.email, + otp: "wrong2", + }); + + // Request new OTP — should generate fresh one since attempts exhausted + await attemptClient.emailOtp.sendVerificationOtp({ + email: attemptUser.email, + type: "email-verification", + }); + const secondOtp = otps[1]; + expect(secondOtp).toBeDefined(); + expect(secondOtp).not.toBe(firstOtp); + }); +}); diff --git a/packages/better-auth/src/plugins/email-otp/index.ts b/packages/better-auth/src/plugins/email-otp/index.ts index 028666cef1..0a29bf0691 100644 --- a/packages/better-auth/src/plugins/email-otp/index.ts +++ b/packages/better-auth/src/plugins/email-otp/index.ts @@ -19,6 +19,7 @@ import { verifyEmailOTP, } from "./routes"; import type { EmailOTPOptions } from "./types"; +import { toOTPIdentifier } from "./utils"; declare module "@better-auth/core" { interface BetterAuthPluginRegistry { @@ -105,7 +106,7 @@ export const emailOTP = (options: EmailOTPOptions) => { const storedOTP = await storeOTP(ctx, opts, otp); await ctx.context.internalAdapter.createVerificationValue({ value: `${storedOTP}:0`, - identifier: `email-verification-otp-${email}`, + identifier: toOTPIdentifier("email-verification", email), expiresAt: getDate(opts.expiresIn, "sec"), }); await ctx.context.runInBackgroundOrAwait( diff --git a/packages/better-auth/src/plugins/email-otp/otp-token.ts b/packages/better-auth/src/plugins/email-otp/otp-token.ts index b7e496e09e..5f1a4192a4 100644 --- a/packages/better-auth/src/plugins/email-otp/otp-token.ts +++ b/packages/better-auth/src/plugins/email-otp/otp-token.ts @@ -4,8 +4,9 @@ import { symmetricDecrypt, symmetricEncrypt, } from "../../crypto"; -import type { EmailOTPOptions } from "./types"; -import { defaultKeyHasher } from "./utils"; +import { getDate } from "../../utils/date"; +import type { EmailOTPOptions, RequiredEmailOTPOptions } from "./types"; +import { defaultKeyHasher, splitAtLastColon } from "./utils"; export async function storeOTP( ctx: GenericEndpointContext, @@ -59,3 +60,55 @@ export async function verifyStoredOTP( return constantTimeEqual(otp, storedOtp); } + +/** + * Retrieves the plain-text OTP from a stored value. + * Returns `null` if the OTP is hashed and cannot be recovered. + */ +async function retrieveOTP( + ctx: GenericEndpointContext, + opts: EmailOTPOptions, + storedOtp: string, +): Promise { + if (opts.storeOTP === "plain" || opts.storeOTP === undefined) { + return storedOtp; + } + if (opts.storeOTP === "encrypted") { + return await symmetricDecrypt({ + key: ctx.context.secretConfig, + data: storedOtp, + }); + } + if (typeof opts.storeOTP === "object" && "decrypt" in opts.storeOTP) { + return await opts.storeOTP.decrypt(storedOtp); + } + // hashed or custom hash -> cannot recover + return null; +} + +/** + * Tries to reuse an existing unexpired OTP. + * Returns the plain-text OTP if reusable, `null` otherwise. + */ +export async function tryReuseOTP( + ctx: GenericEndpointContext, + opts: RequiredEmailOTPOptions, + identifier: string, +): Promise { + const existing = + await ctx.context.internalAdapter.findVerificationValue(identifier); + if (!existing || existing.expiresAt < new Date()) return null; + + const [storedOtpValue, attempts] = splitAtLastColon(existing.value); + const allowedAttempts = opts.allowedAttempts || 3; + if (attempts && parseInt(attempts) >= allowedAttempts) return null; + + const plainOtp = await retrieveOTP(ctx, opts, storedOtpValue); + if (!plainOtp) return null; + + await ctx.context.internalAdapter.updateVerificationByIdentifier(identifier, { + expiresAt: getDate(opts.expiresIn, "sec"), + }); + + return plainOtp; +} diff --git a/packages/better-auth/src/plugins/email-otp/routes.ts b/packages/better-auth/src/plugins/email-otp/routes.ts index 1d6a72992a..1a870ea677 100644 --- a/packages/better-auth/src/plugins/email-otp/routes.ts +++ b/packages/better-auth/src/plugins/email-otp/routes.ts @@ -12,9 +12,10 @@ import { setCookieCache, setSessionCookie } from "../../cookies"; import { generateRandomString, symmetricDecrypt } from "../../crypto"; import { parseUserInput, parseUserOutput } from "../../db/schema"; import { getDate } from "../../utils/date"; -import { storeOTP, verifyStoredOTP } from "./otp-token"; -import type { EmailOTPOptions } from "./types"; -import { splitAtLastColon } from "./utils"; +import { EMAIL_OTP_ERROR_CODES as ERROR_CODES } from "./error-codes"; +import { storeOTP, tryReuseOTP, verifyStoredOTP } from "./otp-token"; +import type { EmailOTPOptions, RequiredEmailOTPOptions } from "./types"; +import { splitAtLastColon, toOTPIdentifier } from "./utils"; const types = [ "email-verification", @@ -23,14 +24,48 @@ const types = [ "change-email", ] as const; -type WithRequired = T & { [P in K]-?: T[P] }; +/** + * Resolves the OTP to send: reuses an existing one if possible, + * otherwise generates and stores a new one. + * + * @internal + */ +async function resolveOTP( + ctx: GenericEndpointContext, + opts: RequiredEmailOTPOptions, + email: string, + type: (typeof types)[number], +): Promise { + const identifier = toOTPIdentifier(type, email); -type RequiredEmailOTPOptions = WithRequired< - EmailOTPOptions, - "expiresIn" | "generateOTP" | "storeOTP" ->; + if (opts.resendStrategy === "reuse") { + const reused = await tryReuseOTP(ctx, opts, identifier); + if (reused) return reused; + } -import { EMAIL_OTP_ERROR_CODES as ERROR_CODES } from "./error-codes"; + const otp = + opts.generateOTP({ email, type }, ctx) || defaultOTPGenerator(opts); + const storedOTP = await storeOTP(ctx, opts, otp); + + await ctx.context.internalAdapter + .createVerificationValue({ + value: `${storedOTP}:0`, + identifier, + expiresAt: getDate(opts.expiresIn, "sec"), + }) + .catch(async () => { + await ctx.context.internalAdapter.deleteVerificationByIdentifier( + identifier, + ); + await ctx.context.internalAdapter.createVerificationValue({ + value: `${storedOTP}:0`, + identifier, + expiresAt: getDate(opts.expiresIn, "sec"), + }); + }); + + return otp; +} const sendVerificationOTPBodySchema = z.object({ email: z.string({}).meta({ @@ -108,57 +143,22 @@ export const sendVerificationOTP = (opts: RequiredEmailOTPOptions) => message: "Invalid OTP type", }); } - const otp = - opts.generateOTP({ email, type: ctx.body.type }, ctx) || - defaultOTPGenerator(opts); + const identifier = toOTPIdentifier(ctx.body.type, email); + const otp = await resolveOTP(ctx, opts, email, ctx.body.type); - const storedOTP = await storeOTP(ctx, opts, otp); - - await ctx.context.internalAdapter - .createVerificationValue({ - value: `${storedOTP}:0`, - identifier: `${ctx.body.type}-otp-${email}`, - expiresAt: getDate(opts.expiresIn, "sec"), - }) - .catch(async (error) => { - // might be duplicate key error - await ctx.context.internalAdapter.deleteVerificationByIdentifier( - `${ctx.body.type}-otp-${email}`, - ); - //try again - await ctx.context.internalAdapter.createVerificationValue({ - value: `${storedOTP}:0`, - identifier: `${ctx.body.type}-otp-${email}`, - expiresAt: getDate(opts.expiresIn, "sec"), - }); - }); + const shouldSendOTP = ctx.body.type === "sign-in" && !opts.disableSignUp; const user = await ctx.context.internalAdapter.findUserByEmail(email); - if (!user) { - if (ctx.body.type === "sign-in" && !opts.disableSignUp) { - // allow - } else { - await ctx.context.internalAdapter.deleteVerificationByIdentifier( - `${ctx.body.type}-otp-${email}`, - ); - return ctx.json({ - success: true, - }); - } + if (!user && !shouldSendOTP) { + await ctx.context.internalAdapter.deleteVerificationByIdentifier( + identifier, + ); + return ctx.json({ success: true }); } await ctx.context.runInBackgroundOrAwait( - opts.sendVerificationOTP( - { - email, - otp, - type: ctx.body.type, - }, - ctx, - ), + opts.sendVerificationOTP({ email, otp, type: ctx.body.type }, ctx), ); - return ctx.json({ - success: true, - }); + return ctx.json({ success: true }); }, ); @@ -204,7 +204,7 @@ export const createVerificationOTP = (opts: RequiredEmailOTPOptions) => const storedOTP = await storeOTP(ctx, opts, otp); await ctx.context.internalAdapter.createVerificationValue({ value: `${storedOTP}:0`, - identifier: `${ctx.body.type}-otp-${email}`, + identifier: toOTPIdentifier(ctx.body.type, email), expiresAt: getDate(opts.expiresIn, "sec"), }); return otp; @@ -270,7 +270,7 @@ export const getVerificationOTP = (opts: RequiredEmailOTPOptions) => const email = ctx.query.email.toLowerCase(); const verificationValue = await ctx.context.internalAdapter.findVerificationValue( - `${ctx.query.type}-otp-${email}`, + toOTPIdentifier(ctx.query.type, email), ); if (!verificationValue || verificationValue.expiresAt < new Date()) { return ctx.json({ @@ -371,17 +371,15 @@ export const checkVerificationOTP = (opts: RequiredEmailOTPOptions) => if (!user) { throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.USER_NOT_FOUND); } + const identifier = toOTPIdentifier(ctx.body.type, email); const verificationValue = - await ctx.context.internalAdapter.findVerificationValue( - `${ctx.body.type}-otp-${email}`, - ); + await ctx.context.internalAdapter.findVerificationValue(identifier); if (!verificationValue) { throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP); } - const otpIdentifier = `${ctx.body.type}-otp-${email}`; if (verificationValue.expiresAt < new Date()) { await ctx.context.internalAdapter.deleteVerificationByIdentifier( - otpIdentifier, + identifier, ); throw APIError.from("BAD_REQUEST", ERROR_CODES.OTP_EXPIRED); } @@ -390,14 +388,14 @@ export const checkVerificationOTP = (opts: RequiredEmailOTPOptions) => const allowedAttempts = opts?.allowedAttempts || 3; if (attempts && parseInt(attempts) >= allowedAttempts) { await ctx.context.internalAdapter.deleteVerificationByIdentifier( - otpIdentifier, + identifier, ); throw APIError.from("FORBIDDEN", ERROR_CODES.TOO_MANY_ATTEMPTS); } const verified = await verifyStoredOTP(ctx, opts, otpValue, ctx.body.otp); if (!verified) { await ctx.context.internalAdapter.updateVerificationByIdentifier( - otpIdentifier, + identifier, { value: `${otpValue}:${parseInt(attempts || "0") + 1}`, }, @@ -488,7 +486,7 @@ export const verifyEmailOTP = (opts: RequiredEmailOTPOptions) => await atomicVerifyOTP( ctx, opts, - `email-verification-otp-${email}`, + toOTPIdentifier("email-verification", email), ctx.body.otp, ); @@ -641,7 +639,7 @@ export const signInEmailOTP = (opts: RequiredEmailOTPOptions) => const email = rawEmail.toLowerCase(); // Use atomic verification to prevent race conditions - await atomicVerifyOTP(ctx, opts, `sign-in-otp-${email}`, otp); + await atomicVerifyOTP(ctx, opts, toOTPIdentifier("sign-in", email), otp); const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { @@ -748,19 +746,12 @@ export const requestPasswordResetEmailOTP = (opts: RequiredEmailOTPOptions) => }, async (ctx) => { const email = ctx.body.email; - const otp = - opts.generateOTP({ email, type: "forget-password" }, ctx) || - defaultOTPGenerator(opts); - const storedOTP = await storeOTP(ctx, opts, otp); - await ctx.context.internalAdapter.createVerificationValue({ - value: `${storedOTP}:0`, - identifier: `forget-password-otp-${email}`, - expiresAt: getDate(opts.expiresIn, "sec"), - }); + const identifier = toOTPIdentifier("forget-password", email); + const otp = await resolveOTP(ctx, opts, email, "forget-password"); const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { await ctx.context.internalAdapter.deleteVerificationByIdentifier( - `forget-password-otp-${email}`, + identifier, ); return ctx.json({ success: true, @@ -847,19 +838,12 @@ export const forgetPasswordEmailOTP = (opts: RequiredEmailOTPOptions) => { async (ctx) => { warnDeprecation(); const email = ctx.body.email; - const otp = - opts.generateOTP({ email, type: "forget-password" }, ctx) || - defaultOTPGenerator(opts); - const storedOTP = await storeOTP(ctx, opts, otp); - await ctx.context.internalAdapter.createVerificationValue({ - value: `${storedOTP}:0`, - identifier: `forget-password-otp-${email}`, - expiresAt: getDate(opts.expiresIn, "sec"), - }); + const identifier = toOTPIdentifier("forget-password", email); + const otp = await resolveOTP(ctx, opts, email, "forget-password"); const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { await ctx.context.internalAdapter.deleteVerificationByIdentifier( - `forget-password-otp-${email}`, + identifier, ); return ctx.json({ success: true, @@ -946,7 +930,7 @@ export const resetPasswordEmailOTP = (opts: RequiredEmailOTPOptions) => await atomicVerifyOTP( ctx, opts, - `forget-password-otp-${email}`, + toOTPIdentifier("forget-password", email), ctx.body.otp, ); @@ -1093,12 +1077,15 @@ export const requestEmailChangeEmailOTP = (opts: RequiredEmailOTPOptions) => const currentEmailVerificationValue = await ctx.context.internalAdapter.findVerificationValue( - `email-verification-otp-${email}`, + toOTPIdentifier("email-verification", email), ); if (!currentEmailVerificationValue) { throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP); } - const currentEmailIdentifier = `email-verification-otp-${email}`; + const currentEmailIdentifier = toOTPIdentifier( + "email-verification", + email, + ); if (currentEmailVerificationValue.expiresAt < new Date()) { await ctx.context.internalAdapter.deleteVerificationByIdentifier( currentEmailIdentifier, @@ -1151,14 +1138,14 @@ export const requestEmailChangeEmailOTP = (opts: RequiredEmailOTPOptions) => const storedOTP = await storeOTP(ctx, opts, otp); await ctx.context.internalAdapter.createVerificationValue({ value: `${storedOTP}:0`, - identifier: `change-email-otp-${email}-${newEmail}`, + identifier: toOTPIdentifier("change-email", `${email}-${newEmail}`), expiresAt: getDate(opts.expiresIn, "sec"), }); const user = await ctx.context.internalAdapter.findUserByEmail(newEmail); if (user) { await ctx.context.internalAdapter.deleteVerificationByIdentifier( - `change-email-otp-${email}-${newEmail}`, + toOTPIdentifier("change-email", `${email}-${newEmail}`), ); return ctx.json({ success: true, @@ -1262,12 +1249,15 @@ export const changeEmailEmailOTP = (opts: RequiredEmailOTPOptions) => const verificationValue = await ctx.context.internalAdapter.findVerificationValue( - `change-email-otp-${email}-${newEmail}`, + toOTPIdentifier("change-email", `${email}-${newEmail}`), ); if (!verificationValue) { throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP); } - const changeEmailIdentifier = `change-email-otp-${email}-${newEmail}`; + const changeEmailIdentifier = toOTPIdentifier( + "change-email", + `${email}-${newEmail}`, + ); if (verificationValue.expiresAt < new Date()) { await ctx.context.internalAdapter.deleteVerificationByIdentifier( changeEmailIdentifier, diff --git a/packages/better-auth/src/plugins/email-otp/types.ts b/packages/better-auth/src/plugins/email-otp/types.ts index 1591b8337f..a3718c73fb 100644 --- a/packages/better-auth/src/plugins/email-otp/types.ts +++ b/packages/better-auth/src/plugins/email-otp/types.ts @@ -1,5 +1,12 @@ import type { GenericEndpointContext } from "@better-auth/core"; +type WithRequired = T & { [P in K]-?: T[P] }; + +export type RequiredEmailOTPOptions = WithRequired< + EmailOTPOptions, + "expiresIn" | "generateOTP" | "storeOTP" +>; + export interface EmailOTPOptions { /** * Function to send email verification. @@ -81,6 +88,18 @@ export interface EmailOTPOptions { } ) | undefined; + /** + * Strategy for handling OTP when the user requests a new OTP + * while an existing one is still valid. + * + * - `"rotate"`: Always generates a new OTP (default behavior). + * - `"reuse"`: Resends the same OTP and extends its expiry. + * Only works when the OTP is recoverable (plain, encrypted, or custom encrypt/decrypt). + * Falls back to `"rotate"` when OTP is hashed. + * + * @default "rotate" + */ + resendStrategy?: "rotate" | "reuse" | undefined; /** * Change email configuration for the change email with OTP flow * diff --git a/packages/better-auth/src/plugins/email-otp/utils.ts b/packages/better-auth/src/plugins/email-otp/utils.ts index 42f794d26f..a55f339ff7 100644 --- a/packages/better-auth/src/plugins/email-otp/utils.ts +++ b/packages/better-auth/src/plugins/email-otp/utils.ts @@ -1,5 +1,13 @@ import { base64Url } from "@better-auth/utils/base64"; import { createHash } from "@better-auth/utils/hash"; + +export function toOTPIdentifier( + type: "email-verification" | "sign-in" | "forget-password" | "change-email", + email: string, +) { + return `${type}-otp-${email}`; +} + export const defaultKeyHasher = async (otp: string) => { const hash = await createHash("SHA-256").digest( new TextEncoder().encode(otp),