mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
feat(email-otp): add resendStrategy option to reuse existing OTP (#8560)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
totp
|
||||
otps
|
||||
pkce
|
||||
unban
|
||||
siwe
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Callout>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
verifyEmailOTP,
|
||||
} from "./routes";
|
||||
import type { EmailOTPOptions } from "./types";
|
||||
import { toOTPIdentifier } from "./utils";
|
||||
|
||||
declare module "@better-auth/core" {
|
||||
interface BetterAuthPluginRegistry<AuthOptions, Options> {
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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, K extends keyof T> = 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<string> {
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
|
||||
type WithRequired<T, K extends keyof T> = 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
|
||||
*
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user