From ef4d27360cec8a0bc11a94e135ea4a3dd32b1969 Mon Sep 17 00:00:00 2001 From: Tushar Date: Thu, 9 Jul 2026 03:19:11 +0530 Subject: [PATCH] fix(auth): handle request clone failures in callbacks (#10336) Co-authored-by: Maxwell <145994855+ping-maxwell@users.noreply.github.com> --- .changeset/fix-request-clone-callbacks.md | 5 ++ .../src/api/routes/email-verification.test.ts | 67 +++++++++++++++++++ .../src/api/routes/email-verification.ts | 5 +- .../better-auth/src/api/routes/sign-in.ts | 3 +- .../better-auth/src/api/routes/sign-up.ts | 5 +- .../better-auth/src/utils/request.test.ts | 35 ++++++++++ packages/better-auth/src/utils/request.ts | 23 +++++++ 7 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-request-clone-callbacks.md create mode 100644 packages/better-auth/src/utils/request.test.ts create mode 100644 packages/better-auth/src/utils/request.ts diff --git a/.changeset/fix-request-clone-callbacks.md b/.changeset/fix-request-clone-callbacks.md new file mode 100644 index 0000000000..5160551fce --- /dev/null +++ b/.changeset/fix-request-clone-callbacks.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Prevent verification callbacks from failing auth requests when cloning the request throws. diff --git a/packages/better-auth/src/api/routes/email-verification.test.ts b/packages/better-auth/src/api/routes/email-verification.test.ts index 53075c053f..1b1f7dca74 100644 --- a/packages/better-auth/src/api/routes/email-verification.test.ts +++ b/packages/better-auth/src/api/routes/email-verification.test.ts @@ -6,6 +6,73 @@ import { getTestInstance } from "../../test-utils/test-instance"; * @see https://github.com/better-auth/better-auth/issues/8969 */ describe("Email Verification - Request body consumption bug", () => { + /** + * @see https://github.com/better-auth/better-auth/issues/10335 + */ + it("should not fail sign-up when callback request cloning throws", async () => { + const originalClone = Request.prototype.clone; + const mockSendEmail = vi.fn(); + let cloneCalls = 0; + + const cloneSpy = vi + .spyOn(Request.prototype, "clone") + .mockImplementation(function (this: Request) { + cloneCalls += 1; + if (cloneCalls > 1) { + throw new TypeError("unusable"); + } + return originalClone.call(this); + }); + + const { auth } = await getTestInstance( + { + emailAndPassword: { + enabled: true, + }, + emailVerification: { + sendOnSignUp: true, + async sendVerificationEmail({ user }, request) { + mockSendEmail( + user.email, + request?.method, + request?.url, + await request?.text(), + ); + }, + }, + }, + { + disableTestUser: true, + }, + ); + + try { + const response = await auth.handler( + new Request("http://localhost:3000/api/auth/sign-up/email", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + name: "Test User", + email: "clone-throws@example.com", + password: "password123", + }), + }), + ); + + expect(response.status).toBe(200); + expect(mockSendEmail).toHaveBeenCalledWith( + "clone-throws@example.com", + "POST", + "http://localhost:3000/api/auth/sign-up/email", + "", + ); + } finally { + cloneSpy.mockRestore(); + } + }); + it("should not throw 'body already consumed' error when sendVerificationEmail callback reads the request", async () => { const mockSendEmail = vi.fn(); let requestBodyReadError: Error | null = null; diff --git a/packages/better-auth/src/api/routes/email-verification.ts b/packages/better-auth/src/api/routes/email-verification.ts index fc47ea5bb3..873a39a0a4 100644 --- a/packages/better-auth/src/api/routes/email-verification.ts +++ b/packages/better-auth/src/api/routes/email-verification.ts @@ -9,6 +9,7 @@ import { setSessionCookie } from "../../cookies"; import { signJWT } from "../../crypto/jwt"; import { parseUserOutput } from "../../db/schema"; import type { User } from "../../types"; +import { safeCloneRequest } from "../../utils/request"; import { originCheck } from "../middlewares"; import { getSessionFromCtx } from "./session"; @@ -354,7 +355,7 @@ export const verifyEmail = createAuthEndpoint( url, token: newToken, }, - ctx.request?.clone(), + safeCloneRequest(ctx.request), ), ); } @@ -453,7 +454,7 @@ export const verifyEmail = createAuthEndpoint( url: `${ctx.context.baseURL}/verify-email?token=${newToken}&callbackURL=${updateCallbackURL}`, token: newToken, }, - ctx.request?.clone(), + safeCloneRequest(ctx.request), ), ); } diff --git a/packages/better-auth/src/api/routes/sign-in.ts b/packages/better-auth/src/api/routes/sign-in.ts index badf716403..c25a4353c8 100644 --- a/packages/better-auth/src/api/routes/sign-in.ts +++ b/packages/better-auth/src/api/routes/sign-in.ts @@ -10,6 +10,7 @@ import { parseUserOutput } from "../../db/schema"; import { missingEmailLogMessage } from "../../oauth2/errors"; import { handleOAuthUserInfo } from "../../oauth2/link-account"; import { generateState } from "../../utils"; +import { safeCloneRequest } from "../../utils/request"; import { formCsrfMiddleware } from "../middlewares/origin-check"; import { createEmailVerificationToken } from "./email-verification"; @@ -557,7 +558,7 @@ export const signInEmail = () => url, token, }, - ctx.request?.clone(), + safeCloneRequest(ctx.request), ), ); } diff --git a/packages/better-auth/src/api/routes/sign-up.ts b/packages/better-auth/src/api/routes/sign-up.ts index cefa4c7e3c..b20a73d7ab 100644 --- a/packages/better-auth/src/api/routes/sign-up.ts +++ b/packages/better-auth/src/api/routes/sign-up.ts @@ -10,6 +10,7 @@ import { parseUserInput } from "../../db"; import { buildSyntheticUserOutput, parseUserOutput } from "../../db/schema"; import type { AdditionalUserFieldsInput, User } from "../../types"; import { isAPIError } from "../../utils/is-api-error"; +import { safeCloneRequest } from "../../utils/request"; import { formCsrfMiddleware } from "../middlewares/origin-check"; import { createEmailVerificationToken } from "./email-verification"; @@ -260,7 +261,7 @@ export const signUpEmail = () => await ctx.context.runInBackgroundOrAwait( ctx.context.options.emailAndPassword.onExistingUserSignUp( { user: dbUser.user }, - ctx.request?.clone(), + safeCloneRequest(ctx.request), ), ); } @@ -394,7 +395,7 @@ export const signUpEmail = () => url, token, }, - ctx.request?.clone(), + safeCloneRequest(ctx.request), ), ); } diff --git a/packages/better-auth/src/utils/request.test.ts b/packages/better-auth/src/utils/request.test.ts new file mode 100644 index 0000000000..1e27f1c147 --- /dev/null +++ b/packages/better-auth/src/utils/request.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { safeCloneRequest } from "./request"; + +describe("safeCloneRequest", () => { + /** + * @see https://github.com/better-auth/better-auth/issues/10335 + */ + it("returns a bodyless request when cloning throws", async () => { + const originalClone = Request.prototype.clone; + const cloneSpy = vi + .spyOn(Request.prototype, "clone") + .mockImplementation(function (this: Request) { + if (this.url === "http://localhost/clone-throws") { + throw new TypeError("unusable"); + } + return originalClone.call(this); + }); + + const request = new Request("http://localhost/clone-throws", { + method: "POST", + body: "{}", + }); + + try { + const fallbackRequest = safeCloneRequest(request); + + expect(fallbackRequest).not.toBe(request); + expect(fallbackRequest?.method).toBe("POST"); + expect(fallbackRequest?.url).toBe("http://localhost/clone-throws"); + await expect(fallbackRequest?.text()).resolves.toBe(""); + } finally { + cloneSpy.mockRestore(); + } + }); +}); diff --git a/packages/better-auth/src/utils/request.ts b/packages/better-auth/src/utils/request.ts new file mode 100644 index 0000000000..71184198fc --- /dev/null +++ b/packages/better-auth/src/utils/request.ts @@ -0,0 +1,23 @@ +export function safeCloneRequest(request?: Request): Request | undefined { + if (!request) { + return undefined; + } + + try { + return request.clone(); + } catch { + return new Request(request.url, { + cache: request.cache, + credentials: request.credentials, + headers: request.headers, + integrity: request.integrity, + keepalive: request.keepalive, + method: request.method, + mode: request.mode, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + signal: request.signal, + }); + } +}