mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-09 12:27:43 +00:00
feat: new user delete flow (#2704)
* feat: new user delete flow * fix: modify and add test cases
This commit is contained in:
@@ -112,6 +112,7 @@ Once enabled, you can call `authClient.deleteUser` to permanently delete user da
|
||||
### Adding Verification Before Deletion
|
||||
|
||||
For added security, you’ll likely want to confirm the user’s 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.
|
||||
|
||||
Here’s 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.
|
||||
|
||||
<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>
|
||||
|
||||
```ts title="delete-user.ts"
|
||||
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"
|
||||
await authClient.deleteUser({
|
||||
|
||||
@@ -267,13 +267,16 @@ describe("updateUser", 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({
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
session: {
|
||||
freshAge: 1000,
|
||||
},
|
||||
});
|
||||
const { headers } = await signInWithTestUser();
|
||||
const res = await client.deleteUser({
|
||||
@@ -292,9 +295,9 @@ describe("delete user", async () => {
|
||||
expect(session.data).toBeNull();
|
||||
});
|
||||
|
||||
it("should delete with verification flow", async () => {
|
||||
it("should delete with verification flow and password", async () => {
|
||||
let token = "";
|
||||
const { client, signInWithTestUser } = await getTestInstance({
|
||||
const { client, signInWithTestUser, testUser } = await getTestInstance({
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
@@ -306,6 +309,7 @@ describe("delete user", async () => {
|
||||
});
|
||||
const { headers } = await signInWithTestUser();
|
||||
const res = await client.deleteUser({
|
||||
password: testUser.password,
|
||||
fetchOptions: {
|
||||
headers,
|
||||
},
|
||||
|
||||
@@ -421,14 +421,17 @@ export const deleteUser = createAuthEndpoint(
|
||||
throw new APIError("NOT_FOUND");
|
||||
}
|
||||
const session = ctx.context.session;
|
||||
let canDelete = false;
|
||||
|
||||
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 the user has a password, we can try to delete the account
|
||||
if (ctx.body.password) {
|
||||
if (!account || !account.password) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: BASE_ERROR_CODES.CREDENTIAL_ACCOUNT_NOT_FOUND,
|
||||
@@ -443,19 +446,10 @@ export const deleteUser = createAuthEndpoint(
|
||||
message: BASE_ERROR_CODES.INVALID_PASSWORD,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
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 the user has a token, we can try to delete the account
|
||||
if (ctx.body.token) {
|
||||
//@ts-expect-error
|
||||
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 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");
|
||||
await ctx.context.internalAdapter.createVerificationValue(
|
||||
{
|
||||
@@ -503,6 +505,28 @@ export const deleteUser = createAuthEndpoint(
|
||||
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;
|
||||
if (beforeDelete) {
|
||||
await beforeDelete(session.user, ctx.request);
|
||||
|
||||
@@ -22,4 +22,6 @@ export const BASE_ERROR_CODES = {
|
||||
SESSION_EXPIRED: "Session expired. Re-authenticate to perform this action.",
|
||||
FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
|
||||
ACCOUNT_NOT_FOUND: "Account not found",
|
||||
USER_ALREADY_HAS_PASSWORD:
|
||||
"User already has a password. Provide that to delete the account.",
|
||||
};
|
||||
|
||||
@@ -439,4 +439,90 @@ describe("oauth2", async () => {
|
||||
expect(receivedHeaders).toHaveProperty("x-custom-header");
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user