fix: URL-encode callbackURL in verify-email links (#9792)

This commit is contained in:
Gustavo Valverde
2026-05-29 09:08:42 +01:00
committed by GitHub
parent 093115e162
commit c92cd74162
5 changed files with 125 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"better-auth": patch
---
URL-encode `callbackURL` in the verify-email links sent during OAuth account linking and username sign-in.
Both paths interpolated the caller's `callbackURL` into the verification link without encoding it. A legitimate value containing an ampersand, such as `/welcome?ref=oauth&plan=pro`, was truncated at the first `&`, so the user landed on the wrong page after verifying their email. The value is now encoded the same way the other verify-email links already handle it.

View File

@@ -204,7 +204,9 @@ export async function handleOAuthUserInfo(
undefined,
c.context.options.emailVerification?.expiresIn,
);
const url = `${c.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`;
const url = `${c.context.baseURL}/verify-email?token=${token}&callbackURL=${encodeURIComponent(
callbackURL || "/",
)}`;
await c.context.runInBackgroundOrAwait(
c.context.options.emailVerification.sendVerificationEmail(
{

View File

@@ -218,6 +218,69 @@ describe("oauth2", async () => {
});
});
/**
* The verify-email link sent to a new, unverified OAuth user must keep the
* caller's `callbackURL` intact. A raw interpolation truncates any value
* containing `&` at the first ampersand.
*
* @see https://github.com/better-auth/better-auth/issues/6086
*/
it("encodes callbackURL in the verify-email link for a new unverified OAuth user", async () => {
let capturedUrl = "";
const { customFetchImpl: localFetch } = await getTestInstance({
emailVerification: {
sendOnSignUp: true,
async sendVerificationEmail({ url }) {
capturedUrl = url;
},
},
plugins: [
genericOAuth({
config: [
{
providerId: "test-encode-callback",
discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`,
clientId,
clientSecret,
pkce: true,
},
],
}),
],
});
const localClient = createAuthClient({
plugins: [genericOAuthClient()],
baseURL: "http://localhost:3000",
fetchOptions: { customFetchImpl: localFetch },
});
server.service.once("beforeUserinfo", (userInfoResponse) => {
userInfoResponse.body = {
email: "encode-callback@test.com",
name: "Encode Callback",
sub: "encode-callback",
email_verified: false,
};
userInfoResponse.statusCode = 200;
});
const callbackURL = "http://localhost:3000/welcome?ref=oauth&plan=pro";
const headers = new Headers();
const signInRes = await localClient.signIn.oauth2({
providerId: "test-encode-callback",
callbackURL,
fetchOptions: {
onSuccess: cookieSetter(headers),
},
});
await simulateOAuthFlow(signInRes.data?.url || "", headers, localFetch);
expect(capturedUrl).not.toBe("");
expect(new URL(capturedUrl).searchParams.get("callbackURL")).toBe(
callbackURL,
);
});
/**
* @see https://github.com/better-auth/better-auth/issues/9375
*/

View File

@@ -507,9 +507,9 @@ export const username = (options?: UsernameOptions | undefined) => {
undefined,
ctx.context.options.emailVerification?.expiresIn,
);
const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${
ctx.body.callbackURL || "/"
}`;
const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${encodeURIComponent(
ctx.body.callbackURL || "/",
)}`;
await ctx.context.runInBackgroundOrAwait(
ctx.context.options.emailVerification.sendVerificationEmail(
{

View File

@@ -655,3 +655,52 @@ describe("username email verification flow (no info leak)", async () => {
expect(res.error?.code).toBe("EMAIL_NOT_VERIFIED");
});
});
describe("username sign-in verify-email callbackURL", async () => {
/**
* The verify-email link sent on username sign-in for an unverified user must
* keep the caller's `callbackURL` intact. A raw interpolation truncates any
* value containing `&` at the first ampersand.
*
* @see https://github.com/better-auth/better-auth/issues/6086
*/
it("encodes callbackURL in the verify-email link on username sign-in", async () => {
let capturedUrl = "";
const { client } = await getTestInstance(
{
emailAndPassword: { enabled: true, requireEmailVerification: true },
emailVerification: {
sendOnSignIn: true,
async sendVerificationEmail({ url }) {
capturedUrl = url;
},
},
plugins: [username()],
},
{
clientOptions: {
plugins: [usernameClient()],
},
},
);
await client.signUp.email({
email: "encode-username@example.com",
username: "encode_username",
password: "correct-password",
name: "Encode Username",
});
const callbackURL = "/welcome?ref=username&plan=pro";
await client.signIn.username({
username: "encode_username",
password: "correct-password",
callbackURL,
});
expect(capturedUrl).not.toBe("");
expect(new URL(capturedUrl).searchParams.get("callbackURL")).toBe(
callbackURL,
);
});
});