Files
redefined-designs/backend/tests/integration/passwordReset.integration.test.ts
T
synAdminandClaude Opus 5 36dbf18916
Linting / lint (pull_request) Canceled after 0s
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
feat(passkeys): a password reset takes the passkeys with it (#42)
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>
2026-09-09 16:03:39 -05:00

336 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import request from 'supertest';
import app from '../../src/app';
import { pool, requireRow } from '../../src/db';
import { createSession } from '../../src/customerSession';
import { resetDb, closeDb } from './setup/testDb';
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
const PASSWORD = 'supersecret123';
async function register(email: string) {
const agent = request.agent(app);
const res = await agent.post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer', email, password: PASSWORD });
expect(res.status).toBe(200);
return agent;
}
async function latestResetToken(email: string): Promise<string | undefined> {
const { rows } = await pool.query(
`SELECT t.token FROM customer_tokens t
JOIN customers c ON c.id = t.customer_id
WHERE c.email = $1 AND t.kind = 'password_reset'
ORDER BY t.created_at DESC LIMIT 1`,
[email]
);
return rows[0]?.token;
}
describe('POST /api/customers/request-password-reset', () => {
it('issues a reset token for a known address', async () => {
await register('known@example.com');
const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'known@example.com' });
expect(res.status).toBe(200);
expect(await latestResetToken('known@example.com')).toBeTruthy();
});
it('reports the same success for an unknown address, and issues nothing', async () => {
const res = await request(app)
.post('/api/customers/request-password-reset')
.send({ email: 'nobody@example.com' });
// Differing responses would turn this endpoint into an oracle for which
// addresses have accounts.
expect(res.status).toBe(200);
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM customer_tokens WHERE kind = 'password_reset'`);
expect(rows[0].n).toBe(0);
});
it('matches the address case-insensitively, as login does', async () => {
await register('mixed@example.com');
await request(app).post('/api/customers/request-password-reset').send({ email: 'MiXeD@Example.com ' });
expect(await latestResetToken('mixed@example.com')).toBeTruthy();
});
it('invalidates an earlier token when a new one is requested', async () => {
await register('twice@example.com');
await request(app).post('/api/customers/request-password-reset').send({ email: 'twice@example.com' });
const first = await latestResetToken('twice@example.com');
await request(app).post('/api/customers/request-password-reset').send({ email: 'twice@example.com' });
const second = await latestResetToken('twice@example.com');
expect(second).not.toBe(first);
const stale = await request(app)
.post('/api/customers/reset-password')
.send({ token: first, password: 'brandnewpassword' });
expect(stale.status).toBe(400);
});
it('rejects a malformed email without pretending to have sent anything', async () => {
const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'not-an-email' });
expect(res.status).toBe(400);
});
it('rate limits repeated requests for the same address', async () => {
await register('flood@example.com');
const statuses: number[] = [];
for (let i = 0; i < 8; i++) {
const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'flood@example.com' });
statuses.push(res.status);
}
// Without a limit this endpoint will send unlimited mail to any address.
expect(statuses).toContain(429);
});
});
describe('POST /api/customers/reset-password', () => {
async function requestReset(email: string): Promise<string> {
await request(app).post('/api/customers/request-password-reset').send({ email });
const token = await latestResetToken(email);
expect(token).toBeTruthy();
return token as string;
}
it('sets a new password and rejects the old one', async () => {
await register('change@example.com');
const token = await requestReset('change@example.com');
const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
expect(res.status).toBe(200);
const oldLogin = await request(app).post('/api/customers/login').send({ email: 'change@example.com', password: PASSWORD });
expect(oldLogin.status).toBe(401);
const newLogin = await request(app)
.post('/api/customers/login')
.send({ email: 'change@example.com', password: 'a-brand-new-password' });
expect(newLogin.status).toBe(200);
});
it('signs the customer in on success', async () => {
await register('signedin@example.com');
const token = await requestReset('signedin@example.com');
const agent = request.agent(app);
const res = await agent.post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
expect(res.status).toBe(200);
const me = await agent.get('/api/customers/me');
expect(me.status).toBe(200);
expect(me.body.email).toBe('signedin@example.com');
});
it('terminates sessions established before the reset', async () => {
const oldSession = await register('evict@example.com');
expect((await oldSession.get('/api/customers/me')).status).toBe(200);
const token = await requestReset('evict@example.com');
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
// A reset prompted by a compromise has to evict the attacker; leaving a
// 30-day cookie alive would defeat the point.
expect((await oldSession.get('/api/customers/me')).status).toBe(401);
});
it('marks the email verified, since the customer received mail at it', async () => {
await register('unverified@example.com');
const token = await requestReset('unverified@example.com');
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
const { rows } = await pool.query(`SELECT email_verified FROM customers WHERE email = $1`, ['unverified@example.com']);
expect(rows[0].email_verified).toBe(true);
});
it('consumes the token so it cannot be replayed', async () => {
await register('replay@example.com');
const token = await requestReset('replay@example.com');
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
const second = await request(app).post('/api/customers/reset-password').send({ token, password: 'another-password' });
expect(second.status).toBe(400);
});
it('rejects an expired token', async () => {
await register('expired@example.com');
const token = await requestReset('expired@example.com');
await pool.query(`UPDATE customer_tokens SET expires_at = now() - interval '1 minute' WHERE token = $1`, [token]);
const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
expect(res.status).toBe(400);
});
it('refuses a verify_email token, so one kind cannot stand in for another', async () => {
await register('crosskind@example.com');
const { rows } = await pool.query(
`SELECT t.token FROM customer_tokens t JOIN customers c ON c.id = t.customer_id
WHERE c.email = $1 AND t.kind = 'verify_email'`,
['crosskind@example.com']
);
expect(rows[0].token).toBeTruthy();
const res = await request(app)
.post('/api/customers/reset-password')
.send({ token: rows[0].token, password: 'a-brand-new-password' });
expect(res.status).toBe(400);
});
it('rejects an unknown token', async () => {
const res = await request(app)
.post('/api/customers/reset-password')
.send({ token: 'nonsense', password: 'a-brand-new-password' });
expect(res.status).toBe(400);
});
it('enforces the same minimum password length as registration', async () => {
await register('short@example.com');
const token = await requestReset('short@example.com');
const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'short' });
expect(res.status).toBe(400);
// A rejected attempt must not burn the token.
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);
});
});
});