From ae2305efe33631786a5211abbd9bcde852eea295 Mon Sep 17 00:00:00 2001 From: Christian Wyglendowski Date: Tue, 20 Oct 2026 17:19:35 -0400 Subject: [PATCH] Revoke other sessions when a user resets their password --- .../convex/emailPassword.test.ts | 71 ++++++++++++++++++- .../src/ChangePasswordForm.tsx | 11 +++ examples/react-email-password/src/index.css | 6 ++ .../react-password/convex/password.test.ts | 49 +++++++++++++ .../react-password/src/PasswordSettings.tsx | 16 ++++- examples/react-password/src/index.css | 6 ++ .../core/src/methods/email-password/server.ts | 18 +++-- .../src/methods/username-password/server.ts | 20 ++++-- 8 files changed, 185 insertions(+), 12 deletions(-) diff --git a/examples/react-email-password/convex/emailPassword.test.ts b/examples/react-email-password/convex/emailPassword.test.ts index c11a90b59..28fe1f320 100644 --- a/examples/react-email-password/convex/emailPassword.test.ts +++ b/examples/react-email-password/convex/emailPassword.test.ts @@ -2,7 +2,7 @@ import { convexTest } from "convex-test"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { AnyDataModel, GenericMutationCtx } from "convex/server"; import { api, components } from "./_generated/api"; -import { registerCore } from "@convex-dev/auth/test"; +import { registerCore, sessionIdentity } from "@convex-dev/auth/test"; import { registerPasswordProvider } from "@convex-dev/auth/components/password/test"; import { registerEmail, @@ -608,6 +608,75 @@ describe("changePassword", () => { expect(sent[0].to).toEqual([EMAIL]); expect(sent[0].subject).toMatch(/password/i); }); + + test("signs out the user's other sessions when requested", async () => { + const t = await setup(); + await seedSignedUpUser(t); + const signIn = () => + t + .withRequestMetadata({ ip: IP }) + .mutation(api.auth.signInWithEmailPassword, { + email: EMAIL, + password: PASSWORD, + }); + const current = await signIn(); + const other = await signIn(); + if (current.status !== "complete" || other.status !== "complete") { + throw new Error("Sign-in failed"); + } + + const result = await t + .withIdentity(sessionIdentity(current.tokens)) + .mutation(api.auth.changePassword, { + currentPassword: PASSWORD, + newPassword: "brand new horse staple", + signOutOtherSessions: true, + }); + expect(result).toEqual({ success: true }); + + // The other session can no longer refresh; the current one can. + expect( + await t.mutation(api.auth.refreshSession, { + refreshToken: other.tokens.refreshToken, + }), + ).toMatchObject({ kind: "noSession" }); + expect( + await t.mutation(api.auth.refreshSession, { + refreshToken: current.tokens.refreshToken, + }), + ).toMatchObject({ kind: "rotated" }); + }); + + test("keeps the user's other sessions by default", async () => { + const t = await setup(); + await seedSignedUpUser(t); + const signIn = () => + t + .withRequestMetadata({ ip: IP }) + .mutation(api.auth.signInWithEmailPassword, { + email: EMAIL, + password: PASSWORD, + }); + const current = await signIn(); + const other = await signIn(); + if (current.status !== "complete" || other.status !== "complete") { + throw new Error("Sign-in failed"); + } + + const result = await t + .withIdentity(sessionIdentity(current.tokens)) + .mutation(api.auth.changePassword, { + currentPassword: PASSWORD, + newPassword: "brand new horse staple", + }); + expect(result).toEqual({ success: true }); + + expect( + await t.mutation(api.auth.refreshSession, { + refreshToken: other.tokens.refreshToken, + }), + ).toMatchObject({ kind: "rotated" }); + }); }); describe("startChangeEmail", () => { diff --git a/examples/react-email-password/src/ChangePasswordForm.tsx b/examples/react-email-password/src/ChangePasswordForm.tsx index 634895a51..27389e937 100644 --- a/examples/react-email-password/src/ChangePasswordForm.tsx +++ b/examples/react-email-password/src/ChangePasswordForm.tsx @@ -11,6 +11,7 @@ export function ChangePasswordForm({ const [pending, setPending] = useState(false); const [currentPasswordField, setCurrentPasswordField] = useState(""); const [newPasswordField, setNewPasswordField] = useState(""); + const [signOutOtherSessions, setSignOutOtherSessions] = useState(false); const [message, setMessage] = useState(null); return ( @@ -23,6 +24,7 @@ export function ChangePasswordForm({ const result = await changePassword({ currentPassword: currentPasswordField, newPassword: newPasswordField, + signOutOtherSessions, }); if (result.success) { setCurrentPasswordField(""); @@ -92,6 +94,15 @@ export function ChangePasswordForm({ disabled={pending} /> + {message ? (

{message} diff --git a/examples/react-email-password/src/index.css b/examples/react-email-password/src/index.css index 9744a9e1f..bbd7cf9ce 100644 --- a/examples/react-email-password/src/index.css +++ b/examples/react-email-password/src/index.css @@ -33,3 +33,9 @@ input { box-sizing: border-box; font-size: 1rem; } + +input[type="checkbox"] { + display: inline; + width: auto; + margin: 0 0.5rem 0 0; +} diff --git a/examples/react-password/convex/password.test.ts b/examples/react-password/convex/password.test.ts index 7db5a4374..6837ebde1 100644 --- a/examples/react-password/convex/password.test.ts +++ b/examples/react-password/convex/password.test.ts @@ -241,6 +241,55 @@ describe("changePassword", () => { }); }); + test("signs out the user's other sessions when requested", async () => { + const t = await setup(); + const current = (await signUp(t, "alice", PASSWORD)) as PasswordSuccess; + const other = (await signIn(t, "alice", PASSWORD)) as PasswordSuccess; + + const result = await asUser(t, current.tokens).mutation( + api.auth.changePassword, + { + currentPassword: PASSWORD, + newPassword: NEW_PASSWORD, + signOutOtherSessions: true, + }, + ); + expect(result).toEqual({ success: true }); + + // The other session can no longer refresh; the current one can. + expect( + await t.mutation(api.auth.refreshSession, { + refreshToken: other.tokens.refreshToken, + }), + ).toMatchObject({ kind: "noSession" }); + expect( + await t.mutation(api.auth.refreshSession, { + refreshToken: current.tokens.refreshToken, + }), + ).toMatchObject({ kind: "rotated" }); + }); + + test("keeps the user's other sessions by default", async () => { + const t = await setup(); + const current = (await signUp(t, "alice", PASSWORD)) as PasswordSuccess; + const other = (await signIn(t, "alice", PASSWORD)) as PasswordSuccess; + + const result = await asUser(t, current.tokens).mutation( + api.auth.changePassword, + { + currentPassword: PASSWORD, + newPassword: NEW_PASSWORD, + }, + ); + expect(result).toEqual({ success: true }); + + expect( + await t.mutation(api.auth.refreshSession, { + refreshToken: other.tokens.refreshToken, + }), + ).toMatchObject({ kind: "rotated" }); + }); + test("refuses a signed-out caller", async () => { const { t } = await signedUpUser(); const result = await t.mutation(api.auth.changePassword, { diff --git a/examples/react-password/src/PasswordSettings.tsx b/examples/react-password/src/PasswordSettings.tsx index cd5783891..5781ff9f1 100644 --- a/examples/react-password/src/PasswordSettings.tsx +++ b/examples/react-password/src/PasswordSettings.tsx @@ -9,6 +9,7 @@ export function PasswordSettings() { const changePassword = useMutation(api.auth.changePassword); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); + const [signOutOtherSessions, setSignOutOtherSessions] = useState(false); const [pending, setPending] = useState(false); const [status, setStatus] = useState(null); @@ -24,7 +25,11 @@ export function PasswordSettings() { setStatus(null); setPending(true); try { - const result = await changePassword({ currentPassword, newPassword }); + const result = await changePassword({ + currentPassword, + newPassword, + signOutOtherSessions, + }); if (result.success) { setCurrentPassword(""); setNewPassword(""); @@ -81,6 +86,15 @@ export function PasswordSettings() { disabled={pending} /> + {status && (

{status} diff --git a/examples/react-password/src/index.css b/examples/react-password/src/index.css index 9744a9e1f..bbd7cf9ce 100644 --- a/examples/react-password/src/index.css +++ b/examples/react-password/src/index.css @@ -33,3 +33,9 @@ input { box-sizing: border-box; font-size: 1rem; } + +input[type="checkbox"] { + display: inline; + width: auto; + margin: 0 0.5rem 0 0; +} diff --git a/packages/core/src/methods/email-password/server.ts b/packages/core/src/methods/email-password/server.ts index 9e47dbcc3..da452d2e9 100644 --- a/packages/core/src/methods/email-password/server.ts +++ b/packages/core/src/methods/email-password/server.ts @@ -536,15 +536,19 @@ export function setupEmailPassword( /** * Change the signed-in user's password. Requires the session *and* the * current password (OWASP ASVS v5 6.2.3), and notifies the primary - * email address (ASVS 6.3.7). + * email address (ASVS 6.3.7). Signs out the user's other sessions + * if `signOutOtherSessions` is `true` (ASVS v5 7.4.3). */ - // TODO: option to invalidate the user's other sessions. changePassword: authMutation({ - args: { currentPassword: v.string(), newPassword: v.string() }, + args: { + currentPassword: v.string(), + newPassword: v.string(), + signOutOtherSessions: v.optional(v.boolean()), + }, returns: changePasswordResult, handler: async ( ctx, - { currentPassword, newPassword }, + { currentPassword, newPassword, signOutOtherSessions = false }, ): Promise => { const userId = await getAuthUserId(ctx, core.component); if (userId === null) { @@ -592,6 +596,12 @@ export function setupEmailPassword( return { success: false, userError: setResult.userError }; } + // The current password was just verified, so the caller owns this + // session: sign out the user's other sessions if requested. + if (signOutOtherSessions) { + await ctx.convexAuth.revokeOtherSessions(); + } + const to = await ctx.runQuery( component.verifiedEmails.getPrimaryEmail, { userId }, diff --git a/packages/core/src/methods/username-password/server.ts b/packages/core/src/methods/username-password/server.ts index 8cc22474a..6e2e241c5 100644 --- a/packages/core/src/methods/username-password/server.ts +++ b/packages/core/src/methods/username-password/server.ts @@ -1,4 +1,3 @@ -import { mutationGeneric } from "convex/server"; import { Infer, v } from "convex/values"; import { getAuthUserId } from "../../server/userId.ts"; import { @@ -272,14 +271,19 @@ export function setupUsernamePassword( /** * Change the password of the signed-in user. The user must re-authenticate - * by giving their current password. + * by giving their current password. Signs out the user's other sessions + * if `signOutOtherSessions` is `true`. */ - changePassword: mutationGeneric({ - args: { currentPassword: v.string(), newPassword: v.string() }, + changePassword: authMutation({ + args: { + currentPassword: v.string(), + newPassword: v.string(), + signOutOtherSessions: v.optional(v.boolean()), + }, returns: changePasswordResult, handler: async ( ctx, - { currentPassword, newPassword }, + { currentPassword, newPassword, signOutOtherSessions = false }, ): Promise => { const userId = await getAuthUserId(ctx, core.component); if (userId === null) { @@ -333,7 +337,11 @@ export function setupUsernamePassword( ); } - // TODO(nicolas) Allow the user to choose to revoke their existing sessions here (needs core support) + // The current password was just verified, so the caller owns this + // session: sign out the user's other sessions if requested. + if (signOutOtherSessions) { + await ctx.convexAuth.revokeOtherSessions(); + } return { success: true }; }, }),