mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-26 02:16:23 -05:00
feat(phone-number): add server-side OTP consumption API (#9766)
This commit is contained in:
@@ -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.
|
||||
@@ -110,6 +110,33 @@ After the OTP is sent, users can verify their phone number by providing the code
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof auth.api>().toHaveProperty("consumePhoneNumberOTP");
|
||||
expectTypeOf<typeof client.phoneNumber>().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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user