mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-18 19:27:48 -05:00
fix: URL-encode callbackURL in verify-email links (#9792)
This commit is contained in:
7
.changeset/encode-verify-email-callback-url.md
Normal file
7
.changeset/encode-verify-email-callback-url.md
Normal 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.
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user