diff --git a/backend/src/routes/passkeys.ts b/backend/src/routes/passkeys.ts index 2b00f0a..6fda703 100644 --- a/backend/src/routes/passkeys.ts +++ b/backend/src/routes/passkeys.ts @@ -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 }; diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx index b3e028a..57507c7 100755 --- a/frontend/src/customer/Account.tsx +++ b/frontend/src/customer/Account.tsx @@ -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) { + + {/* Renders nothing where WebAuthn is unavailable, so a browser that + cannot do this is not offered a button that fails (#40). */} + + {/* Order history is a page of its own now. The link stays here because diff --git a/frontend/src/customer/Passkeys.tsx b/frontend/src/customer/Passkeys.tsx new file mode 100644 index 0000000..0bd9cfd --- /dev/null +++ b/frontend/src/customer/Passkeys.tsx @@ -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([]); + // 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 ( +
+ + Passkeys + + + +
+ + Sign in with your fingerprint, face or screen lock instead of your password. + +
+ + ( + handleRevoke(passkey)} + > + + + ]} + > + + + )} + /> +
+ ); +} diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts index 0b9b7d4..ba16012 100755 --- a/frontend/src/customer/customerApi.ts +++ b/frontend/src/customer/customerApi.ts @@ -172,6 +172,63 @@ export function changeMyEmail(currentPassword: string, email: string): Promise handle(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 { + return fetch('/api/customers/me/passkeys').then(res => handle(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 { + const { startRegistration } = await import('@simplewebauthn/browser'); + + const optionsRes = await fetch('/api/customers/me/passkeys/register/begin', { method: 'POST' }); + const options = await handle[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 { + 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 { // 204 on success, so handle() would throw parsing an empty body. The failure // path must still reject: the server's message distinguishes "already