Merge pull request 'feat(passkeys): list, add and revoke from the account page (#40)' (#334) from feature/40-manage-passkeys into main
Reviewed-on: #334
This commit was merged in pull request #334.
This commit is contained in:
@@ -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