From e5ce8512b05549c061420b2d4b6e6a0e489e1ac7 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:15:54 +0300 Subject: [PATCH 1/2] fix(auth): revoke the calendar link and turn off webhooks when securing an account --- app/frontend/src/pages/PasswordPages.tsx | 2 +- app/frontend/src/pages/settings/SecuritySettings.tsx | 2 +- app/frontend/src/pages/settings/SessionsSection.tsx | 2 +- app/server/src/auth/auth.service.ts | 5 ++++- app/server/src/auth/sessions.service.ts | 2 ++ app/server/test/account.spec.ts | 10 +++++++++- app/server/test/integration/access-tokens.spec.ts | 2 ++ 7 files changed, 20 insertions(+), 5 deletions(-) diff --git a/app/frontend/src/pages/PasswordPages.tsx b/app/frontend/src/pages/PasswordPages.tsx index d9e085b..b15b24d 100644 --- a/app/frontend/src/pages/PasswordPages.tsx +++ b/app/frontend/src/pages/PasswordPages.tsx @@ -86,7 +86,7 @@ export function ResetPasswordPage() { return (

Sign in with your new password.

-

Every device that was signed in has been signed out, and your access tokens were revoked.

+

Every device that was signed in has been signed out. Your access tokens and private calendar link were revoked, and your webhooks were turned off.

diff --git a/app/frontend/src/pages/settings/SecuritySettings.tsx b/app/frontend/src/pages/settings/SecuritySettings.tsx index f369825..a2d8b90 100644 --- a/app/frontend/src/pages/settings/SecuritySettings.tsx +++ b/app/frontend/src/pages/settings/SecuritySettings.tsx @@ -212,7 +212,7 @@ export function SecuritySettings() { void password .run(async () => { await auth.changePassword(proofFrom(target), next); - return 'Password changed. Your other devices were signed out and your access tokens were revoked.'; + return 'Password changed. Your other devices were signed out, your access tokens and private calendar link were revoked, and your webhooks were turned off.'; }, 'Could not change your password') .then((changed) => changed && close()); }} diff --git a/app/frontend/src/pages/settings/SessionsSection.tsx b/app/frontend/src/pages/settings/SessionsSection.tsx index 0c53f8d..9ca248b 100644 --- a/app/frontend/src/pages/settings/SessionsSection.tsx +++ b/app/frontend/src/pages/settings/SessionsSection.tsx @@ -29,7 +29,7 @@ export function SessionsSection() { void action.run(async () => { await auth.request(id ? `/me/sessions/${id}` : '/me/sessions', { method: 'DELETE' }); sessions.mutate((current) => current.filter((session) => (id ? session.id !== id : session.current))); - return id ? 'That device was signed out.' : 'Every other device was signed out and your access tokens were revoked.'; + return id ? 'That device was signed out.' : 'Every other device was signed out, your access tokens and private calendar link were revoked, and your webhooks were turned off.'; }, 'Could not sign that device out'); } diff --git a/app/server/src/auth/auth.service.ts b/app/server/src/auth/auth.service.ts index e9bf405..88ac9d6 100644 --- a/app/server/src/auth/auth.service.ts +++ b/app/server/src/auth/auth.service.ts @@ -360,6 +360,7 @@ export class AuthService implements OnModuleInit { data: { passwordHash: await this.passwords.hash(password), verifiedAt: record.user.verifiedAt ?? now, + calendarFeedHash: null, }, }), this.prisma.verificationToken.deleteMany({ @@ -367,6 +368,7 @@ export class AuthService implements OnModuleInit { }), this.prisma.refreshSession.deleteMany({ where: { userId: record.userId } }), this.prisma.accessToken.updateMany({ where: { userId: record.userId, revokedAt: null }, data: { revokedAt: now } }), + this.prisma.webhook.updateMany({ where: { userId: record.userId, active: true }, data: { active: false } }), ]); await this.attempts.clear(record.user.email); await this.audit.record(record.userId, 'password_reset'); @@ -379,13 +381,14 @@ export class AuthService implements OnModuleInit { await this.prisma.$transaction([ this.prisma.user.update({ where: { id: userId }, - data: { passwordHash: await this.passwords.hash(newPassword) }, + data: { passwordHash: await this.passwords.hash(newPassword), calendarFeedHash: null }, }), this.prisma.verificationToken.deleteMany({ where: { userId, purpose: { in: accountTokens } }, }), this.prisma.refreshSession.deleteMany({ where: { userId } }), this.prisma.accessToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }), + this.prisma.webhook.updateMany({ where: { userId, active: true }, data: { active: false } }), ]); await this.audit.record(userId, 'password_changed'); await this.passwordChangedNotice(user); diff --git a/app/server/src/auth/sessions.service.ts b/app/server/src/auth/sessions.service.ts index dc2cc63..36d6182 100644 --- a/app/server/src/auth/sessions.service.ts +++ b/app/server/src/auth/sessions.service.ts @@ -34,6 +34,8 @@ export class SessionsService { where: { userId, ...(currentSessionId ? { id: { not: currentSessionId } } : {}) }, }), this.prisma.accessToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }), + this.prisma.user.update({ where: { id: userId }, data: { calendarFeedHash: null } }), + this.prisma.webhook.updateMany({ where: { userId, active: true }, data: { active: false } }), ]); await this.audit.record(userId, 'other_sessions_signed_out'); } diff --git a/app/server/test/account.spec.ts b/app/server/test/account.spec.ts index c351c99..d71ca46 100644 --- a/app/server/test/account.spec.ts +++ b/app/server/test/account.spec.ts @@ -62,6 +62,9 @@ function setup(overrides: Record = {}) { accessToken: { updateMany: vi.fn().mockReturnValue('access-token-revoke'), }, + webhook: { + updateMany: vi.fn().mockReturnValue('webhook-disable'), + }, space: { create: vi.fn(), findMany: vi.fn().mockResolvedValue([]), @@ -325,7 +328,7 @@ describe('Account email flows', () => { }); }); - it('resets the password, confirms the address, cancels pending email changes, signs every device out and revokes access tokens', async () => { + it('resets the password, confirms the address, cancels pending email changes, signs every device out and revokes access tokens, the calendar link and webhooks', async () => { const { prisma, service } = setup(); prisma.verificationToken.findUnique.mockResolvedValueOnce({ userId: user.id, @@ -342,6 +345,7 @@ describe('Account email flows', () => { data: { passwordHash: expect.stringMatching(/^scrypt\$32768\$8\$1\$[0-9a-f]{32}\$[0-9a-f]{128}$/), verifiedAt: expect.any(Date), + calendarFeedHash: null, }, }); expect(prisma.verificationToken.deleteMany).toHaveBeenCalledWith({ @@ -353,6 +357,8 @@ describe('Account email flows', () => { expect(prisma.refreshSession.deleteMany).toHaveBeenCalledWith({ where: { userId: user.id } }); expect(prisma.accessToken.updateMany).toHaveBeenCalledWith({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: expect.any(Date) } }); expect(prisma.$transaction.mock.calls.at(-1)![0]).toContain('access-token-revoke'); + expect(prisma.webhook.updateMany).toHaveBeenCalledWith({ where: { userId: user.id, active: true }, data: { active: false } }); + expect(prisma.$transaction.mock.calls.at(-1)![0]).toContain('webhook-disable'); prisma.verificationToken.findUnique.mockResolvedValueOnce({ userId: user.id, @@ -395,6 +401,8 @@ describe('Account email flows', () => { expect(prisma.refreshSession.deleteMany).toHaveBeenCalledWith({ where: { userId: user.id } }); expect(prisma.accessToken.updateMany).toHaveBeenCalledWith({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: expect.any(Date) } }); expect(prisma.$transaction.mock.calls.at(-1)![0]).toContain('access-token-revoke'); + expect(prisma.$transaction.mock.calls.at(-1)![0]).toContain('webhook-disable'); + expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: user.id }, data: { passwordHash: expect.any(String), calendarFeedHash: null } }); expect(prisma.refreshSession.create).toHaveBeenCalledTimes(1); await service.deleteAccount(user.id, { password: 'correct horse battery' }); diff --git a/app/server/test/integration/access-tokens.spec.ts b/app/server/test/integration/access-tokens.spec.ts index b7175fe..05b0777 100644 --- a/app/server/test/integration/access-tokens.spec.ts +++ b/app/server/test/integration/access-tokens.spec.ts @@ -74,9 +74,11 @@ describe('Access tokens against Postgres', () => { const owner = await member('rotator'); const created = await owner.call('POST', '/me/tokens', { name: 'Sync', scopes: ['spaces:read'], password }); expect((await withToken(created.body.token, 'GET', '/spaces')).status).toBe(200); + expect((await owner.call('POST', '/me/calendar-feed')).status).toBe(201); expect((await owner.call('DELETE', '/me/sessions')).status).toBe(204); + expect((await owner.call('GET', '/me/calendar-feed')).body).toEqual({ enabled: false }); expect((await withToken(created.body.token, 'GET', '/spaces')).status).toBe(401); expect((await owner.call('GET', '/me/tokens')).body).toEqual([]); expect((await owner.call('GET', '/spaces')).status).toBe(200); From 2a1a081a18d9fad1a3d9ad699bb5336625f04afc Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:20:44 +0300 Subject: [PATCH 2/2] test(auth): keep the webhook mock beside the space mock --- app/server/test/account.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/server/test/account.spec.ts b/app/server/test/account.spec.ts index d71ca46..ba51531 100644 --- a/app/server/test/account.spec.ts +++ b/app/server/test/account.spec.ts @@ -62,14 +62,14 @@ function setup(overrides: Record = {}) { accessToken: { updateMany: vi.fn().mockReturnValue('access-token-revoke'), }, - webhook: { - updateMany: vi.fn().mockReturnValue('webhook-disable'), - }, space: { create: vi.fn(), findMany: vi.fn().mockResolvedValue([]), deleteMany: vi.fn().mockReturnValue('space-delete'), }, + webhook: { + updateMany: vi.fn().mockReturnValue('webhook-disable'), + }, $transaction: vi.fn((work: unknown) => typeof work === 'function' ? (work as (client: unknown) => unknown)(prisma) : work, ),