import { Router, Request, Response } from 'express'; import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server'; import type { RegistrationResponseJSON } from '@simplewebauthn/server'; import { pool } from '../db'; import { asyncRoute } from '../asyncRoute'; import { requireCustomer } from '../middleware/customerAuth'; import { relyingParty } from '../passkeys/relyingParty'; import { defaultCredentialName, readCredentialName } from '../passkeys/credentialName'; const router = Router(); /** * Registering a passkey (#38). * * Every route here is behind `requireCustomer`. Registration is not a sign-up * path — it adds a credential to an account that already exists and is already * signed in — so an unauthenticated caller has nothing to register against. * * Signing in with a passkey is #39, and the management screen is #40. Neither * exists yet, so nothing reads these credentials. */ /** * How long a customer has to complete the ceremony. * * Long enough to find a phone and use it; short enough that an intercepted * challenge is not useful for long. The browser's own timeout is set to match, * so the two cannot disagree about when the attempt has expired. */ const CHALLENGE_TTL_MS = 5 * 60 * 1000; interface CredentialIdRow { credential_id: string; transports: string | null; } interface ChallengeRow { challenge: string; } /** * Removes a challenge and reports whether it was there. * * Single use is the whole point, and deleting it *is* the check: a replayed * response finds nothing to delete and is refused. Doing it as one statement * rather than a read followed by a delete means two requests racing cannot both * see the row and both proceed. * * Expiry is part of the same condition, so an expired challenge is refused for * the same reason and by the same statement. */ async function consumeChallenge(customerId: number, kind: string): Promise { const { rows } = await pool.query( `DELETE FROM webauthn_challenges WHERE customer_id = $1 AND kind = $2 AND expires_at > now() RETURNING challenge`, [customerId, kind] ); return rows[0]?.challenge ?? null; } router.post( '/register/begin', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const customerId = req.customerId as number; const rp = relyingParty(); const { rows: existing } = await pool.query( `SELECT credential_id, transports FROM customer_credentials WHERE customer_id = $1`, [customerId] ); const { rows: customers } = await pool.query<{ email: string; first_name: string | null }>( `SELECT email, first_name FROM customers WHERE id = $1`, [customerId] ); const customer = customers[0]; if (!customer) return res.status(404).json({ error: 'not found' }); const options = await generateRegistrationOptions({ rpName: rp.name, rpID: rp.id, userName: customer.email, userDisplayName: customer.first_name ?? customer.email, // The customer id, not the email. A userID is meant to be stable and // opaque; the email is neither, and a customer changing theirs would // otherwise look like a different person to their own authenticator. userID: new TextEncoder().encode(String(customerId)), // Stops the same authenticator being enrolled twice. Without it a // customer pressing register again on a device they already registered // gets a second row that behaves identically to the first, and a // management screen showing two entries they cannot tell apart. excludeCredentials: existing.map((row) => ({ id: row.credential_id })), attestationType: 'none', authenticatorSelection: { // Discoverable, because #39 wants sign-in without the customer first // saying who they are. 'preferred' rather than 'required' so an // authenticator that cannot store one is still usable here. residentKey: 'preferred', userVerification: 'preferred' }, timeout: CHALLENGE_TTL_MS }); // One in-flight registration per customer. Pressing the button twice must // not leave the first challenge usable — the second replaces it, and the // first response is then refused by consumeChallenge finding nothing. await pool.query(`DELETE FROM webauthn_challenges WHERE customer_id = $1 AND kind = 'registration'`, [ customerId ]); await pool.query( `INSERT INTO webauthn_challenges (challenge, customer_id, kind, expires_at) VALUES ($1, $2, 'registration', now() + ($3 || ' milliseconds')::interval)`, [options.challenge, customerId, String(CHALLENGE_TTL_MS)] ); res.json(options); }) ); router.post( '/register/finish', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const customerId = req.customerId as number; const rp = relyingParty(); const expectedChallenge = await consumeChallenge(customerId, 'registration'); if (expectedChallenge === null) { // Deliberately the same answer for "never started", "already used" and // "expired". They are the same thing from here — no challenge this // customer may still complete — and distinguishing them would tell an // attacker which of their guesses was closest. return res.status(400).json({ error: 'start again — that registration is no longer valid' }); } let verification; try { verification = await verifyRegistrationResponse({ response: req.body as RegistrationResponseJSON, expectedChallenge, expectedOrigin: rp.origins, expectedRPID: rp.id }); } catch { // The library throws on a malformed or unverifiable response. The // challenge is already consumed by this point, deliberately: a failed // attempt must not leave one usable for a second try. return res.status(400).json({ error: 'that passkey could not be registered' }); } if (!verification.verified) { return res.status(400).json({ error: 'that passkey could not be registered' }); } const { credential } = verification.registrationInfo; const transports = credential.transports ?? []; const name = readCredentialName((req.body as { name?: unknown }).name) ?? defaultCredentialName(transports); try { await pool.query( `INSERT INTO customer_credentials (customer_id, credential_id, public_key, signature_counter, transports, name) VALUES ($1, $2, $3, $4, $5, $6)`, [ customerId, credential.id, Buffer.from(credential.publicKey).toString('base64url'), credential.counter, JSON.stringify(transports), name ] ); } catch (err) { // credential_id is unique across the table. excludeCredentials should // have stopped the browser offering an already-registered authenticator, // but that is a hint the browser may ignore, so the constraint is what // actually holds — and hitting it means the credential is already // registered rather than that anything is broken. if ((err as { code?: string }).code === '23505') { return res.status(409).json({ error: 'that passkey is already registered' }); } throw err; } res.status(201).json({ name }); }) ); /** Exported for the tests; nothing else constructs a challenge. */ export { CHALLENGE_TTL_MS }; export default router;