feat: new user delete flow (#2704)

* feat: new user delete flow

* fix: modify and add test cases
This commit is contained in:
Rahul Mishra
2025-05-24 01:16:10 +05:30
committed by GitHub
parent 9cc2e3d8ab
commit bc2bc2e9fd
5 changed files with 150 additions and 23 deletions

View File

@@ -111,7 +111,8 @@ Once enabled, you can call `authClient.deleteUser` to permanently delete user da
### Adding Verification Before Deletion ### Adding Verification Before Deletion
For added security, youll likely want to confirm the users intent before deleting their account. A common approach is to send a verification email. Better Auth provides a `sendDeleteAccountVerification` utility for this purpose. For added security, youll likely want to confirm the users intent before deleting their account. A common approach is to send a verification email. Better Auth provides a `sendDeleteAccountVerification` utility for this purpose.
This is especially needed if you have OAuth setup and want them to be able to delete their account without forcing them to login again for a fresh session.
Heres how you can set it up: Heres how you can set it up:
@@ -176,14 +177,24 @@ await authClient.deleteUser({
The user must have a `fresh` session token, meaning the user must have signed in recently. This is checked if the password is not provided. The user must have a `fresh` session token, meaning the user must have signed in recently. This is checked if the password is not provided.
<Callout type="warn"> <Callout type="warn">
By default `session.freshAge` is set to `60 * 60 * 24` (1 day). You can change this value by passing the `session` object to the `auth` configuration. If it is set to `0`, the freshness check is disabled. By default `session.freshAge` is set to `60 * 60 * 24` (1 day). You can change this value by passing the `session` object to the `auth` configuration. If it is set to `0`, the freshness check is disabled. It is recommended not to disable this check if you are not using email verification for deleting the account.
</Callout> </Callout>
```ts title="delete-user.ts" ```ts title="delete-user.ts"
await authClient.deleteUser(); await authClient.deleteUser();
``` ```
3. The user must provide a token generated by the `sendDeleteAccountVerification` callback. 3. Enabled email verification (needed for OAuth users)
As OAuth users don't have a password, we need to send a verification email to confirm the user's intent to delete their account. If you have already added the `sendDeleteAccountVerification` callback, you can just call the `deleteUser` method without providing any other information.
Note that this would fail if they have a password. In that case, you need to provide the password to delete the account.
```ts title="delete-user.ts"
await authClient.deleteUser({});
```
4. If you have a custom delete account page and sent that url via the `sendDeleteAccountVerification` callback.
Then you need to call the `deleteUser` method with the token to complete the deletion.
```ts title="delete-user.ts" ```ts title="delete-user.ts"
await authClient.deleteUser({ await authClient.deleteUser({

View File

@@ -267,13 +267,16 @@ describe("updateUser", async () => {
}); });
describe("delete user", async () => { describe("delete user", async () => {
it("should delete the user", async () => { it("should delete the user with a fresh session", async () => {
const { auth, client, signInWithTestUser } = await getTestInstance({ const { auth, client, signInWithTestUser } = await getTestInstance({
user: { user: {
deleteUser: { deleteUser: {
enabled: true, enabled: true,
}, },
}, },
session: {
freshAge: 1000,
},
}); });
const { headers } = await signInWithTestUser(); const { headers } = await signInWithTestUser();
const res = await client.deleteUser({ const res = await client.deleteUser({
@@ -292,9 +295,9 @@ describe("delete user", async () => {
expect(session.data).toBeNull(); expect(session.data).toBeNull();
}); });
it("should delete with verification flow", async () => { it("should delete with verification flow and password", async () => {
let token = ""; let token = "";
const { client, signInWithTestUser } = await getTestInstance({ const { client, signInWithTestUser, testUser } = await getTestInstance({
user: { user: {
deleteUser: { deleteUser: {
enabled: true, enabled: true,
@@ -306,6 +309,7 @@ describe("delete user", async () => {
}); });
const { headers } = await signInWithTestUser(); const { headers } = await signInWithTestUser();
const res = await client.deleteUser({ const res = await client.deleteUser({
password: testUser.password,
fetchOptions: { fetchOptions: {
headers, headers,
}, },

View File

@@ -421,14 +421,17 @@ export const deleteUser = createAuthEndpoint(
throw new APIError("NOT_FOUND"); throw new APIError("NOT_FOUND");
} }
const session = ctx.context.session; const session = ctx.context.session;
let canDelete = false;
const accounts = await ctx.context.internalAdapter.findAccounts(
session.user.id,
);
const account = accounts.find(
(account) => account.providerId === "credential" && account.password,
);
// If the user has a password, we can try to delete the account
if (ctx.body.password) { if (ctx.body.password) {
const accounts = await ctx.context.internalAdapter.findAccounts(
session.user.id,
);
const account = accounts.find(
(account) => account.providerId === "credential" && account.password,
);
if (!account || !account.password) { if (!account || !account.password) {
throw new APIError("BAD_REQUEST", { throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.CREDENTIAL_ACCOUNT_NOT_FOUND, message: BASE_ERROR_CODES.CREDENTIAL_ACCOUNT_NOT_FOUND,
@@ -443,19 +446,10 @@ export const deleteUser = createAuthEndpoint(
message: BASE_ERROR_CODES.INVALID_PASSWORD, message: BASE_ERROR_CODES.INVALID_PASSWORD,
}); });
} }
} else { canDelete = true;
if (ctx.context.options.session?.freshAge) {
const currentAge = session.session.createdAt.getTime();
const freshAge = ctx.context.options.session.freshAge;
const now = Date.now();
if (now - currentAge > freshAge) {
throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.SESSION_EXPIRED,
});
}
}
} }
// If the user has a token, we can try to delete the account
if (ctx.body.token) { if (ctx.body.token) {
//@ts-expect-error //@ts-expect-error
await deleteUserCallback({ await deleteUserCallback({
@@ -470,7 +464,15 @@ export const deleteUser = createAuthEndpoint(
}); });
} }
// if user didn't provide a password or token, try sending email verification
if (ctx.context.options.user.deleteUser?.sendDeleteAccountVerification) { if (ctx.context.options.user.deleteUser?.sendDeleteAccountVerification) {
// if the user has a password but it was not provided, we can't delete the account
if (account && account.password && !canDelete) {
throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.USER_ALREADY_HAS_PASSWORD,
});
}
const token = generateRandomString(32, "0-9", "a-z"); const token = generateRandomString(32, "0-9", "a-z");
await ctx.context.internalAdapter.createVerificationValue( await ctx.context.internalAdapter.createVerificationValue(
{ {
@@ -503,6 +505,28 @@ export const deleteUser = createAuthEndpoint(
message: "Verification email sent", message: "Verification email sent",
}); });
} }
// if the user didn't provide a password or token, or email verification is not enabled
// we can check if the session is fresh and delete based on that
if (ctx.context.options.session?.freshAge) {
const currentAge = session.session.createdAt.getTime();
const freshAge = ctx.context.options.session.freshAge;
const now = Date.now();
if (now - currentAge > freshAge) {
throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.SESSION_EXPIRED,
});
}
canDelete = true;
}
// if password/fresh session didn't work, we can't delete the account
if (!canDelete) {
throw new APIError("BAD_REQUEST", {
message: "User cannot be deleted. please provide a password or token",
});
}
const beforeDelete = ctx.context.options.user.deleteUser?.beforeDelete; const beforeDelete = ctx.context.options.user.deleteUser?.beforeDelete;
if (beforeDelete) { if (beforeDelete) {
await beforeDelete(session.user, ctx.request); await beforeDelete(session.user, ctx.request);

View File

@@ -22,4 +22,6 @@ export const BASE_ERROR_CODES = {
SESSION_EXPIRED: "Session expired. Re-authenticate to perform this action.", SESSION_EXPIRED: "Session expired. Re-authenticate to perform this action.",
FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account", FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
ACCOUNT_NOT_FOUND: "Account not found", ACCOUNT_NOT_FOUND: "Account not found",
USER_ALREADY_HAS_PASSWORD:
"User already has a password. Provide that to delete the account.",
}; };

View File

@@ -439,4 +439,90 @@ describe("oauth2", async () => {
expect(receivedHeaders).toHaveProperty("x-custom-header"); expect(receivedHeaders).toHaveProperty("x-custom-header");
expect(receivedHeaders["x-custom-header"]).toBe("test-value"); expect(receivedHeaders["x-custom-header"]).toBe("test-value");
}); });
it("should delete oauth user with verification flow without password", async () => {
let token = "";
const { customFetchImpl } = await getTestInstance({
user: {
deleteUser: {
enabled: true,
async sendDeleteAccountVerification(data, _) {
token = data.token;
},
},
},
plugins: [
genericOAuth({
config: [
{
providerId: "test",
discoveryUrl:
"http://localhost:8081/.well-known/openid-configuration",
clientId: clientId,
clientSecret: clientSecret,
},
],
}),
],
});
const client = createAuthClient({
plugins: [genericOAuthClient()],
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
},
});
const signInRes = await client.signIn.oauth2({
providerId: "test",
callbackURL: "http://localhost:3000/dashboard",
newUserCallbackURL: "http://localhost:3000/new_user",
});
expect(signInRes.data).toMatchObject({
url: expect.stringContaining("http://localhost:8081/authorize"),
redirect: true,
});
const { headers } = await simulateOAuthFlow(
signInRes.data?.url || "",
new Headers(),
customFetchImpl,
);
const session = await client.getSession({
fetchOptions: {
headers,
},
});
expect(session.data).not.toBeNull();
const deleteRes = await client.deleteUser({
fetchOptions: {
headers,
},
});
expect(deleteRes.data).toMatchObject({
success: true,
});
expect(token.length).toBe(32);
const deleteCallbackRes = await client.deleteUser({
token,
fetchOptions: {
headers,
},
});
expect(deleteCallbackRes.data).toMatchObject({
success: true,
});
const nullSession = await client.getSession({
fetchOptions: {
headers,
},
});
expect(nullSession.data).toBeNull();
});
}); });