From bf39cbf13f3b934f728cde72b1e7ebdc4c85f641 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Fri, 12 Jun 2026 05:28:16 +0530 Subject: [PATCH] feat(phone-number): add server-side OTP consumption API (#9766) --- .changeset/consume-phone-number-otp.md | 5 + docs/content/docs/plugins/phone-number.mdx | 27 +++ .../src/plugins/phone-number/index.ts | 4 + .../plugins/phone-number/phone-number.test.ts | 148 +++++++++++++++- .../src/plugins/phone-number/routes.ts | 165 ++++++++++-------- 5 files changed, 275 insertions(+), 74 deletions(-) create mode 100644 .changeset/consume-phone-number-otp.md diff --git a/.changeset/consume-phone-number-otp.md b/.changeset/consume-phone-number-otp.md new file mode 100644 index 0000000000..6b036ac6b4 --- /dev/null +++ b/.changeset/consume-phone-number-otp.md @@ -0,0 +1,5 @@ +--- +"better-auth": minor +--- + +Add a server-only `auth.api.consumePhoneNumberOTP` API for custom phone OTP flows that need to verify and consume a code without creating or updating users or sessions. diff --git a/docs/content/docs/plugins/phone-number.mdx b/docs/content/docs/plugins/phone-number.mdx index 03a79a69b7..9854ac0004 100644 --- a/docs/content/docs/plugins/phone-number.mdx +++ b/docs/content/docs/plugins/phone-number.mdx @@ -110,6 +110,33 @@ After the OTP is sent, users can verify their phone number by providing the code ``` +### Consume an OTP on the Server + +For custom sign-up or account-linking flows, consume an OTP without creating or +updating a user or session. This server-only API is exposed through `auth.api`; +it does not register an HTTP route or generate an `authClient` method. + +```ts title="auth.ts" +const result = await auth.api.consumePhoneNumberOTP({ + body: { + phoneNumber: "+1234567890", + code: "123456" + } +}) +``` + +This method uses the same validation and consumption behavior as +`verifyPhoneNumber`; it does not introduce a stronger concurrency guarantee. +If your server flow can receive parallel redemption attempts and requires +strict single-use acceptance, configure [`verifyOTP`](#verifyotp) with a +provider that atomically consumes accepted codes. + +Call this method inside the server-side flow that uses the verification result; +it does not return a session or reusable proof of verification. Validate any +inputs that do not depend on successful OTP verification first. If you expose +this capability through your own public endpoint, apply rate limiting and +appropriate abuse protection there. + ### Allow Sign-Up with Phone Number To allow users to sign up using their phone number, you can pass `signUpOnVerification` option to your plugin configuration. It requires you to pass `getTempEmail` function to generate a temporary email for the user. diff --git a/packages/better-auth/src/plugins/phone-number/index.ts b/packages/better-auth/src/plugins/phone-number/index.ts index ac43e7fecb..fac646d235 100644 --- a/packages/better-auth/src/plugins/phone-number/index.ts +++ b/packages/better-auth/src/plugins/phone-number/index.ts @@ -6,6 +6,7 @@ import { PACKAGE_VERSION } from "../../version"; import { PHONE_NUMBER_ERROR_CODES } from "./error-codes"; import type { RequiredPhoneNumberOptions } from "./routes"; import { + consumePhoneNumberOTP, requestPasswordResetPhoneNumber, resetPasswordPhoneNumber, sendPhoneNumberOTP, @@ -87,6 +88,9 @@ export const phoneNumber = (options?: PhoneNumberOptions | undefined) => { sendPhoneNumberOTP: sendPhoneNumberOTP( opts as RequiredPhoneNumberOptions, ), + consumePhoneNumberOTP: consumePhoneNumberOTP( + opts as RequiredPhoneNumberOptions, + ), verifyPhoneNumber: verifyPhoneNumber(opts as RequiredPhoneNumberOptions), requestPasswordResetPhoneNumber: requestPasswordResetPhoneNumber( opts as RequiredPhoneNumberOptions, 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 d69b03b22f..8ab64a9190 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 @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; import { bearer } from "../bearer"; import { phoneNumber } from "."; @@ -106,6 +106,126 @@ describe("phone-number", async () => { }); }); +/** + * @see https://github.com/better-auth/better-auth/issues/9754 + */ +describe("consume phone-number OTP", async () => { + let otp = ""; + const callbackOnVerification = vi.fn(); + const { auth, client, db } = await getTestInstance( + { + plugins: [ + phoneNumber({ + async sendOTP({ code }) { + otp = code; + }, + callbackOnVerification, + }), + ], + }, + { + disableTestUser: true, + clientOptions: { + plugins: [phoneNumberClient()], + }, + }, + ); + + it("consumes an OTP server-side without creating a user or session", async () => { + const phoneNumber = "+251911121301"; + await client.phoneNumber.sendOtp({ phoneNumber }); + + const result = await auth.api.consumePhoneNumberOTP({ + body: { phoneNumber, code: otp }, + }); + + expect(result).toEqual({ status: true }); + expect(callbackOnVerification).not.toHaveBeenCalled(); + expect( + await db.findMany({ + model: "user", + where: [{ field: "phoneNumber", value: phoneNumber }], + }), + ).toHaveLength(0); + expect( + await (await auth.$context).internalAdapter.findVerificationValue( + phoneNumber, + ), + ).toBeNull(); + }); + + it("is exposed through the server API only", async () => { + expectTypeOf().toHaveProperty("consumePhoneNumberOTP"); + expectTypeOf().not.toHaveProperty("consumeOtp"); + + const response = await client.$fetch("/phone-number/consume-otp", { + method: "POST", + body: { phoneNumber: "+251911121302", code: "123456" }, + }); + expect(response.error?.status).toBe(404); + }); + + it("keeps attempt limiting active when a stored attempt count is corrupt", async () => { + const phoneNumber = "+251911121303"; + await client.phoneNumber.sendOtp({ phoneNumber }); + await (await auth.$context).internalAdapter.updateVerificationByIdentifier( + phoneNumber, + { + value: `${otp}:not-a-number`, + }, + ); + + for (let attempt = 0; attempt < 3; attempt++) { + const res = await client.phoneNumber.verify({ + phoneNumber, + code: "000000", + }); + expect(res.error?.status).toBe(400); + expect(res.error?.message).toBe("Invalid OTP"); + } + + const blocked = await client.phoneNumber.verify({ + phoneNumber, + code: "000000", + }); + expect(blocked.error?.status).toBe(403); + expect(blocked.error?.message).toBe("Too many attempts"); + }); + + it("honors allowedAttempts set to 0", async () => { + let zeroAttemptOtp = ""; + const { auth: zeroAttemptAuth, client: zeroAttemptClient } = + await getTestInstance( + { + plugins: [ + phoneNumber({ + async sendOTP({ code }) { + zeroAttemptOtp = code; + }, + allowedAttempts: 0, + }), + ], + }, + { + disableTestUser: true, + clientOptions: { + plugins: [phoneNumberClient()], + }, + }, + ); + const testPhoneNumber = "+251911121304"; + await zeroAttemptClient.phoneNumber.sendOtp({ + phoneNumber: testPhoneNumber, + }); + + await expect( + zeroAttemptAuth.api.consumePhoneNumberOTP({ + body: { phoneNumber: testPhoneNumber, code: zeroAttemptOtp }, + }), + ).rejects.toThrow("Too many attempts"); + }); +}); + /** * @see https://github.com/better-auth/better-auth/issues/4839 */ @@ -1035,7 +1155,7 @@ describe("signUpOnVerification with additionalFields", async () => { describe("custom verifyOTP", async () => { const mockVerifyOTP = vi.fn(); - const { client, sessionSetter } = await getTestInstance( + const { auth, client, sessionSetter } = await getTestInstance( { plugins: [ phoneNumber({ @@ -1168,6 +1288,30 @@ describe("custom verifyOTP", async () => { expect(user.data?.user.phoneNumber).toBe(updatedPhoneNumber); expect(user.data?.user.phoneNumberVerified).toBe(true); }); + + /** + * @see https://github.com/better-auth/better-auth/issues/9754 + */ + it("should delegate server-side consumption to custom verifyOTP without an internal record", async () => { + const externalPhoneNumber = "+12223334444"; + mockVerifyOTP.mockResolvedValueOnce(true); + + const result = await auth.api.consumePhoneNumberOTP({ + body: { + phoneNumber: externalPhoneNumber, + code: "external-code", + }, + }); + + expect(result).toEqual({ status: true }); + expect(mockVerifyOTP).toHaveBeenCalledWith( + { + phoneNumber: externalPhoneNumber, + code: "external-code", + }, + expect.anything(), + ); + }); }); // The provisioning gate reaches phone-number sign-up through the createUser diff --git a/packages/better-auth/src/plugins/phone-number/routes.ts b/packages/better-auth/src/plugins/phone-number/routes.ts index a204a7a31c..ec7a1e89f9 100644 --- a/packages/better-auth/src/plugins/phone-number/routes.ts +++ b/packages/better-auth/src/plugins/phone-number/routes.ts @@ -1,3 +1,4 @@ +import type { GenericEndpointContext } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import * as z from "zod"; @@ -7,6 +8,7 @@ import { generateRandomString } from "../../crypto/random"; import { parseUserInput } from "../../db"; import { parseUserOutput } from "../../db/schema"; import type { Account } from "../../types"; +import { HIDE_METADATA } from "../../utils"; import { getDate } from "../../utils/date"; import { PHONE_NUMBER_ERROR_CODES } from "./error-codes"; import type { PhoneNumberOptions, UserWithPhoneNumber } from "./types"; @@ -208,6 +210,15 @@ const sendPhoneNumberOTPBodySchema = z.object({ }), }); +const consumePhoneNumberOTPBodySchema = z.object({ + phoneNumber: z.string().meta({ + description: 'Phone number to verify. Eg: "+1234567890"', + }), + code: z.string().meta({ + description: 'OTP code. Eg: "123456"', + }), +}); + /** * ### Endpoint * @@ -307,6 +318,87 @@ export const sendPhoneNumberOTP = (opts: RequiredPhoneNumberOptions) => }, ); +async function verifyAndConsumePhoneNumberOTP( + ctx: GenericEndpointContext, + opts: RequiredPhoneNumberOptions, + body: { + phoneNumber: string; + code: string; + }, +) { + const { phoneNumber, code } = body; + if (opts.verifyOTP) { + const isValid = await opts.verifyOTP({ phoneNumber, code }, ctx); + if (!isValid) { + throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP); + } + + await ctx.context.internalAdapter.deleteVerificationByIdentifier( + phoneNumber, + ); + return; + } + + const otp = + await ctx.context.internalAdapter.findVerificationValue(phoneNumber); + if (!otp || otp.expiresAt < new Date()) { + if (otp && otp.expiresAt < new Date()) { + throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_EXPIRED); + } + throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_NOT_FOUND); + } + + const [otpValue, attemptsValue] = otp.value.split(":"); + const attempts = parseVerificationAttempts(attemptsValue); + const allowedAttempts = opts.allowedAttempts ?? 3; + if (attempts >= allowedAttempts) { + await ctx.context.internalAdapter.deleteVerificationByIdentifier( + phoneNumber, + ); + throw APIError.from( + "FORBIDDEN", + PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS, + ); + } + if (otpValue !== code) { + await ctx.context.internalAdapter.updateVerificationByIdentifier( + phoneNumber, + { + value: `${otpValue}:${attempts + 1}`, + }, + ); + throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP); + } + + await ctx.context.internalAdapter.deleteVerificationByIdentifier(phoneNumber); +} + +function parseVerificationAttempts(value: string | undefined) { + const attempts = Number(value ?? 0); + return Number.isSafeInteger(attempts) && attempts > 0 ? attempts : 0; +} + +/** + * ### API Methods + * + * **server:** + * `auth.api.consumePhoneNumberOTP` + * + * Verifies and consumes an OTP without updating a user or creating a session. + */ +export const consumePhoneNumberOTP = (opts: RequiredPhoneNumberOptions) => + createAuthEndpoint( + { + method: "POST", + body: consumePhoneNumberOTPBodySchema, + metadata: HIDE_METADATA, + }, + async (ctx) => { + await verifyAndConsumePhoneNumberOTP(ctx, opts, ctx.body); + return ctx.json({ status: true }); + }, + ); + const verifyPhoneNumberBodySchema = z .object({ /** @@ -465,78 +557,7 @@ export const verifyPhoneNumber = (opts: RequiredPhoneNumberOptions) => }, }, async (ctx) => { - if (opts?.verifyOTP) { - // Use custom verifyOTP if provided - const isValid = await opts.verifyOTP( - { - phoneNumber: ctx.body.phoneNumber, - code: ctx.body.code, - }, - ctx, - ); - - if (!isValid) { - throw APIError.from( - "BAD_REQUEST", - PHONE_NUMBER_ERROR_CODES.INVALID_OTP, - ); - } - - // Clean up verification value - const otp = await ctx.context.internalAdapter.findVerificationValue( - ctx.body.phoneNumber, - ); - if (otp) { - await ctx.context.internalAdapter.deleteVerificationByIdentifier( - ctx.body.phoneNumber, - ); - } - } else { - // Default internal verification logic - const otp = await ctx.context.internalAdapter.findVerificationValue( - ctx.body.phoneNumber, - ); - - if (!otp || otp.expiresAt < new Date()) { - if (otp && otp.expiresAt < new Date()) { - throw APIError.from( - "BAD_REQUEST", - PHONE_NUMBER_ERROR_CODES.OTP_EXPIRED, - ); - } - throw APIError.from( - "BAD_REQUEST", - PHONE_NUMBER_ERROR_CODES.OTP_NOT_FOUND, - ); - } - const [otpValue, attempts] = otp.value.split(":"); - const allowedAttempts = opts?.allowedAttempts || 3; - if (attempts && parseInt(attempts) >= allowedAttempts) { - await ctx.context.internalAdapter.deleteVerificationByIdentifier( - ctx.body.phoneNumber, - ); - throw APIError.from( - "FORBIDDEN", - PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS, - ); - } - if (otpValue !== ctx.body.code) { - await ctx.context.internalAdapter.updateVerificationByIdentifier( - ctx.body.phoneNumber, - { - value: `${otpValue}:${parseInt(attempts || "0") + 1}`, - }, - ); - throw APIError.from( - "BAD_REQUEST", - PHONE_NUMBER_ERROR_CODES.INVALID_OTP, - ); - } - - await ctx.context.internalAdapter.deleteVerificationByIdentifier( - ctx.body.phoneNumber, - ); - } + await verifyAndConsumePhoneNumberOTP(ctx, opts, ctx.body); if (ctx.body.updatePhoneNumber) { const session = await getSessionFromCtx(ctx);