fix(auth): handle request clone failures in callbacks (#10336)

Co-authored-by: Maxwell <145994855+ping-maxwell@users.noreply.github.com>
This commit is contained in:
Tushar
2026-07-08 21:49:11 +00:00
committed by GitHub
co-authored by Maxwell
parent 03dc5a046f
commit ef4d27360c
7 changed files with 138 additions and 5 deletions
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Prevent verification callbacks from failing auth requests when cloning the request throws.
@@ -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;
@@ -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),
),
);
}
@@ -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 = <O extends BetterAuthOptions>() =>
url,
token,
},
ctx.request?.clone(),
safeCloneRequest(ctx.request),
),
);
}
@@ -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 = <O extends BetterAuthOptions>() =>
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 = <O extends BetterAuthOptions>() =>
url,
token,
},
ctx.request?.clone(),
safeCloneRequest(ctx.request),
),
);
}
@@ -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();
}
});
});
+23
View File
@@ -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,
});
}
}