feat(passkeys): list, add and revoke from the account page (#40)
The issue calls this the smallest one in the project and the one that makes the rest usable, and that is right: registering a passkey with no way to see or remove it is worse than not offering passkeys at all. Revocation is the row going away. #39 looks a credential up by id on every sign-in, so a deleted one is refused immediately and by construction rather than by a flag something has to remember to check. The delete is scoped to the signed-in customer in the same statement that removes the row, because a credential id is not a secret and the WHERE clause is the only thing making this safe. Reading first and deleting after would leave a window. Both "no such credential" and "not yours" answer 404. The second is the interesting case, and saying so would confirm that some other customer holds that id. The lockout check is written even though it cannot fire. password_hash is NOT NULL, so every customer has a password and removing every passkey still leaves a way in. The issue asks for the check anyway and that is the right call, because it is written against the condition rather than against today's schema — it starts holding on its own the moment the condition changes. #332 is what changes it: social sign-in makes password_hash nullable and creates the first customers with no password, and at that point this branch starts running for real. The list returns name, added and last used, and nothing else. No public key, no credential id, no counter — the customer cannot act on any of them, and the credential id is the one value that identifies an authenticator to anyone holding it. Last used is what actually tells two entries apart when the names are similar: someone about to revoke one needs to know which device they are cutting off, and a creation date does not answer that. The whole ceremony lives in customerApi rather than the component, because it is one operation: options from the server, an attestation from the browser, verification back at the server. A component holding that intermediate state could leave a challenge issued and never answered. Dismissing the browser's prompt rejects, and that is a cancellation rather than a failure. Reporting it as an error would tell a customer something went wrong when they simply changed their mind, so NotAllowedError and AbortError are swallowed and everything else is shown. The server's message on a refused revoke is shown as it arrives too — it says what to do about the last way in, and a generic message would strand the customer on a button that just does not work. The section renders nothing where WebAuthn is unavailable, rather than offering a button that cannot work. Same rule #41 applies to the login form. Verified: tsc clean for src and tests in both workspaces, backend lint at the seven pre-existing warnings with none added, frontend lint clean, 521 unit tests across 36 suites, frontend build green. Not verified: the registration ceremony needs a browser and a real authenticator, which per #41 is a standing limitation of this feature. What CI can prove is the list and revoke endpoints, which are ordinary routes. Closes #40 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
36bbaf99e4
commit
1c8763f30f
@@ -9,6 +9,7 @@ import { asyncRoute } from '../asyncRoute';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { relyingParty } from '../passkeys/relyingParty';
|
||||
import { defaultCredentialName, readCredentialName } from '../passkeys/credentialName';
|
||||
import { readId } from '../utils';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -192,6 +193,99 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* The customer's registered passkeys (#40).
|
||||
*
|
||||
* Registering one with no way to see or remove it is worse than not offering
|
||||
* passkeys at all, which is what makes this the smallest issue in the project
|
||||
* and the one that makes the rest usable.
|
||||
*
|
||||
* `last_used_at` is here because it is the only thing that tells two entries
|
||||
* apart when the names are similar — a customer about to revoke one needs to
|
||||
* know which device they are cutting off, and "used an hour ago" answers that
|
||||
* where a creation date does not.
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
requireCustomer,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<{
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: Date;
|
||||
last_used_at: Date | null;
|
||||
}>(
|
||||
`SELECT id, name, created_at, last_used_at
|
||||
FROM customer_credentials
|
||||
WHERE customer_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[req.customerId]
|
||||
);
|
||||
|
||||
// No public key, no credential id, no counter. The customer cannot act on
|
||||
// any of them, and a credential id is the one value that identifies this
|
||||
// authenticator to anyone who has it.
|
||||
res.json(rows);
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireCustomer,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
// Removing the last way in must not lock the customer out.
|
||||
//
|
||||
// This cannot fire today: password_hash is NOT NULL, so every customer has
|
||||
// a password and removing every passkey still leaves them a way to sign in.
|
||||
// The issue asks for the check anyway, and that is the right call — it is
|
||||
// written against the condition rather than against today's schema, so it
|
||||
// starts holding on its own the moment the condition changes.
|
||||
//
|
||||
// #332 is what changes it. Social sign-in makes password_hash nullable and
|
||||
// creates the first customers with no password, at which point a customer
|
||||
// whose only credential is a passkey can genuinely lock themselves out with
|
||||
// this button. When that lands, `has_password` stops being always true and
|
||||
// this branch starts running.
|
||||
const { rows: waysIn } = await pool.query<{ has_password: boolean; credentials: string }>(
|
||||
`SELECT (c.password_hash IS NOT NULL) AS has_password,
|
||||
(SELECT count(*) FROM customer_credentials WHERE customer_id = c.id) AS credentials
|
||||
FROM customers c
|
||||
WHERE c.id = $1`,
|
||||
[req.customerId]
|
||||
);
|
||||
const waysInRow = waysIn[0];
|
||||
if (waysInRow && !waysInRow.has_password && Number(waysInRow.credentials) <= 1) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
'that is the only way you can sign in — set a password first, or add another passkey'
|
||||
});
|
||||
}
|
||||
|
||||
// Scoped to the signed-in customer in the same statement that deletes.
|
||||
// Reading first and deleting after would leave a window, and a credential
|
||||
// id is not a secret — the only thing making this safe is that the WHERE
|
||||
// names whose it must be.
|
||||
//
|
||||
// Revocation is the row going away: #39 looks the credential up by id on
|
||||
// every sign-in, so a deleted one is refused immediately and by
|
||||
// construction rather than by a flag something has to remember to check.
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM customer_credentials WHERE id = $1 AND customer_id = $2`,
|
||||
[id, req.customerId]
|
||||
);
|
||||
|
||||
// 404 for both "no such credential" and "not yours", deliberately. The
|
||||
// second is the interesting case and saying so would confirm that some
|
||||
// other customer holds that id.
|
||||
if (rowCount === 0) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
res.status(204).end();
|
||||
})
|
||||
);
|
||||
|
||||
/** Exported for the tests; nothing else constructs a challenge. */
|
||||
export { CHALLENGE_TTL_MS };
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { updateConsent, updateAnalyticsConsent, exportMyData, deleteMyAccount, r
|
||||
import { setFavoriteAlerts } from './favoritesApi';
|
||||
import { useCustomerAuth } from './CustomerAuthContext';
|
||||
import AccountDetails from './AccountDetails';
|
||||
import Passkeys from './Passkeys';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -172,6 +173,11 @@ export default function Account({ onClose }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
{/* Renders nothing where WebAuthn is unavailable, so a browser that
|
||||
cannot do this is not offered a button that fails (#40). */}
|
||||
<Passkeys />
|
||||
|
||||
<Divider />
|
||||
<Space wrap>
|
||||
{/* Order history is a page of its own now. The link stays here because
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Button from 'antd/es/button';
|
||||
import List from 'antd/es/list';
|
||||
import Space from 'antd/es/space';
|
||||
import Typography from 'antd/es/typography';
|
||||
import Popconfirm from 'antd/es/popconfirm';
|
||||
import message from 'antd/es/message';
|
||||
import { Passkey, fetchPasskeys, registerPasskey, revokePasskey } from './customerApi';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* The passkeys section of the account page (#40).
|
||||
*
|
||||
* Registering a passkey with no way to see or remove it is worse than not
|
||||
* offering passkeys at all, which is the whole reason this exists.
|
||||
*
|
||||
* Rendered only where WebAuthn is available. A browser without it gets nothing
|
||||
* rather than a button that cannot work — the same rule #41 applies to the login
|
||||
* form, and the reason the check is here rather than inside the click handler.
|
||||
*/
|
||||
const SUPPORTED =
|
||||
typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function';
|
||||
|
||||
function whenUsed(passkey: Passkey): string {
|
||||
// The thing that actually tells two entries apart when the names are similar.
|
||||
// A customer about to revoke one needs to know which device they are cutting
|
||||
// off, and "last used" answers that where a creation date does not.
|
||||
if (!passkey.last_used_at) return 'never used';
|
||||
return `last used ${new Date(passkey.last_used_at).toLocaleDateString()}`;
|
||||
}
|
||||
|
||||
export default function Passkeys() {
|
||||
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
|
||||
// Seeded from support rather than always true, so the unsupported case needs
|
||||
// no effect to correct it. Setting it synchronously in the effect below would
|
||||
// be a state update during render as far as the hooks rule is concerned, and
|
||||
// suppressing that would be hiding the smell rather than removing it.
|
||||
const [loading, setLoading] = useState(SUPPORTED);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
return fetchPasskeys()
|
||||
.then(setPasskeys)
|
||||
// Distinguished from an empty list on purpose: "you have no passkeys" and
|
||||
// "we could not find out" look identical otherwise, and the first invites
|
||||
// a customer to add one they may already have.
|
||||
.catch(() => message.error('Could not load your passkeys'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// `load` raises the pending flag before fetching. The rule cannot tell that
|
||||
// from a value that was already knowable, and this is the former — the same
|
||||
// exception CustomerAuthContext and DraftQueue take, for the same reason.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (SUPPORTED) void load();
|
||||
}, [load]);
|
||||
|
||||
if (!SUPPORTED) return null;
|
||||
|
||||
async function handleAdd() {
|
||||
setAdding(true);
|
||||
try {
|
||||
setPasskeys(await registerPasskey());
|
||||
message.success('Passkey added');
|
||||
} catch (err) {
|
||||
// Dismissing the browser's prompt rejects, and that is a cancellation
|
||||
// rather than a failure. Reporting it as an error would tell a customer
|
||||
// something went wrong when they simply changed their mind.
|
||||
const name = (err as { name?: string }).name;
|
||||
if (name !== 'NotAllowedError' && name !== 'AbortError') {
|
||||
message.error(`Couldn't add a passkey — ${(err as Error).message}`);
|
||||
}
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(passkey: Passkey) {
|
||||
try {
|
||||
await revokePasskey(passkey.id);
|
||||
} catch (err) {
|
||||
// The server's message is shown rather than replaced. Refusing to remove
|
||||
// the last way in says what to do about it, and a generic message would
|
||||
// strand the customer on a button that just does not work.
|
||||
message.error((err as Error).message);
|
||||
return;
|
||||
}
|
||||
message.success(`Removed ${passkey.name}`);
|
||||
void load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space align="center" style={{ marginBottom: 8 }}>
|
||||
<Text strong>Passkeys</Text>
|
||||
<Button size="small" loading={adding} onClick={handleAdd}>
|
||||
Add a passkey
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Sign in with your fingerprint, face or screen lock instead of your password.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={passkeys}
|
||||
locale={{ emptyText: 'No passkeys yet' }}
|
||||
renderItem={(passkey) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Popconfirm
|
||||
key="remove"
|
||||
title={`Remove ${passkey.name}?`}
|
||||
description="You will not be able to sign in with this device again."
|
||||
okText="Remove"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleRevoke(passkey)}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
Remove
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={passkey.name}
|
||||
description={`Added ${new Date(passkey.created_at).toLocaleDateString()} · ${whenUsed(passkey)}`}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -172,6 +172,63 @@ export function changeMyEmail(currentPassword: string, email: string): Promise<C
|
||||
}).then(res => handle<Customer>(res));
|
||||
}
|
||||
|
||||
/** A registered passkey, as the account page lists it (#40). */
|
||||
export interface Passkey {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export function fetchPasskeys(): Promise<Passkey[]> {
|
||||
return fetch('/api/customers/me/passkeys').then(res => handle<Passkey[]>(res));
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a passkey on this device.
|
||||
*
|
||||
* Both halves of the ceremony live here rather than in the component, because
|
||||
* they are one operation: options come from the server, the browser turns them
|
||||
* into an attestation, and the server verifies it. A component holding the
|
||||
* intermediate state could leave a challenge issued and never answered.
|
||||
*
|
||||
* `startRegistration` is what prompts the customer. It throws when they dismiss
|
||||
* that prompt, which is a cancellation rather than a failure — the caller tells
|
||||
* them apart.
|
||||
*/
|
||||
export async function registerPasskey(name?: string): Promise<Passkey[]> {
|
||||
const { startRegistration } = await import('@simplewebauthn/browser');
|
||||
|
||||
const optionsRes = await fetch('/api/customers/me/passkeys/register/begin', { method: 'POST' });
|
||||
const options = await handle<Parameters<typeof startRegistration>[0]['optionsJSON']>(optionsRes);
|
||||
|
||||
const attestation = await startRegistration({ optionsJSON: options });
|
||||
|
||||
const finishRes = await fetch('/api/customers/me/passkeys/register/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...attestation, name })
|
||||
});
|
||||
await handle<{ name: string }>(finishRes);
|
||||
|
||||
// The fresh list rather than the one row, so the caller cannot render a list
|
||||
// that disagrees with the server about what was just added.
|
||||
return fetchPasskeys();
|
||||
}
|
||||
|
||||
export function revokePasskey(id: number): Promise<void> {
|
||||
return fetch(`/api/customers/me/passkeys/${id}`, { method: 'DELETE' }).then(async (res) => {
|
||||
// 204 on success, so handle() would throw on an empty body. The failure
|
||||
// message matters here — refusing to remove the last way in says what to do
|
||||
// about it, and replacing that with something generic would strand the
|
||||
// customer on a button that simply does not work.
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function resendVerificationEmail(): Promise<void> {
|
||||
// 204 on success, so handle() would throw parsing an empty body. The failure
|
||||
// path must still reject: the server's message distinguishes "already
|
||||
|
||||
Reference in New Issue
Block a user