feat(passkeys): list, add and revoke from the account page (#40) #334
@@ -9,6 +9,7 @@ import { asyncRoute } from '../asyncRoute';
|
|||||||
import { requireCustomer } from '../middleware/customerAuth';
|
import { requireCustomer } from '../middleware/customerAuth';
|
||||||
import { relyingParty } from '../passkeys/relyingParty';
|
import { relyingParty } from '../passkeys/relyingParty';
|
||||||
import { defaultCredentialName, readCredentialName } from '../passkeys/credentialName';
|
import { defaultCredentialName, readCredentialName } from '../passkeys/credentialName';
|
||||||
|
import { readId } from '../utils';
|
||||||
|
|
||||||
const router = Router();
|
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. */
|
/** Exported for the tests; nothing else constructs a challenge. */
|
||||||
export { CHALLENGE_TTL_MS };
|
export { CHALLENGE_TTL_MS };
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { updateConsent, updateAnalyticsConsent, exportMyData, deleteMyAccount, r
|
|||||||
import { setFavoriteAlerts } from './favoritesApi';
|
import { setFavoriteAlerts } from './favoritesApi';
|
||||||
import { useCustomerAuth } from './CustomerAuthContext';
|
import { useCustomerAuth } from './CustomerAuthContext';
|
||||||
import AccountDetails from './AccountDetails';
|
import AccountDetails from './AccountDetails';
|
||||||
|
import Passkeys from './Passkeys';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -172,6 +173,11 @@ export default function Account({ onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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 />
|
<Divider />
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{/* Order history is a page of its own now. The link stays here because
|
{/* 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));
|
}).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> {
|
export function resendVerificationEmail(): Promise<void> {
|
||||||
// 204 on success, so handle() would throw parsing an empty body. The failure
|
// 204 on success, so handle() would throw parsing an empty body. The failure
|
||||||
// path must still reject: the server's message distinguishes "already
|
// path must still reject: the server's message distinguishes "already
|
||||||
|
|||||||
Reference in New Issue
Block a user