mirror of
https://github.com/better-auth/better-auth.git
synced 2026-08-25 17:11:27 -05:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user