Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion examples/react-email-password/convex/emailPassword.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", 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 });

// 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 when asked to", 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: false,
});
expect(result).toEqual({ success: true });

expect(
await t.mutation(api.auth.refreshSession, {
refreshToken: other.tokens.refreshToken,
}),
).toMatchObject({ kind: "rotated" });
});
});

describe("startChangeEmail", () => {
Expand Down
11 changes: 11 additions & 0 deletions examples/react-email-password/src/ChangePasswordForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function ChangePasswordForm({
const [pending, setPending] = useState(false);
const [currentPasswordField, setCurrentPasswordField] = useState("");
const [newPasswordField, setNewPasswordField] = useState("");
const [signOutOtherSessions, setSignOutOtherSessions] = useState(true);
const [message, setMessage] = useState<string | null>(null);

return (
Expand All @@ -23,6 +24,7 @@ export function ChangePasswordForm({
const result = await changePassword({
currentPassword: currentPasswordField,
newPassword: newPasswordField,
signOutOtherSessions,
});
if (result.success) {
setCurrentPasswordField("");
Expand Down Expand Up @@ -92,6 +94,15 @@ export function ChangePasswordForm({
disabled={pending}
/>
</label>
<label>
<input
type="checkbox"
checked={signOutOtherSessions}
onChange={(e) => setSignOutOtherSessions(e.target.checked)}
disabled={pending}
/>
Sign out of all other devices
</label>
{message ? (
<p role="alert">
<strong>{message}</strong>
Expand Down
6 changes: 6 additions & 0 deletions examples/react-email-password/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
49 changes: 49 additions & 0 deletions examples/react-password/convex/password.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,55 @@ describe("changePassword", () => {
});
});

test("signs out the user's other sessions", 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 });

// 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 when asked to", 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: false,
},
);
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, {
Expand Down
16 changes: 15 additions & 1 deletion examples/react-password/src/PasswordSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(true);
const [pending, setPending] = useState(false);
const [status, setStatus] = useState<string | null>(null);

Expand All @@ -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("");
Expand Down Expand Up @@ -81,6 +86,15 @@ export function PasswordSettings() {
disabled={pending}
/>
</label>
<label>
<input
type="checkbox"
checked={signOutOtherSessions}
onChange={(e) => setSignOutOtherSessions(e.target.checked)}
disabled={pending}
/>
Sign out of all other devices
</label>
{status && (
<p role="alert">
<strong>{status}</strong>
Expand Down
6 changes: 6 additions & 0 deletions examples/react-password/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,9 @@ input {
box-sizing: border-box;
font-size: 1rem;
}

input[type="checkbox"] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this style doing? Fixing the margins?

display: inline;
width: auto;
margin: 0 0.5rem 0 0;
}
18 changes: 14 additions & 4 deletions packages/core/src/methods/email-password/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,15 +536,19 @@ export function setupEmailPassword<UsersTable extends string>(
/**
* 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
* unless `signOutOtherSessions` is `false` (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 = true },
): Promise<ChangePasswordResult> => {
const userId = await getAuthUserId(ctx, core.component);
if (userId === null) {
Expand Down Expand Up @@ -592,6 +596,12 @@ export function setupEmailPassword<UsersTable extends string>(
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This revokes sessions only for changePassword; completePasswordRecovery still changes the password and mints a session without revoking existing sessions. Revoke all pre-existing sessions in the recovery path before minting the replacement session, so a stolen refresh token cannot survive an email password reset.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/core/src/methods/email-password/server.ts, line 602:

<comment>This revokes sessions only for `changePassword`; `completePasswordRecovery` still changes the password and mints a session without revoking existing sessions. Revoke all pre-existing sessions in the recovery path before minting the replacement session, so a stolen refresh token cannot survive an email password reset.</comment>

<file context>
@@ -592,6 +596,13 @@ export function setupEmailPassword<UsersTable extends string>(
+            // The current password was just verified, so the caller owns this
+            // session: sign out the user's other sessions, which may belong to
+            // whoever knew the old password.
+            if (signOutOtherSessions) {
+              await ctx.convexAuth.revokeOtherSessions();
+            }
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's in another PR.

await ctx.convexAuth.revokeOtherSessions();
}

const to = await ctx.runQuery(
component.verifiedEmails.getPrimaryEmail,
{ userId },
Expand Down
20 changes: 14 additions & 6 deletions packages/core/src/methods/username-password/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { mutationGeneric } from "convex/server";
import { Infer, v } from "convex/values";
import { getAuthUserId } from "../../server/userId.ts";
import {
Expand Down Expand Up @@ -272,14 +271,19 @@ export function setupUsernamePassword<UsersTable extends string>(

/**
* 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
* unless `signOutOtherSessions` is `false`.
*/
changePassword: mutationGeneric({
args: { currentPassword: v.string(), newPassword: v.string() },
changePassword: authMutation({
args: {
currentPassword: v.string(),
newPassword: v.string(),
signOutOtherSessions: v.optional(v.boolean()),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about making this default to false?

Pilcrow says the following about this:

Whether auth sessions should be invalidated after a user updates their password is a separate discussion. My personal view is that it's a good idea to ask users whether they want to sign out of all devices immediately after changing their password, but it shouldn't be mandatory. Users often update their password because they've forgotten it or simply as a preventative measure. It's also uncommon to invalidate existing sessions when passkeys are registered or removed. If you do choose to invalidate all sessions after a password change, always make that clear so users aren't left guessing.

},
returns: changePasswordResult,
handler: async (
ctx,
{ currentPassword, newPassword },
{ currentPassword, newPassword, signOutOtherSessions = true },
): Promise<ChangePasswordResult> => {
const userId = await getAuthUserId(ctx, core.component);
if (userId === null) {
Expand Down Expand Up @@ -333,7 +337,11 @@ export function setupUsernamePassword<UsersTable extends string>(
);
}

// 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 };
},
}),
Expand Down
Loading