mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-22 06:16:18 -05:00
refactor: improved change email verification flow (#6088)
This commit is contained in:
@@ -35,18 +35,22 @@ export const auth = betterAuth({
|
||||
})
|
||||
```
|
||||
|
||||
For users with a verified email, provide the `sendChangeEmailVerification` function. This function triggers when a user changes their email, sending a verification email with a URL and token. If the current email isn't verified, the change happens immediately without verification.
|
||||
By default, when a user requests to change their email, a verification email is sent to the **new** email address. The email is only updated after the user verifies the new email.
|
||||
|
||||
#### Confirming with Current Email
|
||||
|
||||
For added security, you can require users to confirm the change via their **current** email before the verification email is sent to the new address. To do this, provide the `sendChangeEmailConfirmation` function.
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
user: {
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
sendChangeEmailVerification: async ({ user, newEmail, url, token }, request) => {
|
||||
sendChangeEmailConfirmation: async ({ user, newEmail, url, token }, request) => {
|
||||
await sendEmail({
|
||||
to: user.email, // verification email must be sent to the current user email to approve the change
|
||||
to: user.email, // Sent to the CURRENT email
|
||||
subject: 'Approve email change',
|
||||
text: `Click the link to approve the change: ${url}`
|
||||
text: `Click the link to approve the change to ${newEmail}: ${url}`
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -54,20 +58,35 @@ export const auth = betterAuth({
|
||||
})
|
||||
```
|
||||
|
||||
Once enabled, use the `changeEmail` function on the client to update a user’s email. The user must verify their current email before changing it.
|
||||
#### Updating Without Verification
|
||||
|
||||
If you want to allow users to update their email immediately without verification (only if their current email is NOT verified), you can enable `updateEmailWithoutVerification`.
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
user: {
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
updateEmailWithoutVerification: true
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
If `updateEmailWithoutVerification` is false (default), the email will not be updated until the new email is verified, even if the current email is unverified.
|
||||
</Callout>
|
||||
|
||||
#### Client Usage
|
||||
|
||||
Use the `changeEmail` function on the client to initiate the process.
|
||||
|
||||
```ts
|
||||
await authClient.changeEmail({
|
||||
newEmail: "new-email@email.com",
|
||||
callbackURL: "/dashboard", //to redirect after verification
|
||||
callbackURL: "/dashboard", // to redirect after verification
|
||||
});
|
||||
```
|
||||
|
||||
After verification, the new email is updated in the user table, and a confirmation is sent to the new address.
|
||||
|
||||
<Callout type="warn">
|
||||
If the current email is unverified, the new email is updated without the verification step.
|
||||
</Callout>
|
||||
|
||||
### Change Password
|
||||
A user's password isn't stored in the user table. Instead, it's stored in the account table. To change the password of a user, you can use one of the following approaches:
|
||||
|
||||
@@ -273,9 +273,10 @@ export const auth = betterAuth({
|
||||
},
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
sendChangeEmailVerification: async ({ user, newEmail, url, token }) => {
|
||||
// Send change email verification
|
||||
}
|
||||
sendChangeEmailConfirmation: async ({ user, newEmail, url, token }) => {
|
||||
// Send change email confirmation to the old email
|
||||
},
|
||||
updateEmailWithoutVerification: false // Update email without verification if user is not verified
|
||||
},
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
|
||||
@@ -341,23 +341,46 @@ describe("Email Verification Secondary Storage", async () => {
|
||||
},
|
||||
headers,
|
||||
});
|
||||
const newHeaders = new Headers();
|
||||
|
||||
// 1. Verify confirmation token (sent to old email)
|
||||
const confirmationHeaders = new Headers();
|
||||
await client.verifyEmail({
|
||||
query: {
|
||||
token,
|
||||
},
|
||||
fetchOptions: {
|
||||
onSuccess: cookieSetter(newHeaders),
|
||||
onSuccess: cookieSetter(confirmationHeaders),
|
||||
headers,
|
||||
},
|
||||
});
|
||||
|
||||
// Check that email is NOT updated yet
|
||||
const sessionAfterConfirmation = await client.getSession({
|
||||
fetchOptions: {
|
||||
headers: confirmationHeaders,
|
||||
},
|
||||
});
|
||||
expect(sessionAfterConfirmation.data?.user.email).toBe(testUser.email);
|
||||
|
||||
// 2. Verify new email token (token variable was updated by sendVerificationEmail mock)
|
||||
const verificationHeaders = new Headers();
|
||||
await client.verifyEmail({
|
||||
query: {
|
||||
token,
|
||||
},
|
||||
fetchOptions: {
|
||||
onSuccess: cookieSetter(verificationHeaders),
|
||||
headers: confirmationHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await client.getSession({
|
||||
fetchOptions: {
|
||||
headers: newHeaders,
|
||||
headers: verificationHeaders,
|
||||
},
|
||||
});
|
||||
expect(session.data?.user.email).toBe("new@email.com");
|
||||
expect(session.data?.user.emailVerified).toBe(false);
|
||||
expect(session.data?.user.emailVerified).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,11 +22,16 @@ export async function createEmailVerificationToken(
|
||||
* The time in seconds for the token to expire
|
||||
*/
|
||||
expiresIn: number = 3600,
|
||||
/**
|
||||
* Extra payload to include in the token
|
||||
*/
|
||||
extraPayload?: Record<string, any>,
|
||||
) {
|
||||
const token = await signJWT(
|
||||
{
|
||||
email: email.toLowerCase(),
|
||||
updateTo,
|
||||
...extraPayload,
|
||||
},
|
||||
secret,
|
||||
expiresIn,
|
||||
@@ -294,6 +299,7 @@ export const verifyEmail = createAuthEndpoint(
|
||||
const schema = z.object({
|
||||
email: z.email(),
|
||||
updateTo: z.string().optional(),
|
||||
requestType: z.string().optional(),
|
||||
});
|
||||
const parsed = schema.parse(jwt.payload);
|
||||
const user = await ctx.context.internalAdapter.findUserByEmail(
|
||||
@@ -317,6 +323,64 @@ export const verifyEmail = createAuthEndpoint(
|
||||
return redirectOnError("unauthorized");
|
||||
}
|
||||
|
||||
if (parsed.requestType === "change-email-confirmation") {
|
||||
const newToken = await createEmailVerificationToken(
|
||||
ctx.context.secret,
|
||||
parsed.email,
|
||||
parsed.updateTo,
|
||||
ctx.context.options.emailVerification?.expiresIn,
|
||||
{
|
||||
requestType: "change-email-verification",
|
||||
},
|
||||
);
|
||||
const updateCallbackURL = ctx.query.callbackURL
|
||||
? encodeURIComponent(ctx.query.callbackURL)
|
||||
: encodeURIComponent("/");
|
||||
const url = `${ctx.context.baseURL}/verify-email?token=${newToken}&callbackURL=${updateCallbackURL}`;
|
||||
await ctx.context.options.emailVerification?.sendVerificationEmail?.(
|
||||
{
|
||||
user: {
|
||||
...session.user,
|
||||
email: parsed.updateTo,
|
||||
},
|
||||
url,
|
||||
token: newToken,
|
||||
},
|
||||
ctx.request,
|
||||
);
|
||||
if (ctx.query.callbackURL) {
|
||||
throw ctx.redirect(ctx.query.callbackURL);
|
||||
}
|
||||
return ctx.json({
|
||||
status: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.requestType === "change-email-verification") {
|
||||
const updatedUser = await ctx.context.internalAdapter.updateUserByEmail(
|
||||
parsed.email,
|
||||
{
|
||||
email: parsed.updateTo,
|
||||
emailVerified: true,
|
||||
},
|
||||
);
|
||||
await setSessionCookie(ctx, {
|
||||
session: session.session,
|
||||
user: {
|
||||
...session.user,
|
||||
email: parsed.updateTo,
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
if (ctx.query.callbackURL) {
|
||||
throw ctx.redirect(ctx.query.callbackURL);
|
||||
}
|
||||
return ctx.json({
|
||||
status: true,
|
||||
user: updatedUser,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUser = await ctx.context.internalAdapter.updateUserByEmail(
|
||||
parsed.email,
|
||||
{
|
||||
|
||||
@@ -54,31 +54,8 @@ describe("updateUser", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should update user email", async () => {
|
||||
const newEmail = "new-email@email.com";
|
||||
await globalRunWithClient(async () => {
|
||||
const res = await client.changeEmail({
|
||||
newEmail,
|
||||
});
|
||||
const sessionRes = await client.getSession();
|
||||
expect(sessionRes.data?.user.email).toBe(newEmail);
|
||||
expect(sessionRes.data?.user.emailVerified).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should verify email", async () => {
|
||||
await globalRunWithClient(async () => {
|
||||
await client.verifyEmail({
|
||||
query: {
|
||||
token: emailVerificationToken,
|
||||
},
|
||||
});
|
||||
const sessionRes = await client.getSession();
|
||||
expect(sessionRes.data?.user.emailVerified).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should send email verification before update", async () => {
|
||||
it("should not update user email immediately (default secure flow)", async () => {
|
||||
// Ensure user is verified to trigger the confirmation flow
|
||||
await db.update({
|
||||
model: "user",
|
||||
update: {
|
||||
@@ -87,27 +64,68 @@ describe("updateUser", async () => {
|
||||
where: [
|
||||
{
|
||||
field: "email",
|
||||
value: "new-email@email.com",
|
||||
value: testUser.email,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const newEmail = "new-email@email.com";
|
||||
await globalRunWithClient(async () => {
|
||||
await client.changeEmail({
|
||||
newEmail: "new-email-2@email.com",
|
||||
const res = await client.changeEmail({
|
||||
newEmail,
|
||||
});
|
||||
const sessionRes = await client.getSession();
|
||||
// Should NOT update email yet
|
||||
expect(sessionRes.data?.user.email).not.toBe(newEmail);
|
||||
expect(sessionRes.data?.user.email).toBe(testUser.email);
|
||||
});
|
||||
});
|
||||
|
||||
it("should verify email change (flow with confirmation)", async () => {
|
||||
// The previous test triggered changeEmail.
|
||||
// Since testUser is verified, and sendChangeEmailVerification is provided,
|
||||
// it should have sent a confirmation email to the OLD email.
|
||||
|
||||
expect(sendChangeEmail).toHaveBeenCalled();
|
||||
const call = sendChangeEmail.mock.calls[0];
|
||||
const token = call?.[3]; // token is 4th arg
|
||||
if (!token) throw new Error("Token not found");
|
||||
|
||||
await globalRunWithClient(async () => {
|
||||
// 1. Verify the confirmation token (sent to old email)
|
||||
const res = await client.verifyEmail({
|
||||
query: {
|
||||
token: token,
|
||||
},
|
||||
});
|
||||
expect(res.data?.status).toBe(true);
|
||||
|
||||
// This should trigger sending verification to the NEW email.
|
||||
// emailVerification.sendVerificationEmail should have been called.
|
||||
// We captured this in emailVerificationToken variable in setup.
|
||||
expect(emailVerificationToken).toBeDefined();
|
||||
|
||||
// User email should STILL be old email
|
||||
const sessionRes = await client.getSession();
|
||||
expect(sessionRes.data?.user.email).toBe(testUser.email);
|
||||
|
||||
// 2. Verify the new email token
|
||||
const res2 = await client.verifyEmail({
|
||||
query: {
|
||||
token: emailVerificationToken,
|
||||
},
|
||||
});
|
||||
expect(res2.data?.status).toBe(true);
|
||||
|
||||
// NOW user email should be updated
|
||||
const sessionRes2 = await client.getSession();
|
||||
expect(sessionRes2.data?.user.email).toBe("new-email@email.com");
|
||||
expect(sessionRes2.data?.user.emailVerified).toBe(true);
|
||||
});
|
||||
expect(sendChangeEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: "new-email@email.com",
|
||||
}),
|
||||
"new-email-2@email.com",
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it("should update the user's password", async () => {
|
||||
const newEmail = "new-email@email.com";
|
||||
const newEmail = "new-email@email.com"; // User email is now this
|
||||
await globalRunWithClient(async () => {
|
||||
const updated = await client.changePassword({
|
||||
newPassword: "newPassword",
|
||||
@@ -122,7 +140,7 @@ describe("updateUser", async () => {
|
||||
});
|
||||
expect(signInRes.data?.user).toBeDefined();
|
||||
const signInCurrentPassword = await client.signIn.email({
|
||||
email: testUser.email,
|
||||
email: testUser.email, // Old email
|
||||
password: testUser.password,
|
||||
});
|
||||
expect(signInCurrentPassword.data).toBeNull();
|
||||
|
||||
@@ -759,10 +759,14 @@ export const changeEmail = createAuthEndpoint(
|
||||
message: BASE_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If the email is not verified, we can update the email
|
||||
* If the email is not verified, we can update the email if the option is enabled
|
||||
*/
|
||||
if (ctx.context.session.user.emailVerified !== true) {
|
||||
if (
|
||||
ctx.context.session.user.emailVerified !== true &&
|
||||
ctx.context.options.user.changeEmail.updateEmailWithoutVerification
|
||||
) {
|
||||
await ctx.context.internalAdapter.updateUserByEmail(
|
||||
ctx.context.session.user.email,
|
||||
{
|
||||
@@ -809,7 +813,44 @@ export const changeEmail = createAuthEndpoint(
|
||||
/**
|
||||
* If the email is verified, we need to send a verification email
|
||||
*/
|
||||
if (!ctx.context.options.user.changeEmail.sendChangeEmailVerification) {
|
||||
const sendConfirmationToOldEmail =
|
||||
ctx.context.session.user.emailVerified &&
|
||||
(ctx.context.options.user.changeEmail.sendChangeEmailConfirmation ||
|
||||
ctx.context.options.user.changeEmail.sendChangeEmailVerification);
|
||||
|
||||
if (sendConfirmationToOldEmail) {
|
||||
const token = await createEmailVerificationToken(
|
||||
ctx.context.secret,
|
||||
ctx.context.session.user.email,
|
||||
newEmail,
|
||||
ctx.context.options.emailVerification?.expiresIn,
|
||||
{
|
||||
requestType: "change-email-confirmation",
|
||||
},
|
||||
);
|
||||
const url = `${
|
||||
ctx.context.baseURL
|
||||
}/verify-email?token=${token}&callbackURL=${ctx.body.callbackURL || "/"}`;
|
||||
const sendFn =
|
||||
ctx.context.options.user.changeEmail.sendChangeEmailConfirmation ||
|
||||
ctx.context.options.user.changeEmail.sendChangeEmailVerification;
|
||||
if (sendFn) {
|
||||
await sendFn(
|
||||
{
|
||||
user: ctx.context.session.user,
|
||||
newEmail: newEmail,
|
||||
url,
|
||||
token,
|
||||
},
|
||||
ctx.request,
|
||||
);
|
||||
}
|
||||
return ctx.json({
|
||||
status: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.context.options.emailVerification?.sendVerificationEmail) {
|
||||
ctx.context.logger.error("Verification email isn't enabled.");
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Verification email isn't enabled",
|
||||
@@ -821,14 +862,19 @@ export const changeEmail = createAuthEndpoint(
|
||||
ctx.context.session.user.email,
|
||||
newEmail,
|
||||
ctx.context.options.emailVerification?.expiresIn,
|
||||
{
|
||||
requestType: "change-email-verification",
|
||||
},
|
||||
);
|
||||
const url = `${
|
||||
ctx.context.baseURL
|
||||
}/verify-email?token=${token}&callbackURL=${ctx.body.callbackURL || "/"}`;
|
||||
await ctx.context.options.user.changeEmail.sendChangeEmailVerification(
|
||||
await ctx.context.options.emailVerification.sendVerificationEmail(
|
||||
{
|
||||
user: ctx.context.session.user,
|
||||
newEmail: newEmail,
|
||||
user: {
|
||||
...ctx.context.session.user,
|
||||
email: newEmail,
|
||||
},
|
||||
url,
|
||||
token,
|
||||
},
|
||||
|
||||
@@ -122,6 +122,7 @@ describe("phone auth flow", async () => {
|
||||
user: {
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
updateEmailWithoutVerification: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -206,6 +207,7 @@ describe("phone auth flow", async () => {
|
||||
email: newEmail,
|
||||
password: "password",
|
||||
});
|
||||
console.log(res);
|
||||
expect(res.error).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -617,6 +617,7 @@ export type BetterAuthOptions = {
|
||||
* Send a verification email when the user changes their email.
|
||||
* @param data the data object
|
||||
* @param request the request object
|
||||
* @deprecated Use `sendChangeEmailConfirmation` instead
|
||||
*/
|
||||
sendChangeEmailVerification?: (
|
||||
data: {
|
||||
@@ -627,6 +628,25 @@ export type BetterAuthOptions = {
|
||||
},
|
||||
request?: Request,
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* Send a confirmation email to the old email address when the user changes their email.
|
||||
* @param data the data object
|
||||
* @param request the request object
|
||||
*/
|
||||
sendChangeEmailConfirmation?: (
|
||||
data: {
|
||||
user: User;
|
||||
newEmail: string;
|
||||
url: string;
|
||||
token: string;
|
||||
},
|
||||
request?: Request,
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* Update the email without verification if the user is not verified.
|
||||
* @default false
|
||||
*/
|
||||
updateEmailWithoutVerification?: boolean;
|
||||
};
|
||||
/**
|
||||
* User deletion configuration
|
||||
|
||||
Reference in New Issue
Block a user