diff --git a/docs/content/docs/plugins/email-otp.mdx b/docs/content/docs/plugins/email-otp.mdx index 6ce7cfaa85..6a93e2f5e1 100644 --- a/docs/content/docs/plugins/email-otp.mdx +++ b/docs/content/docs/plugins/email-otp.mdx @@ -241,6 +241,102 @@ type resetPasswordEmailOTP = { ``` +### Change Email with OTP + +To allow users to change their email with OTP, first enable the `changeEmail` feature, which is disabled by default. Set `changeEmail.enabled` to `true`: + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + plugins: [ + emailOTP({ + changeEmail: { + enabled: true, // [!code highlight] + } + }) + ] +}) +``` + +By default, when a user requests to change their email, an OTP is sent to the **new** email address. +The email is only updated after the user verifies the new email. + +#### Usage + +To change the user's email address with OTP, use the `emailOtp.requestEmailChange()` method to send a "change-email" OTP to the user's new email address. + + +```ts +type requestEmailChangeEmailOTP = { + /** + * New email address to send the OTP. + */ + newEmail: string = "user@example.com" + /** + * OTP sent to the current email. This is required when the `changeEmail.verifyCurrentEmail` option is set to `true`. + */ + otp?: string = "123456" +} +``` + + +Once the user provides the OTP, use the `changeEmail()` method to change the user's email address. + + +```ts +type changeEmailEmailOTP = { + /** + * New email address to change to. + */ + newEmail: string = "user@example.com" + /** + * OTP sent to the new email. + */ + otp: string = "123456" +} +``` + + +#### Confirming with Current Email + +For added security, you can require users to confirm the change with an OTP sent to their **current** email before +sending an OTP to the new email address. To enable this, set `changeEmail.verifyCurrentEmail` to `true` in the plugin options. + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + plugins: [ + emailOTP({ + changeEmail: { + enabled: true, + verifyCurrentEmail: true, // [!code highlight] + } + }) + ] +}) +``` + +Before requesting the email change, use the `sendVerificationOtp()` method with type `email-verification` to send an OTP to the user's email address. + + +```ts +type sendVerificationOTP = { + /** + * Email address to send the OTP. + */ + email: string = "user@example.com" + /** + * Type of the OTP. Must be `email-verification` for confirming email change. + */ + type: string = "email-verification" +} +``` + + +Then, the user can provide the OTP when calling `requestEmailChange()`. The system will first verify the OTP sent to the current email before sending an OTP to the new email. + ### Override Default Email Verification To override the default email verification, pass `overrideDefaultEmailVerification: true` in the options. This will make the system use an email OTP instead of the default verification link whenever email verification is triggered. In other words, the user will verify their email using an OTP rather than clicking a link. 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 825ef6e292..658e67f574 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 @@ -288,6 +288,15 @@ describe("email-otp", async () => { expect(res.error?.code).toBe("INVALID_EMAIL"); }); + it("should reject change-email type", async () => { + const res = await client.emailOtp.sendVerificationOtp({ + email: testUser.email, + type: "change-email", + }); + expect(res.error?.status).toBe(400); + expect(res.error?.message).toBe("Invalid OTP type"); + }); + it("should fail on expired otp", async () => { await client.emailOtp.sendVerificationOtp({ email: testUser.email, @@ -390,6 +399,599 @@ describe("email-otp", async () => { }); }); +describe("change email", async () => { + const otpFn = vi.fn(); + let otp = ""; + const { client, testUser, runWithUser } = await getTestInstance( + { + plugins: [ + bearer(), + emailOTP({ + async sendVerificationOTP({ email, otp: _otp, type }) { + otp = _otp; + otpFn(email, _otp, type); + }, + sendVerificationOnSignUp: true, + changeEmail: { enabled: true }, + }), + ], + emailVerification: { + autoSignInAfterVerification: true, + }, + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("request", () => { + it("should send otp for change email request", async () => { + const newEmail = "new-email@test.com"; + otpFn.mockClear(); + await runWithUser(testUser.email, testUser.password, async () => { + const res = await client.emailOtp.requestEmailChange({ + newEmail, + }); + expect(res.data?.success).toBe(true); + expect(res.error).toBeFalsy(); + }); + expect(otpFn).toHaveBeenCalledWith( + newEmail, + expect.any(String), + "change-email", + ); + }); + + it("should not send otp for change email request if session does not exist", async () => { + const res = await client.emailOtp.requestEmailChange({ + newEmail: "new-email@test.com", + }); + expect(res.error?.status).toBe(401); + expect(res.error?.code).toBe("UNAUTHORIZED"); + }); + + it("should not send otp for change email request if session is invalid", async () => { + const res = await client.emailOtp.requestEmailChange({ + newEmail: "new-email@test.com", + fetchOptions: { + headers: new Headers({ + Authorization: "Bearer invalid-session-token", + }), + }, + }); + expect(res.error?.status).toBe(401); + expect(res.error?.code).toBe("UNAUTHORIZED"); + }); + + it("should not send otp for change email request when change email with OTP is disabled", async () => { + const { + client: disabledClient, + testUser: disabledTestUser, + runWithUser: disabledRunWithUser, + } = await getTestInstance( + { + plugins: [ + bearer(), + emailOTP({ + async sendVerificationOTP() {}, + sendVerificationOnSignUp: true, + changeEmail: { enabled: false }, + }), + ], + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + let res: Awaited< + ReturnType + >; + await disabledRunWithUser( + disabledTestUser.email, + disabledTestUser.password, + async () => { + res = await disabledClient.emailOtp.requestEmailChange({ + newEmail: "new@test.com", + }); + }, + ); + expect(res!.error?.status).toBe(400); + expect(res!.error?.message).toBe("Change email with OTP is disabled"); + }); + + it("should not send otp for change email request if email is same as old email", async () => { + let res: Awaited>; + await runWithUser(testUser.email, testUser.password, async () => { + res = await client.emailOtp.requestEmailChange({ + newEmail: testUser.email, + }); + }); + expect(res!.error?.status).toBe(400); + expect(res!.error?.message).toContain("Email is the same"); + }); + + it("should not send otp for change email request if email is already used by another account", async () => { + const otherUser = { + email: "other-user@test.com", + password: "password123", + name: "Other User", + }; + await client.signUp.email(otherUser); + + otpFn.mockClear(); + await runWithUser(testUser.email, testUser.password, async () => { + const res = await client.emailOtp.requestEmailChange({ + newEmail: otherUser.email, + }); + expect(res.data?.success).toBe(true); + }); + expect(otpFn).not.toHaveBeenCalledWith( + otherUser.email, + expect.any(String), + "change-email", + ); + }); + + describe("when verifyCurrentEmail is enabled", async () => { + const verifyCurrentOtpFn = vi.fn(); + let currentEmailOtp = ""; + const { + client: vcClient, + testUser: vcTestUser, + runWithUser: vcRunWithUser, + } = await getTestInstance( + { + plugins: [ + bearer(), + emailOTP({ + async sendVerificationOTP({ email, otp: _otp, type }) { + currentEmailOtp = _otp; + verifyCurrentOtpFn(email, _otp, type); + }, + sendVerificationOnSignUp: true, + changeEmail: { enabled: true, verifyCurrentEmail: true }, + }), + ], + emailVerification: { + autoSignInAfterVerification: true, + }, + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + it("should require otp when requesting email change", async () => { + let res: Awaited< + ReturnType + >; + await vcRunWithUser(vcTestUser.email, vcTestUser.password, async () => { + res = await vcClient.emailOtp.requestEmailChange({ + newEmail: "new@test.com", + }); + }); + expect(res!.error?.status).toBe(400); + expect(res!.error?.message).toBe( + "OTP is required to verify current email", + ); + }); + + it("should reject invalid current email otp when requesting email change", async () => { + let res: Awaited< + ReturnType + >; + await vcRunWithUser(vcTestUser.email, vcTestUser.password, async () => { + res = await vcClient.emailOtp.requestEmailChange({ + newEmail: "new@test.com", + otp: "000000", + }); + }); + expect(res!.error?.status).toBe(400); + expect(res!.error?.code).toBe("INVALID_OTP"); + }); + + it("should reject when no email-verification OTP was requested for current email", async () => { + let res: Awaited< + ReturnType + >; + await vcRunWithUser(vcTestUser.email, vcTestUser.password, async () => { + res = await vcClient.emailOtp.requestEmailChange({ + newEmail: "new@test.com", + otp: "123456", + }); + }); + expect(res!.error?.status).toBe(400); + expect(res!.error?.code).toBe("INVALID_OTP"); + }); + + it("should reject expired current email OTP when requesting email change", async () => { + const { + client: expClient, + testUser: expTestUser, + runWithUser: expRunWithUser, + } = await getTestInstance( + { + plugins: [ + bearer(), + emailOTP({ + async sendVerificationOTP({ otp: _otp, type }) { + if (type === "email-verification") { + currentEmailOtp = _otp; + } + }, + sendVerificationOnSignUp: true, + changeEmail: { enabled: true, verifyCurrentEmail: true }, + expiresIn: 60, + }), + ], + emailVerification: { + autoSignInAfterVerification: true, + }, + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + await expRunWithUser( + expTestUser.email, + expTestUser.password, + async () => { + await expClient.emailOtp.sendVerificationOtp({ + email: expTestUser.email, + type: "email-verification", + }); + }, + ); + vi.useFakeTimers(); + await vi.advanceTimersByTimeAsync(61 * 1000); + + let res: Awaited< + ReturnType + >; + await expRunWithUser( + expTestUser.email, + expTestUser.password, + async () => { + res = await expClient.emailOtp.requestEmailChange({ + newEmail: "new@test.com", + otp: currentEmailOtp, + }); + }, + ); + expect(res!.error?.status).toBe(400); + expect(res!.error?.code).toBe("OTP_EXPIRED"); + }); + + it("should send change-email OTP when valid current email OTP is provided", async () => { + const newEmail = "verified-change@test.com"; + verifyCurrentOtpFn.mockClear(); + await vcRunWithUser(vcTestUser.email, vcTestUser.password, async () => { + await vcClient.emailOtp.sendVerificationOtp({ + email: vcTestUser.email, + type: "email-verification", + }); + }); + + expect(currentEmailOtp).toBeTruthy(); + await vcRunWithUser(vcTestUser.email, vcTestUser.password, async () => { + const res = await vcClient.emailOtp.requestEmailChange({ + newEmail, + otp: currentEmailOtp, + }); + expect(res.data?.success).toBe(true); + expect(res.error).toBeFalsy(); + }); + expect(verifyCurrentOtpFn).toHaveBeenCalledWith( + newEmail, + expect.any(String), + "change-email", + ); + }); + }); + }); + + describe("change", () => { + it("should change email with otp", async () => { + const userToChange = { + email: "user-to-change@test.com", + password: "password123", + name: "User To Change", + }; + await client.signUp.email(userToChange); + + const newEmail = "changed-email@test.com"; + await runWithUser(userToChange.email, userToChange.password, async () => { + const requestRes = await client.emailOtp.requestEmailChange({ + newEmail, + }); + expect(requestRes.data?.success).toBe(true); + }); + expect(otpFn).toHaveBeenCalledWith( + newEmail, + expect.any(String), + "change-email", + ); + + let sessionEmail: string | undefined; + await runWithUser(userToChange.email, userToChange.password, async () => { + const changeRes = await client.emailOtp.changeEmail({ + newEmail, + otp, + }); + expect(changeRes.data?.success).toBe(true); + expect(changeRes.error).toBeFalsy(); + const session = await client.getSession(); + sessionEmail = session.data?.user.email; + }); + expect(sessionEmail).toBe(newEmail); + }); + + it("should not change email if session does not exist", async () => { + const res = await client.emailOtp.changeEmail({ + newEmail: "other@test.com", + otp: "123456", + }); + expect(res.error?.status).toBe(401); + expect(res.error?.code).toBe("UNAUTHORIZED"); + }); + + it("should not change email if session is invalid", async () => { + const res = await client.emailOtp.changeEmail({ + newEmail: "other@test.com", + otp: "123456", + fetchOptions: { + headers: new Headers({ + Authorization: "Bearer invalid-session-token", + }), + }, + }); + expect(res.error?.status).toBe(401); + expect(res.error?.code).toBe("UNAUTHORIZED"); + }); + + it("should not change email if session contains different email from otp request email", async () => { + const newEmail = "target-email@test.com"; + await runWithUser(testUser.email, testUser.password, async () => { + const requestRes = await client.emailOtp.requestEmailChange({ + newEmail, + }); + expect(requestRes.data?.success).toBe(true); + }); + + const otherUser = { + email: "other-account@test.com", + password: "password123", + name: "Other Account", + }; + await client.signUp.email(otherUser); + + let changeRes: Awaited>; + await runWithUser(otherUser.email, otherUser.password, async () => { + changeRes = await client.emailOtp.changeEmail({ + newEmail, + otp, + }); + }); + expect(changeRes!.error?.status).toBe(400); + expect(changeRes!.error?.code).toBe("INVALID_OTP"); + }); + + it("should not change email if new email is different from otp request email", async () => { + const requestedNewEmail = "requested@test.com"; + const wrongNewEmail = "wrong@test.com"; + await runWithUser(testUser.email, testUser.password, async () => { + const requestRes = await client.emailOtp.requestEmailChange({ + newEmail: requestedNewEmail, + }); + expect(requestRes.data?.success).toBe(true); + }); + + let changeRes: Awaited>; + await runWithUser(testUser.email, testUser.password, async () => { + changeRes = await client.emailOtp.changeEmail({ + newEmail: wrongNewEmail, + otp, + }); + }); + expect(changeRes!.error?.status).toBe(400); + expect(changeRes!.error?.code).toBe("INVALID_OTP"); + }); + + it("should not change email if otp is invalid", async () => { + const newEmail = "another-new@test.com"; + await runWithUser(testUser.email, testUser.password, async () => { + const requestRes = await client.emailOtp.requestEmailChange({ + newEmail, + }); + expect(requestRes.data?.success).toBe(true); + }); + + let changeRes: Awaited>; + await runWithUser(testUser.email, testUser.password, async () => { + changeRes = await client.emailOtp.changeEmail({ + newEmail, + otp: "000000", + }); + }); + expect(changeRes!.error?.status).toBe(400); + expect(changeRes!.error?.code).toBe("INVALID_OTP"); + }); + + it("should not change email if otp is expired", async () => { + const newEmail = "expired-otp@test.com"; + const { + client: expClient, + testUser: expTestUser, + runWithUser: expRunWithUser, + } = await getTestInstance( + { + plugins: [ + bearer(), + emailOTP({ + async sendVerificationOTP({ otp: _otp }) { + otp = _otp; + }, + sendVerificationOnSignUp: true, + expiresIn: 60, + changeEmail: { enabled: true }, + }), + ], + emailVerification: { + autoSignInAfterVerification: true, + }, + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + await expRunWithUser( + expTestUser.email, + expTestUser.password, + async () => { + const requestRes = await expClient.emailOtp.requestEmailChange({ + newEmail, + }); + expect(requestRes.data?.success).toBe(true); + }, + ); + vi.useFakeTimers(); + await vi.advanceTimersByTimeAsync(61 * 1000); + + let changeRes: Awaited>; + await expRunWithUser( + expTestUser.email, + expTestUser.password, + async () => { + changeRes = await expClient.emailOtp.changeEmail({ + newEmail, + otp, + }); + }, + ); + expect(changeRes!.error?.status).toBe(400); + expect(changeRes!.error?.code).toBe("OTP_EXPIRED"); + }); + + it("should call beforeEmailVerification callback when email is updated", async () => { + const beforeEmailVerification = vi.fn(); + let callbackOtp = ""; + const { + client: cbClient, + testUser: cbTestUser, + runWithUser: cbRunWithUser, + } = await getTestInstance( + { + plugins: [ + bearer(), + emailOTP({ + async sendVerificationOTP({ otp: _otp }) { + callbackOtp = _otp; + }, + sendVerificationOnSignUp: true, + changeEmail: { enabled: true }, + }), + ], + emailVerification: { + autoSignInAfterVerification: true, + beforeEmailVerification, + }, + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + const newEmail = "before-cb@test.com"; + await cbRunWithUser(cbTestUser.email, cbTestUser.password, async () => { + await cbClient.emailOtp.requestEmailChange({ newEmail }); + }); + await cbRunWithUser(cbTestUser.email, cbTestUser.password, async () => { + await cbClient.emailOtp.changeEmail({ + newEmail, + otp: callbackOtp, + }); + }); + expect(beforeEmailVerification).toHaveBeenCalledTimes(1); + expect(beforeEmailVerification).toHaveBeenCalledWith( + expect.objectContaining({ + email: cbTestUser.email, + }), + expect.any(Object), + ); + }); + + it("should call afterEmailVerification callback when email is updated", async () => { + const afterEmailVerification = vi.fn(); + let callbackOtp = ""; + const { + client: cbClient, + testUser: cbTestUser, + runWithUser: cbRunWithUser, + } = await getTestInstance( + { + plugins: [ + bearer(), + emailOTP({ + async sendVerificationOTP({ otp: _otp }) { + callbackOtp = _otp; + }, + sendVerificationOnSignUp: true, + changeEmail: { enabled: true }, + }), + ], + emailVerification: { + autoSignInAfterVerification: true, + afterEmailVerification, + }, + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + const newEmail = "after-cb@test.com"; + await cbRunWithUser(cbTestUser.email, cbTestUser.password, async () => { + await cbClient.emailOtp.requestEmailChange({ newEmail }); + }); + await cbRunWithUser(cbTestUser.email, cbTestUser.password, async () => { + await cbClient.emailOtp.changeEmail({ + newEmail, + otp: callbackOtp, + }); + }); + expect(afterEmailVerification).toHaveBeenCalledTimes(1); + expect(afterEmailVerification).toHaveBeenCalledWith( + expect.objectContaining({ + email: newEmail, + emailVerified: true, + }), + expect.any(Object), + ); + }); + }); +}); + describe("email-otp-verify", async () => { const otpFn = vi.fn(); const otp = [""]; @@ -642,13 +1244,15 @@ describe("custom generate otpFn", async () => { }); describe("custom storeOTP", async () => { + type SendVerificationOtpData = { + email: string; + otp: string; + type: "sign-in" | "email-verification" | "forget-password" | "change-email"; + }; + // Testing hashed OTPs. describe("hashed", async () => { - let sendVerificationOtpFn = async (data: { - email: string; - otp: string; - type: "sign-in" | "email-verification" | "forget-password"; - }) => {}; + let sendVerificationOtpFn = async (data: SendVerificationOtpData) => {}; function getTheSentOTP() { let gotOtp: string | null = null; @@ -742,11 +1346,7 @@ describe("custom storeOTP", async () => { // Testing encrypted OTPs. describe("encrypted", async () => { - let sendVerificationOtpFn = async (data: { - email: string; - otp: string; - type: "sign-in" | "email-verification" | "forget-password"; - }) => {}; + let sendVerificationOtpFn = async (data: SendVerificationOtpData) => {}; function getTheSentOTP() { let gotOtp: string | null = null; @@ -833,11 +1433,7 @@ describe("custom storeOTP", async () => { }); describe("custom encryptor", async () => { - let sendVerificationOtpFn = async (data: { - email: string; - otp: string; - type: "sign-in" | "email-verification" | "forget-password"; - }) => {}; + let sendVerificationOtpFn = async (data: SendVerificationOtpData) => {}; function getTheSentOTP() { let gotOtp: string | null = null; @@ -930,11 +1526,7 @@ describe("custom storeOTP", async () => { }); describe("custom hasher", async () => { - let sendVerificationOtpFn = async (data: { - email: string; - otp: string; - type: "sign-in" | "email-verification" | "forget-password"; - }) => {}; + let sendVerificationOtpFn = async (data: SendVerificationOtpData) => {}; function getTheSentOTP() { let gotOtp: string | null = null; diff --git a/packages/better-auth/src/plugins/email-otp/index.ts b/packages/better-auth/src/plugins/email-otp/index.ts index 53429f1db4..028666cef1 100644 --- a/packages/better-auth/src/plugins/email-otp/index.ts +++ b/packages/better-auth/src/plugins/email-otp/index.ts @@ -6,10 +6,12 @@ import { getEndpointResponse } from "../../utils/plugin-helper"; import { EMAIL_OTP_ERROR_CODES } from "./error-codes"; import { storeOTP } from "./otp-token"; import { + changeEmailEmailOTP, checkVerificationOTP, createVerificationOTP, forgetPasswordEmailOTP, getVerificationOTP, + requestEmailChangeEmailOTP, requestPasswordResetEmailOTP, resetPasswordEmailOTP, sendVerificationOTP, @@ -78,6 +80,8 @@ export const emailOTP = (options: EmailOTPOptions) => { requestPasswordResetEmailOTP: requestPasswordResetEmailOTP(opts), forgetPasswordEmailOTP: forgetPasswordEmailOTP(opts), resetPasswordEmailOTP: resetPasswordEmailOTP(opts), + requestEmailChangeEmailOTP: requestEmailChangeEmailOTP(opts), + changeEmailEmailOTP: changeEmailEmailOTP(opts), }, hooks: { after: [ @@ -170,6 +174,20 @@ export const emailOTP = (options: EmailOTPOptions) => { window: opts.rateLimit?.window || 60, max: opts.rateLimit?.max || 3, }, + { + pathMatcher(path) { + return path === "/email-otp/request-email-change"; + }, + window: opts.rateLimit?.window || 60, + max: opts.rateLimit?.max || 3, + }, + { + pathMatcher(path) { + return path === "/email-otp/change-email"; + }, + window: opts.rateLimit?.window || 60, + max: opts.rateLimit?.max || 3, + }, ], options, $ERROR_CODES: EMAIL_OTP_ERROR_CODES, diff --git a/packages/better-auth/src/plugins/email-otp/routes.ts b/packages/better-auth/src/plugins/email-otp/routes.ts index 8ec60db46f..e20e8438d8 100644 --- a/packages/better-auth/src/plugins/email-otp/routes.ts +++ b/packages/better-auth/src/plugins/email-otp/routes.ts @@ -3,7 +3,11 @@ import { createAuthEndpoint } from "@better-auth/core/api"; import { BASE_ERROR_CODES } from "@better-auth/core/error"; import { deprecate } from "@better-auth/core/utils/deprecate"; import * as z from "zod"; -import { APIError, getSessionFromCtx } from "../../api"; +import { + APIError, + getSessionFromCtx, + sensitiveSessionMiddleware, +} from "../../api"; import { setCookieCache, setSessionCookie } from "../../cookies"; import { generateRandomString, symmetricDecrypt } from "../../crypto"; import { parseUserInput, parseUserOutput } from "../../db/schema"; @@ -12,7 +16,12 @@ import { storeOTP, verifyStoredOTP } from "./otp-token"; import type { EmailOTPOptions } from "./types"; import { splitAtLastColon } from "./utils"; -const types = ["email-verification", "sign-in", "forget-password"] as const; +const types = [ + "email-verification", + "sign-in", + "forget-password", + "change-email", +] as const; type WithRequired = T & { [P in K]-?: T[P] }; @@ -89,6 +98,16 @@ export const sendVerificationOTP = (opts: RequiredEmailOTPOptions) => if (!isValidEmail.success) { throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL); } + + // Enforce using the correct endpoint for change email OTP + if (ctx.body.type === "change-email") { + ctx.context.logger.error( + "Use the /email-otp/request-email-change endpoint to send OTP for changing email", + ); + throw APIError.fromStatus("BAD_REQUEST", { + message: "Invalid OTP type", + }); + } const otp = opts.generateOTP({ email, type: ctx.body.type }, ctx) || defaultOTPGenerator(opts); @@ -986,6 +1005,350 @@ export const resetPasswordEmailOTP = (opts: RequiredEmailOTPOptions) => }, ); +const requestEmailChangeEmailOTPBodySchema = z.object({ + newEmail: z.string().meta({ + description: "New email address to send the OTP", + }), + otp: z.string().optional().meta({ + description: + "OTP sent to the current email. This is required if changeEmail.verifyCurrentEmail option is set to true", + }), +}); + +/** + * ### Endpoint + * + * POST `/email-otp/request-email-change` + * + * ### API Methods + * + * **server:** + * `auth.api.requestEmailChangeEmailOTP` + * + * **client:** + * `authClient.emailOtp.requestEmailChange` + * + * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/email-otp#change-email-with-otp) + */ +export const requestEmailChangeEmailOTP = (opts: RequiredEmailOTPOptions) => + createAuthEndpoint( + "/email-otp/request-email-change", + { + method: "POST", + body: requestEmailChangeEmailOTPBodySchema, + use: [sensitiveSessionMiddleware], + metadata: { + openapi: { + operationId: "requestEmailChangeWithEmailOTP", + description: + "Request email change with verification OTP sent to the new email", + responses: { + 200: { + description: "Success", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + async (ctx) => { + if (!opts.changeEmail?.enabled) { + ctx.context.logger.error("Change email with OTP is disabled."); + throw APIError.fromStatus("BAD_REQUEST", { + message: "Change email with OTP is disabled", + }); + } + + const email = ctx.context.session.user.email.toLowerCase(); + const newEmail = ctx.body.newEmail.toLowerCase(); + const isValidEmail = z.email().safeParse(newEmail); + if (!isValidEmail.success) { + throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL); + } + if (newEmail === email) { + ctx.context.logger.error("Email is the same"); + throw APIError.fromStatus("BAD_REQUEST", { + message: "Email is the same", + }); + } + + if (opts.changeEmail?.verifyCurrentEmail) { + if (!ctx.body.otp) { + throw APIError.fromStatus("BAD_REQUEST", { + message: "OTP is required to verify current email", + }); + } + + const currentEmailVerificationValue = + await ctx.context.internalAdapter.findVerificationValue( + `email-verification-otp-${email}`, + ); + if (!currentEmailVerificationValue) { + throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP); + } + if (currentEmailVerificationValue.expiresAt < new Date()) { + await ctx.context.internalAdapter.deleteVerificationValue( + currentEmailVerificationValue.id, + ); + throw APIError.from("BAD_REQUEST", ERROR_CODES.OTP_EXPIRED); + } + + const [otpValue, attempts] = splitAtLastColon( + currentEmailVerificationValue.value, + ); + const allowedAttempts = opts?.allowedAttempts || 3; + if (attempts && parseInt(attempts) >= allowedAttempts) { + await ctx.context.internalAdapter.deleteVerificationValue( + currentEmailVerificationValue.id, + ); + 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.updateVerificationValue( + currentEmailVerificationValue.id, + { + value: `${otpValue}:${parseInt(attempts || "0") + 1}`, + }, + ); + throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP); + } + await ctx.context.internalAdapter.deleteVerificationValue( + currentEmailVerificationValue.id, + ); + } else { + if (ctx.body.otp) { + ctx.context.logger.warn( + "OTP provided but not required for verifying current email. " + + "If you want to require OTP verification for current email, " + + "please set the changeEmail.verifyCurrentEmail option to true in the configuration", + ); + } + } + + const otp = + opts.generateOTP({ email: newEmail, type: "change-email" }, ctx) || + defaultOTPGenerator(opts); + const storedOTP = await storeOTP(ctx, opts, otp); + await ctx.context.internalAdapter.createVerificationValue({ + value: `${storedOTP}:0`, + identifier: `change-email-otp-${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}`, + ); + return ctx.json({ + success: true, + }); + } + + await ctx.context.runInBackgroundOrAwait( + opts.sendVerificationOTP( + { + email: newEmail, + otp, + type: "change-email", + }, + ctx, + ), + ); + return ctx.json({ + success: true, + }); + }, + ); + +const changeEmailEmailOTPBodySchema = z.object({ + newEmail: z.string().meta({ + description: "New email address to verify and change to", + }), + otp: z.string().meta({ + description: "OTP sent to the new email", + }), +}); + +/** + * ### Endpoint + * + * POST `/email-otp/change-email` + * + * ### API Methods + * + * **server:** + * `auth.api.changeEmailEmailOTP` + * + * **client:** + * `authClient.emailOtp.changeEmail` + * + * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/email-otp#change-email-with-otp) + */ +export const changeEmailEmailOTP = (opts: RequiredEmailOTPOptions) => + createAuthEndpoint( + "/email-otp/change-email", + { + method: "POST", + body: changeEmailEmailOTPBodySchema, + use: [sensitiveSessionMiddleware], + metadata: { + openapi: { + operationId: "changeEmailWithEmailOTP", + description: + "Verify new email with OTP and change the email if verification is successful", + responses: { + 200: { + description: "Success", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + async (ctx) => { + if (!opts.changeEmail?.enabled) { + ctx.context.logger.error("Change email with OTP is disabled."); + throw APIError.fromStatus("BAD_REQUEST", { + message: "Change email with OTP is disabled", + }); + } + + const session = ctx.context.session; + + const email = session.user.email.toLowerCase(); + const newEmail = ctx.body.newEmail.toLowerCase(); + const isValidNewEmail = z.email().safeParse(newEmail); + if (!isValidNewEmail.success) { + throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL); + } + if (newEmail === email) { + ctx.context.logger.error("Email is the same"); + throw APIError.fromStatus("BAD_REQUEST", { + message: "Email is the same", + }); + } + + const verificationValue = + await ctx.context.internalAdapter.findVerificationValue( + `change-email-otp-${email}-${newEmail}`, + ); + if (!verificationValue) { + throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP); + } + if (verificationValue.expiresAt < new Date()) { + await ctx.context.internalAdapter.deleteVerificationValue( + verificationValue.id, + ); + throw APIError.from("BAD_REQUEST", ERROR_CODES.OTP_EXPIRED); + } + + const [otpValue, attempts] = splitAtLastColon(verificationValue.value); + const allowedAttempts = opts?.allowedAttempts || 3; + if (attempts && parseInt(attempts) >= allowedAttempts) { + await ctx.context.internalAdapter.deleteVerificationValue( + verificationValue.id, + ); + 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.updateVerificationValue( + verificationValue.id, + { + value: `${otpValue}:${parseInt(attempts || "0") + 1}`, + }, + ); + throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_OTP); + } + await ctx.context.internalAdapter.deleteVerificationValue( + verificationValue.id, + ); + + const currentUser = + await ctx.context.internalAdapter.findUserByEmail(email); + if (!currentUser) { + /** + * safe to leak the existence of a user as a valid OTP has been provided + */ + throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.USER_NOT_FOUND); + } + + const existingUserWithNewEmail = + await ctx.context.internalAdapter.findUserByEmail(newEmail); + if (existingUserWithNewEmail) { + /** + * safe to leak the existence of a user as a valid OTP has been provided + */ + throw APIError.fromStatus("BAD_REQUEST", { + message: "Email already in use", + }); + } + + if (ctx.context.options.emailVerification?.beforeEmailVerification) { + await ctx.context.options.emailVerification.beforeEmailVerification( + currentUser.user, + ctx.request, + ); + } + const updatedUser = await ctx.context.internalAdapter.updateUser( + currentUser.user.id, + { + email: newEmail, + emailVerified: true, + }, + ); + if (ctx.context.options.emailVerification?.afterEmailVerification) { + await ctx.context.options.emailVerification.afterEmailVerification( + updatedUser, + ctx.request, + ); + } + await setSessionCookie(ctx, { + session: session.session, + user: { + ...session.user, + email: newEmail, + emailVerified: true, + }, + }); + + return ctx.json({ + success: true, + }); + }, + ); + const defaultOTPGenerator = (options: EmailOTPOptions) => generateRandomString(options.otpLength ?? 6, "0-9"); diff --git a/packages/better-auth/src/plugins/email-otp/types.ts b/packages/better-auth/src/plugins/email-otp/types.ts index e6e3ddf51e..1591b8337f 100644 --- a/packages/better-auth/src/plugins/email-otp/types.ts +++ b/packages/better-auth/src/plugins/email-otp/types.ts @@ -11,7 +11,11 @@ export interface EmailOTPOptions { data: { email: string; otp: string; - type: "sign-in" | "email-verification" | "forget-password"; + type: + | "sign-in" + | "email-verification" + | "forget-password" + | "change-email"; }, ctx?: GenericEndpointContext | undefined, ) => Promise; @@ -33,7 +37,11 @@ export interface EmailOTPOptions { generateOTP?: ( data: { email: string; - type: "sign-in" | "email-verification" | "forget-password"; + type: + | "sign-in" + | "email-verification" + | "forget-password" + | "change-email"; }, ctx?: GenericEndpointContext, ) => string | undefined; @@ -73,6 +81,18 @@ export interface EmailOTPOptions { } ) | undefined; + /** + * Change email configuration for the change email with OTP flow + * + * @default { + * enabled: false, + * verifyCurrentEmail: false, + * } + */ + changeEmail?: { + enabled?: boolean; + verifyCurrentEmail?: boolean; + }; /** * Override the default email verification to use email otp instead *