diff --git a/docs/content/docs/concepts/users-accounts.mdx b/docs/content/docs/concepts/users-accounts.mdx index 2decbb68..00553ddc 100644 --- a/docs/content/docs/concepts/users-accounts.mdx +++ b/docs/content/docs/concepts/users-accounts.mdx @@ -111,7 +111,8 @@ 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. +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. -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. ```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({ diff --git a/packages/better-auth/src/api/routes/update-user.test.ts b/packages/better-auth/src/api/routes/update-user.test.ts index c8191f59..8126da5e 100644 --- a/packages/better-auth/src/api/routes/update-user.test.ts +++ b/packages/better-auth/src/api/routes/update-user.test.ts @@ -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, }, diff --git a/packages/better-auth/src/api/routes/update-user.ts b/packages/better-auth/src/api/routes/update-user.ts index f63795ec..16fb1e92 100644 --- a/packages/better-auth/src/api/routes/update-user.ts +++ b/packages/better-auth/src/api/routes/update-user.ts @@ -421,14 +421,17 @@ export const deleteUser = createAuthEndpoint( throw new APIError("NOT_FOUND"); } 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) { - const accounts = await ctx.context.internalAdapter.findAccounts( - session.user.id, - ); - const account = accounts.find( - (account) => account.providerId === "credential" && account.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); diff --git a/packages/better-auth/src/error/codes.ts b/packages/better-auth/src/error/codes.ts index 578b8612..f88b80a0 100644 --- a/packages/better-auth/src/error/codes.ts +++ b/packages/better-auth/src/error/codes.ts @@ -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.", }; 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 ebcf9088..267ccb7f 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 @@ -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(); + }); });