diff --git a/packages/better-auth/src/db/internal-adapter.ts b/packages/better-auth/src/db/internal-adapter.ts index 14b4bce38a..7446f07daa 100644 --- a/packages/better-auth/src/db/internal-adapter.ts +++ b/packages/better-auth/src/db/internal-adapter.ts @@ -180,14 +180,18 @@ export const createInternalAdapter = ( await secondaryStorage.delete(activeSessionsKey); } - async function queueCachedUserSessionDeletion(userId: string) { + async function queueCachedUserSessionDeletion( + userId: string, + sessionReferences?: readonly ActiveSessionReference[], + ) { if (!secondaryStorage) return; - // Callers may create a replacement session before queued hooks run. - // Capture the revocation set now so that replacement remains active. - const sessionReferences = await getActiveSessionReferences(userId); + // Callers may capture the revocation set before destructive work because a + // replacement session created by a later hook must remain active. + const references = + sessionReferences ?? (await getActiveSessionReferences(userId)); await queueAfterTransactionHook( - () => deleteCachedUserSessions(userId, sessionReferences), + () => deleteCachedUserSessions(userId, references), { onError(error) { logger.error( @@ -421,7 +425,7 @@ export const createInternalAdapter = ( return total; }, deleteUser: async (userId: string) => { - await queueCachedUserSessionDeletion(userId); + const sessionReferences = await getActiveSessionReferences(userId); if (!secondaryStorage || options.session?.storeSessionInDatabase) { await deleteManyWithHooks( [ @@ -445,7 +449,7 @@ export const createInternalAdapter = ( undefined, ); - await deleteWithHooks( + const deletedUser = await deleteWithHooks( [ { field: "id", @@ -455,6 +459,9 @@ export const createInternalAdapter = ( "user", undefined, ); + if (deletedUser !== null) { + await queueCachedUserSessionDeletion(userId, sessionReferences); + } }, createSession: async ( userId: string, @@ -934,17 +941,23 @@ export const createInternalAdapter = ( ); }, deleteUserSessions: async (userId: string) => { - await queueCachedUserSessionDeletion(userId); + const sessionReferences = await getActiveSessionReferences(userId); if (secondaryStorage) { if (!options.session?.storeSessionInDatabase) { + await queueCachedUserSessionDeletion(userId, sessionReferences); return; } if (ctx.options.session?.preserveSessionInDatabase) { - await endPreservedSessions([{ field: "userId", value: userId }]); + const endedSessions = await endPreservedSessions([ + { field: "userId", value: userId }, + ]); + if (endedSessions !== null) { + await queueCachedUserSessionDeletion(userId, sessionReferences); + } return; } } - await deleteManyWithHooks( + const deletedSessions = await deleteManyWithHooks( [ { field: "userId", @@ -954,6 +967,9 @@ export const createInternalAdapter = ( "session", undefined, ); + if (deletedSessions !== null) { + await queueCachedUserSessionDeletion(userId, sessionReferences); + } }, deleteSessions: async (sessionTokens: string[]) => { if (secondaryStorage) { diff --git a/packages/better-auth/src/db/secondary-storage.test.ts b/packages/better-auth/src/db/secondary-storage.test.ts index 74632b871d..73a320b217 100644 --- a/packages/better-auth/src/db/secondary-storage.test.ts +++ b/packages/better-auth/src/db/secondary-storage.test.ts @@ -300,6 +300,80 @@ describe("secondary storage - storeSessionInDatabase", () => { }); describe("secondary storage - deleteUser", () => { + /** + * @see https://github.com/better-auth/better-auth/pull/10687#discussion_r3716703104 + */ + it("keeps cached sessions when user deletion is vetoed", async () => { + const store = new Map(); + const { auth } = await getTestInstance({ + secondaryStorage: createStringSecondaryStorage(store), + databaseHooks: { + user: { + delete: { + before: async () => false, + }, + }, + }, + rateLimit: { enabled: false }, + }); + const { internalAdapter } = await auth.$context; + const user = await internalAdapter.createUser( + { + name: "Retained User", + email: "retained-user@test.com", + }, + { method: "test" }, + ); + const session = await internalAdapter.createSession(user.id); + const activeSessionsKey = `active-sessions-${user.id}`; + + await internalAdapter.deleteUser(user.id); + + expect(await internalAdapter.findUserById(user.id)).not.toBeNull(); + expect(store.has(session.token)).toBe(true); + expect(store.has(activeSessionsKey)).toBe(true); + }); + + /** + * @see https://github.com/better-auth/better-auth/pull/10687#discussion_r3716703104 + */ + it("keeps cached sessions when session deletion is vetoed", async () => { + const store = new Map(); + const { auth, db } = await getTestInstance({ + secondaryStorage: createStringSecondaryStorage(store), + session: { storeSessionInDatabase: true }, + databaseHooks: { + session: { + delete: { + before: async () => false, + }, + }, + }, + rateLimit: { enabled: false }, + }); + const { internalAdapter } = await auth.$context; + const user = await internalAdapter.createUser( + { + name: "Retained Session User", + email: "retained-session-user@test.com", + }, + { method: "test" }, + ); + const session = await internalAdapter.createSession(user.id); + const activeSessionsKey = `active-sessions-${user.id}`; + + await internalAdapter.deleteUserSessions(user.id); + + expect( + await db.findMany({ + model: "session", + where: [{ field: "userId", value: user.id }], + }), + ).toHaveLength(1); + expect(store.has(session.token)).toBe(true); + expect(store.has(activeSessionsKey)).toBe(true); + }); + it("deletes user sessions from secondary storage and database", async () => { const store = new Map(); const { auth, db } = await getTestInstance({