diff --git a/docs/content/docs/plugins/phone-number.mdx b/docs/content/docs/plugins/phone-number.mdx index bdf1d554..82b55213 100644 --- a/docs/content/docs/plugins/phone-number.mdx +++ b/docs/content/docs/plugins/phone-number.mdx @@ -203,8 +203,9 @@ export const auth = betterAuth({ - `signUpOnVerification`: An object with the following properties: - `getTempEmail`: A function that generates a temporary email for the user. It takes the phone number as an argument and returns the temporary email. - `getTempName`: A function that generates a temporary name for the user. It takes the phone number as an argument and returns the temporary name. - +- `requireVerification`: When enabled, users cannot sign in with their phone number until it has been verified. If an unverified user attempts to sign in, the server will respond with a 401 error (PHONE_NUMBER_NOT_VERIFIED) and automatically trigger an OTP send to start the verification process. + ## Schema The plugin requires 2 fields to be added to the user table diff --git a/packages/better-auth/src/plugins/phone-number/index.ts b/packages/better-auth/src/plugins/phone-number/index.ts index 8311d246..ad2bf403 100644 --- a/packages/better-auth/src/plugins/phone-number/index.ts +++ b/packages/better-auth/src/plugins/phone-number/index.ts @@ -13,6 +13,7 @@ import { getDate } from "../../utils/date"; import { setSessionCookie } from "../../cookies"; import { BASE_ERROR_CODES } from "../../error/codes"; import type { User } from "../../types"; +import { ERROR_CODES } from "./phone-number-error"; export interface UserWithPhoneNumber extends User { phoneNumber: string; @@ -62,6 +63,12 @@ export interface PhoneNumberOptions { * by default any string is accepted */ phoneNumberValidator?: (phoneNumber: string) => boolean | Promise; + /** + * Require a phone number verification before signing in + * + * @default false + */ + requireVerification?: boolean; /** * Callback when phone number is verified */ @@ -122,15 +129,6 @@ export const phoneNumber = (options?: PhoneNumberOptions) => { createdAt: "createdAt", }; - const ERROR_CODES = { - INVALID_PHONE_NUMBER: "Invalid phone number", - PHONE_NUMBER_EXIST: "Phone number already exist", - INVALID_PHONE_NUMBER_OR_PASSWORD: "Invalid phone number or password", - UNEXPECTED_ERROR: "Unexpected error", - OTP_NOT_FOUND: "OTP not found", - OTP_EXPIRED: "OTP expired", - INVALID_OTP: "Invalid OTP", - } as const; return { id: "phone-number", endpoints: { @@ -209,6 +207,23 @@ export const phoneNumber = (options?: PhoneNumberOptions) => { message: ERROR_CODES.INVALID_PHONE_NUMBER_OR_PASSWORD, }); } + if (opts.requireVerification) { + if (!user.phoneNumberVerified) { + const otp = generateOTP(opts.otpLength); + await ctx.context.internalAdapter.createVerificationValue({ + value: otp, + identifier: phoneNumber, + expiresAt: getDate(opts.expiresIn, "sec"), + }); + await opts.sendOTP?.({ + phoneNumber, + code: otp, + }); + throw new APIError("UNAUTHORIZED", { + message: ERROR_CODES.PHONE_NUMBER_NOT_VERIFIED, + }); + } + } const accounts = await ctx.context.internalAdapter.findAccountByUserId(user.id); const credentialAccount = accounts.find( diff --git a/packages/better-auth/src/plugins/phone-number/phone-number-error.ts b/packages/better-auth/src/plugins/phone-number/phone-number-error.ts new file mode 100644 index 00000000..9353f788 --- /dev/null +++ b/packages/better-auth/src/plugins/phone-number/phone-number-error.ts @@ -0,0 +1,10 @@ +export const ERROR_CODES = { + INVALID_PHONE_NUMBER: "Invalid phone number", + PHONE_NUMBER_EXIST: "Phone number already exist", + INVALID_PHONE_NUMBER_OR_PASSWORD: "Invalid phone number or password", + UNEXPECTED_ERROR: "Unexpected error", + OTP_NOT_FOUND: "OTP not found", + OTP_EXPIRED: "OTP expired", + INVALID_OTP: "Invalid OTP", + PHONE_NUMBER_NOT_VERIFIED: "Phone number not verified", +} as const; diff --git a/packages/better-auth/src/plugins/phone-number/phone-number.test.ts b/packages/better-auth/src/plugins/phone-number/phone-number.test.ts index 0e4d04ef..b570ed30 100644 --- a/packages/better-auth/src/plugins/phone-number/phone-number.test.ts +++ b/packages/better-auth/src/plugins/phone-number/phone-number.test.ts @@ -370,3 +370,55 @@ describe("reset password flow attempts", async (it) => { expect(res.data?.status).toBe(true); }); }); + +describe("phone number verification requirement", async () => { + let otp = ""; + const { customFetchImpl } = await getTestInstance({ + plugins: [ + phoneNumber({ + async sendOTP({ code }) { + otp = code; + }, + requireVerification: true, + signUpOnVerification: { + getTempEmail(phoneNumber) { + return `temp-${phoneNumber}`; + }, + }, + }), + ], + user: { + changeEmail: { + enabled: true, + }, + }, + }); + + const client = createAuthClient({ + baseURL: "http://localhost:3000", + plugins: [phoneNumberClient()], + fetchOptions: { + customFetchImpl, + }, + }); + + const testPhoneNumber = "+251911121314"; + const testPassword = "password123"; + const testEmail = "test2@test.com"; + + it("should not allow sign in with unverified phone number and trigger OTP send", async () => { + await client.signUp.email({ + email: testEmail, + password: testPassword, + name: "test", + phoneNumber: testPhoneNumber, + }); + const signInRes = await client.signIn.phoneNumber({ + phoneNumber: testPhoneNumber, + password: testPassword, + }); + expect(signInRes.error?.status).toBe(401); + expect(signInRes.error?.code).toMatch("PHONE_NUMBER_NOT_VERIFIED"); + expect(otp).toHaveLength(6); + }); +});