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:
@@ -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, 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.
|
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:
|
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.
|
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({
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user