diff --git a/.changeset/encode-verify-email-callback-url.md b/.changeset/encode-verify-email-callback-url.md new file mode 100644 index 0000000000..477e284019 --- /dev/null +++ b/.changeset/encode-verify-email-callback-url.md @@ -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. diff --git a/packages/better-auth/src/oauth2/link-account.ts b/packages/better-auth/src/oauth2/link-account.ts index 19ff810fb9..ea25f7fa41 100644 --- a/packages/better-auth/src/oauth2/link-account.ts +++ b/packages/better-auth/src/oauth2/link-account.ts @@ -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( { diff --git a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts index 1921765034..e13dc291b8 100644 --- a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts +++ b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts @@ -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 */ diff --git a/packages/better-auth/src/plugins/username/index.ts b/packages/better-auth/src/plugins/username/index.ts index 36507b4295..761944389e 100644 --- a/packages/better-auth/src/plugins/username/index.ts +++ b/packages/better-auth/src/plugins/username/index.ts @@ -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( { diff --git a/packages/better-auth/src/plugins/username/username.test.ts b/packages/better-auth/src/plugins/username/username.test.ts index 8188b5ba61..031f445f6b 100644 --- a/packages/better-auth/src/plugins/username/username.test.ts +++ b/packages/better-auth/src/plugins/username/username.test.ts @@ -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, + ); + }); +});