feat(passkeys): a password reset takes the passkeys with it (#42)
Linting / lint (pull_request) Canceled after 0s
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s

The last issue in the passkeys project, and the only one that adds no capability. It is the safety net, and it exists because this is the piece most likely to be skipped and most expensive to discover missing.

A reset is the recovery path, and recovery has to be complete. The reset already deletes every session on the account, on the reasoning that a reset prompted by a compromise must not leave an intruder signed in for the remaining thirty days of their cookie. A passkey an intruder registered has no expiry at all. Leaving those behind would mean a customer can recover their password and still not have their account back.

The obvious objection is that this hands whoever controls the mailbox a way to strip a customer's passkeys. It does, and it costs nothing: anyone who can complete a reset already controls the email address and therefore already controls the account. The passkeys were not protecting anything by that point.

Anything in flight goes too. An intruder who pressed add a passkey moments before the reset could otherwise finish that ceremony afterwards and put a credential straight back onto the account the reset had just cleared.

Changing a password deliberately does not do this, and the asymmetry is the point. A change requires the current password from someone already signed in, so nothing about it suggests a lockout or a compromise, and it already spares the current session for the same reason. A customer who suspects one particular device revokes that device by name from the account page, which is a better tool than deleting everything. A reset has no idea which credential is the problem, so it takes all of them.

The customer is told twice. Before, in the reset email and on the reset form, unconditionally — that form has no session and is never told whether the account has passkeys, because answering that would make the reset page an oracle for it, so the wording has to read the same to someone who has none. After, with a count, and only when the count is not zero. That moment is the only one where the count can be reported: the rows are gone by the time anyone could go and look. A customer told two were removed who only remembers registering one has just learned something they could not otherwise find out.

That notice is a panel that waits to be dismissed rather than a toast, because a toast dismisses itself and this is the message a customer needs to still be looking at while they decide what to do about it.

The other question this issue asks — whether a reset ends a session established by a passkey — turns out to need no code, because #39 made both paths call one createSession. But "it falls out for free" is a claim, so there is now a test that establishes a session through that exact function and watches the reset end it.

docs/ops/account-recovery.md records the whole policy, including the two answers that are not code. Losing an authenticator is not a lockout: the customer signs in with their password and revokes the lost credential themselves, which is why this change implements nothing for it. Losing the email address is a lockout, and there is deliberately no self-service route out — this shop holds no second proof of identity, and anything invented to fill that gap would be a weaker credential than the one it replaced. The manual route runs through the shop owner verifying against order history, and its third step, changing the address from the admin screen, does not exist yet. That is written down as a gap with its own notification and audit questions rather than smuggled in here.

Verified: backend tsc clean for src and tests, 521 unit tests pass, lint clean apart from warnings that predate this branch; frontend tsc, lint and build clean. The integration and end-to-end suites need a database this machine has no Docker for, so CI is what proves those.

Closes #42

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
synAdmin
2026-09-09 16:03:39 -05:00
co-authored by Claude Opus 5
parent a0238ea4ed
commit 36dbf18916
8 changed files with 407 additions and 6 deletions
+6
View File
@@ -68,6 +68,12 @@ export const TEMPLATES: Record<TemplateKey, TemplateDefinition> = {
defaultBody:
'Someone asked to reset the password for this account.\n\n' +
'[Choose a new password]({{resetUrl}}). This link expires in {{expiresIn}}.\n\n' +
// Said before the customer follows the link rather than after they have
// used it, because it is the one consequence of a reset they cannot undo
// and might have chosen differently about (#42). Worded so it reads the
// same to someone who has never registered one.
'Resetting your password also removes any passkeys saved on this account, ' +
'and signs you out everywhere. You can add your passkeys again afterwards.\n\n' +
"If this wasn't you, you can ignore this email — your password has not changed."
},
+45 -1
View File
@@ -374,6 +374,11 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
const passwordHash = await bcrypt.hash(String(password), PASSWORD_HASH_ROUNDS);
// Reported back so the customer is told, rather than finding an empty list
// the next time they look. Declared out here because it is decided inside the
// transaction and read after it.
let passkeysRemoved = 0;
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -389,6 +394,37 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
// up to 30 days.
await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [customerId]);
await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customerId]);
// Passkeys go with the sessions, for the same reason and more of it (#42).
//
// A reset is the recovery path, and recovery has to be complete. The line
// above already takes the position that a reset must evict anyone else
// holding the account — a session an intruder holds lasts up to 30 days, and
// a passkey an intruder registered lasts forever. Leaving those behind would
// mean a customer can recover their password and still not have their
// account back.
//
// The obvious objection is that this lets whoever controls the mailbox strip
// a customer's passkeys. It does, and it costs nothing: anyone who can
// complete a reset already controls the email address, and therefore already
// controls the account. The passkeys were not protecting anything at that
// point.
//
// Deliberately NOT the same rule as change-password, which leaves passkeys
// alone. That one requires the current password from someone already signed
// in — no part of it suggests a lockout or a compromise, and a customer who
// suspects one device can revoke that device by name on the account page
// (#40). This path has no idea which credential is the problem, so it takes
// all of them.
const removed = await client.query(`DELETE FROM customer_credentials WHERE customer_id = $1`, [customerId]);
passkeysRemoved = removed.rowCount ?? 0;
// Including anything in flight. A registration challenge issued to an
// intruder moments before the reset would otherwise still be completable
// afterwards, which would put a passkey back on the account the reset just
// cleared.
await client.query(`DELETE FROM webauthn_challenges WHERE customer_id = $1`, [customerId]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
@@ -400,7 +436,15 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
const { rows: fresh } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [customerId]);
const sessionToken = await createSession(customerId);
setSessionCookie(res, sessionToken);
res.json(publicCustomer(requireRow(fresh, 'the customer whose password was just reset')));
// The count rides along with the customer rather than being left for the
// account page to imply. A customer who never registered a passkey sees zero
// and is told nothing; one who is told two were removed and only remembers
// registering one has just learned something they could not otherwise find
// out — the row is already gone by the time they could go looking.
res.json({
...publicCustomer(requireRow(fresh, 'the customer whose password was just reset')),
passkeysRemoved
});
}));
router.post('/login', asyncRoute(async (req: Request, res: Response) => {
@@ -1,6 +1,7 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { pool, requireRow } from '../../src/db';
import { createSession } from '../../src/customerSession';
import { resetDb, closeDb } from './setup/testDb';
beforeEach(async () => {
@@ -204,4 +205,131 @@ describe('POST /api/customers/reset-password', () => {
const retry = await request(app).post('/api/customers/reset-password').send({ token, password: 'long-enough-password' });
expect(retry.status).toBe(200);
});
// #42. A reset is the recovery path, so it has to leave the account with no
// way in that the customer did not just establish. Sessions were already
// covered above; a passkey outlives a session without bound.
describe('and the passkeys on the account', () => {
async function customerId(email: string): Promise<number> {
const { rows } = await pool.query<{ id: number }>(`SELECT id FROM customers WHERE email = $1`, [email]);
return requireRow(rows, 'the customer this test just registered').id;
}
// Registering one for real needs an authenticator, which no test has. The
// row is what the reset acts on, so the row is what these insert.
async function giveAPasskey(id: number, credentialId: string): Promise<void> {
await pool.query(
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
VALUES ($1, $2, 'not-a-real-key', 'Test key')`,
[id, credentialId]
);
}
async function passkeyCount(id: number): Promise<number> {
const { rows } = await pool.query<{ n: number }>(
`SELECT count(*)::int AS n FROM customer_credentials WHERE customer_id = $1`,
[id]
);
return requireRow(rows, 'a count of credentials').n;
}
it('removes every passkey, so one an intruder registered does not survive it', async () => {
await register('haskeys@example.com');
const id = await customerId('haskeys@example.com');
await giveAPasskey(id, 'credential-one');
await giveAPasskey(id, 'credential-two');
const token = await requestReset('haskeys@example.com');
const res = await request(app)
.post('/api/customers/reset-password')
.send({ token, password: 'a-brand-new-password' });
expect(res.status).toBe(200);
expect(await passkeyCount(id)).toBe(0);
});
it('says how many it removed, because nothing else can report it afterwards', async () => {
await register('counted@example.com');
const id = await customerId('counted@example.com');
await giveAPasskey(id, 'credential-counted');
const token = await requestReset('counted@example.com');
const res = await request(app)
.post('/api/customers/reset-password')
.send({ token, password: 'a-brand-new-password' });
// The rows are gone by the time the customer could go and look, so a
// reset that removed something and said nothing would hide exactly the
// case worth knowing about.
expect(res.body.passkeysRemoved).toBe(1);
});
it('reports zero for a customer who never registered one', async () => {
await register('nokeys@example.com');
const token = await requestReset('nokeys@example.com');
const res = await request(app)
.post('/api/customers/reset-password')
.send({ token, password: 'a-brand-new-password' });
// The notice on the reset form is shown on this number, so zero has to
// mean zero rather than undefined.
expect(res.status).toBe(200);
expect(res.body.passkeysRemoved).toBe(0);
});
it('leaves another customers passkeys alone', async () => {
await register('mine@example.com');
await register('theirs@example.com');
const mine = await customerId('mine@example.com');
const theirs = await customerId('theirs@example.com');
await giveAPasskey(mine, 'credential-mine');
await giveAPasskey(theirs, 'credential-theirs');
const token = await requestReset('mine@example.com');
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
expect(await passkeyCount(theirs)).toBe(1);
});
it('clears a challenge in flight, so a registration cannot land after the reset', async () => {
await register('inflight@example.com');
const id = await customerId('inflight@example.com');
await pool.query(
`INSERT INTO webauthn_challenges (challenge, customer_id, kind, expires_at)
VALUES ('challenge-in-flight', $1, 'registration', now() + interval '5 minutes')`,
[id]
);
const token = await requestReset('inflight@example.com');
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
// Otherwise an intruder who pressed "add a passkey" moments earlier could
// finish the ceremony afterwards and put a credential back on the account
// the reset had just cleared.
const { rows } = await pool.query<{ n: number }>(
`SELECT count(*)::int AS n FROM webauthn_challenges WHERE customer_id = $1`,
[id]
);
expect(requireRow(rows, 'a count of challenges').n).toBe(0);
});
it('terminates a session established by a passkey, not only one from a password', async () => {
await register('passkeysession@example.com');
const id = await customerId('passkeysession@example.com');
// The call the passkey login route makes. Not an imitation of it — the
// same function, so this asserts the shared session path rather than
// asserting that two paths happen to agree today.
const passkeySession = await createSession(id);
const asPasskeyHolder = () =>
request(app).get('/api/customers/me').set('Cookie', `rd_session=${passkeySession}`);
expect((await asPasskeyHolder()).status).toBe(200);
const token = await requestReset('passkeysession@example.com');
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
expect((await asPasskeyHolder()).status).toBe(401);
});
});
});
+102
View File
@@ -0,0 +1,102 @@
# Account recovery
What happens when a customer cannot get into their account, and what the shop
can do about it. Written for #42, which exists because this is the piece most
likely to be skipped and most expensive to discover missing.
Every other issue in the passkeys project adds a capability. This one is the
safety net.
## The short version
| The customer has lost | They can recover by | Self-service |
| --- | --- | --- |
| Their password | A reset link emailed to them | Yes |
| Their passkey or the device holding it | Signing in with their password | Yes |
| Every passkey and their password | A reset link emailed to them | Yes |
| Access to their email address | Nothing. See below. | No |
The email address is the root of trust. Every self-service route above ends at
it, and none of them can work without it.
## A password reset removes every passkey
This is the decision #42 existed to make, and it is deliberate.
A reset is the recovery path, and recovery has to be complete. The reset already
deletes every session on the account, on the reasoning that a reset prompted by
a compromise must not leave an intruder signed in for the remaining 30 days of
their cookie. A passkey an intruder registered has no expiry at all. Leaving
those behind would mean a customer can recover their password and still not have
their account back.
The obvious objection is that this lets whoever controls the mailbox strip a
customer's passkeys. It does, and it costs nothing: anyone who can complete a
reset already controls the email address and therefore already controls the
account. The passkeys were not protecting anything by that point.
Consequences worth knowing:
- The customer is told before they act. The reset email and the reset form both
say it, unconditionally. The form is not signed in and is never told whether
the account has passkeys, because answering that would make the reset page an
oracle for it.
- The customer is told after they act, with a count, and only when the count is
more than zero. This is the one moment that count can be reported: the rows
are gone by the time anyone could go and look. A customer told two were
removed who only remembers registering one has just learned something they
could not otherwise find out.
- Any WebAuthn challenge in flight goes too. An intruder who pressed "add a
passkey" moments before the reset could otherwise finish the ceremony
afterwards and put a credential straight back.
## Changing a password does not remove passkeys
The asymmetry with the paragraph above is intentional.
`change-password` requires the current password from someone already signed in.
Nothing about that suggests a lockout or a compromise, and it already spares the
current session for the same reason. A customer who suspects one particular
device can revoke that device by name from the account page, which is a better
tool than deleting everything.
A reset has no idea which credential is the problem, so it takes all of them. A
change knows the customer is present and in control, so it takes none.
## Losing the authenticator is not a lockout
A customer who loses the phone or key holding their passkey signs in with their
password as normal, and revokes the lost credential from the account page. This
needs no support involvement and no new capability, which is why #42 implements
nothing for it.
This holds only while every account has a password. It stops holding when #332
lands social sign-in, which creates the first customers with no password at all.
Their recovery route is the identity provider, not a reset link, and #332 owns
that question. The revocation guard in `backend/src/routes/passkeys.ts` already
refuses to delete a customer's only way in, written against that condition
rather than against today's schema, so it starts holding on its own the moment
the condition changes.
## Losing the email address is a lockout
There is no self-service recovery, and there should not be. Recovering an
account whose email is gone means proving identity some other way, and this shop
holds no other way — no phone number, no security questions, no identity
documents. Anything invented to fill that gap would be a weaker credential than
the one it replaces, and would become the easiest way to take an account over.
The route is manual, and it runs through the shop owner:
1. The customer makes contact by whatever means they have.
2. The owner verifies them against order history — items bought, dates, the
shipping address on file. A stranger has none of that.
3. The owner changes the address on the account.
**Step 3 does not exist yet.** The admin customer screen can disable, enable and
release reservations, but it cannot change an email address. Until it does, the
answer to a locked-out customer is a database edit by hand. That gap is worth
its own issue rather than being smuggled into the passkeys project, because it
is an admin capability with its own audit and notification questions — the
customer whose address is being replaced has to be told, exactly as the
self-service change already tells them.
+44 -1
View File
@@ -22,6 +22,11 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
const token = searchParams.get('token');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Set only when the reset actually removed passkeys (#42). A toast would be
// the wrong shape for this: it dismisses itself, and a customer who is told
// that credentials they do not remember registering have just been deleted
// needs to still be looking at that when they decide what to do about it.
const [passkeysRemoved, setPasskeysRemoved] = useState(0);
const { refresh } = useCustomerAuth();
// A link without a token can't do anything, so say so rather than showing a
@@ -48,11 +53,17 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
setLoading(true);
setError(null);
try {
await resetPassword(token as string, values.password);
const result = await resetPassword(token as string, values.password);
// The server signs the customer in as part of the reset, so pick up the
// new session before closing. Closing lands on the storefront: the link
// came from an email, so there is no page behind to return to.
refresh();
// Unless there is something to say. The reset succeeded either way, so
// this is a notice to acknowledge rather than a step still to complete.
if (result.passkeysRemoved > 0) {
setPasskeysRemoved(result.passkeysRemoved);
return;
}
onClose();
} catch (err) {
setError((err as Error).message);
@@ -61,6 +72,31 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
}
}
// The reset is done and the customer is signed in; this is the notice about
// what else it took with it. Shown in place of the form because there is
// nothing left to fill in, and closed by the customer rather than by a timer.
if (passkeysRemoved > 0) {
return (
<Modal title="Password changed" open onCancel={onClose} footer={null} destroyOnHidden>
<Alert
type="warning"
showIcon
message={
passkeysRemoved === 1
? 'Your saved passkey was removed'
: `Your ${passkeysRemoved} saved passkeys were removed`
}
description="Resetting a password removes them, so nobody who had access to your account keeps a way in. You can set them up again from your account page."
/>
<Paragraph style={{ marginTop: 16 }}>
<Button type="primary" onClick={onClose}>
Done
</Button>
</Paragraph>
</Modal>
);
}
return (
<Modal
title="Choose a new password"
@@ -73,6 +109,13 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
<Paragraph type="secondary">
Signing in elsewhere will end you'll stay signed in on this device.
</Paragraph>
{/* Said unconditionally, and it has to be: this form has no session and
is not told whether the account has passkeys, because answering that
would make the reset page an oracle for it. The wording works either
way — someone with none reads it and has nothing to lose. */}
<Paragraph type="secondary">
Any passkeys saved on this account will be removed. You can add them again afterwards.
</Paragraph>
{error && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
<Form layout="vertical" onFinish={onFinish}>
<Form.Item
+13 -2
View File
@@ -132,12 +132,23 @@ export function requestPasswordReset(email: string): Promise<{ status: string }>
}).then(res => handle<{ status: string }>(res));
}
export function resetPassword(token: string, password: string): Promise<Customer> {
/**
* A completed reset, and how many passkeys it removed (#42).
*
* The count is part of the answer rather than something to look up afterwards:
* the credentials are already gone by the time the form could go and ask, so
* the only moment this can be reported is this one.
*/
export interface PasswordResetResult extends Customer {
passkeysRemoved: number;
}
export function resetPassword(token: string, password: string): Promise<PasswordResetResult> {
return fetch('/api/customers/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password })
}).then(res => handle<Customer>(res));
}).then(res => handle<PasswordResetResult>(res));
}
export function updateMyName(firstName: string, lastName: string): Promise<Customer> {
+44 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from './fixtures';
import { readPasswordResetToken } from './support/db';
import { giveCustomerAPasskey, readPasswordResetToken } from './support/db';
const NEW_PASSWORD = 'a-brand-new-password';
@@ -118,4 +118,47 @@ test.describe('Password reset', () => {
await expect(page.getByText('invalid email or password')).toBeVisible();
await expect(header.myAccountButton).toHaveCount(0);
});
// #42. A reset removes every passkey on the account, which is the one thing
// it does that a customer cannot undo and might have chosen differently
// about, so both halves of telling them are asserted here.
test('the reset form says passkeys will be removed before the customer commits', async ({
passwordReset,
page
}) => {
await passwordReset.gotoReset('any-token-will-do');
// Said unconditionally, and it has to be: this form has no session and is
// never told whether the account has passkeys, because answering that would
// make the reset page an oracle for it. So the warning shows on a token
// that was never issued, exactly as it would on a real one.
await expect(page.getByText(/passkeys saved on this account will be removed/i)).toBeVisible();
});
test('a reset that removed a passkey says so, and waits to be acknowledged', async ({
page,
request,
customer,
accountModal,
header,
passwordReset
}) => {
await giveCustomerAPasskey(customer.email);
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
await request.post('/api/customers/request-password-reset', { data: { email: customer.email } });
await passwordReset.gotoReset(await readPasswordResetToken(customer.email));
await passwordReset.setNewPassword(NEW_PASSWORD);
// A toast would be the wrong shape: it dismisses itself, and a customer
// told that a credential they do not remember registering has just been
// deleted needs to still be looking at that when they decide what to do.
await expect(page.getByText('Your saved passkey was removed')).toBeVisible();
await page.getByRole('button', { name: 'Done' }).click();
// The reset still succeeded — this was a notice, not a step that failed.
await header.waitForSignedIn();
});
});
+24
View File
@@ -67,3 +67,27 @@ export async function readPasswordResetToken(email: string): Promise<string> {
return rows[0].token as string;
});
}
/**
* Puts a passkey on an account, for the tests that are about what happens to it
* (#42).
*
* Registering one for real needs an authenticator, which Playwright does not
* have and which no fixture can supply. The credential is a row, and a password
* reset acts on the row rather than on the hardware behind it, so a row is
* enough to test the interaction — nothing here ever tries to sign in with it.
*
* Here rather than in a spec for the same reason as the token read above: the
* next spec that wants a shortcut should have to come and ask for one.
*/
export async function giveCustomerAPasskey(email: string, name = 'Test key'): Promise<void> {
await withClient(async (client) => {
const { rowCount } = await client.query(
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
SELECT id, 'e2e-' || id || '-' || floor(random() * 1e9)::text, 'not-a-real-key', $2
FROM customers WHERE email = $1`,
[email, name]
);
if (rowCount === 0) throw new Error(`no customer to give a passkey to: ${email}`);
});
}