feat(auth): passkey / security-key UI — WebAuthn ships end to end

The backend existed since 99abca9 with no way to reach it (QA-B1-01).
Now wired:

- LoginPage: requires_webauthn second step mirrors TOTP — the key prompt
  fires automatically after the password; cancel/retry + back-to-sign-in
- ProfilePage: PasskeySection (mirrors TotpSection) — enroll with a name,
  list keys, password-gated per-key remove + remove-all
- webauthnService: WEBAUTHN_ORIGIN stays authoritative; otherwise accept
  the browser's origin when its hostname equals the RP ID — fixes real
  dev/e2e enrollment where the Vite UI port differs from the API port
  (the browser already enforces RP ID ⊆ origin, so no widened trust)
- Deploy safety: WEBAUTHN_RP_ID documented in .env.example + a boot-time
  prod warning in utils/env.cts (localhost-bound keys silently fail
  behind a real domain)
- e2e/webauthn.probe.spec.js: CDP virtual authenticator drives the full
  lifecycle (enroll -> key sign-in -> password-gated removal), retiring
  Cycle 1's 'needs a human with a key' assumption. Probe suite 18/18.

Server 252/252, client 52/52, typecheck + build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-10 17:30:14 -05:00
parent 68ca7cbb43
commit 88a32a983f
9 changed files with 459 additions and 10 deletions

View File

@ -61,6 +61,14 @@ NODE_ENV=production
#
# TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-string-at-least-32-chars
# WebAuthn / passkeys Relying Party ID. REQUIRED for passkeys to work behind a
# real domain: credentials are cryptographically bound to this hostname, so the
# "localhost" default only works in local dev. Use the bare hostname users sign
# in on (no scheme, no port) — e.g. bills.example.com. Changing it later
# invalidates already-registered keys.
#
# WEBAUTHN_RP_ID=bills.example.com
# ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
# Enable/disable bank sync from the Admin panel. Users connect their own
# SimpleFIN Bridge from the Data page. No environment config required.

View File

@ -189,7 +189,13 @@ export const api = {
get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
authMode: () => get('/auth/mode'),
login: (data: Body) =>
post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data),
post<{
requires_totp?: boolean;
requires_webauthn?: boolean;
challenge_token?: string;
webauthn_options?: unknown;
user?: User;
}>('/auth/login', data),
logout: () => post('/auth/logout'),
logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'),
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),

View File

@ -2,6 +2,7 @@ import { useState, useEffect, type ComponentType, type ReactNode } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
import { startAuthentication } from '@simplewebauthn/browser';
import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { useAuth, type User } from '@/hooks/useAuth';
@ -81,6 +82,12 @@ export default function LoginPage() {
const [totpLoading, setTotpLoading] = useState(false);
const [useRecovery, setUseRecovery] = useState(false);
// WebAuthn second step — mirrors the TOTP flow: password first, then the key.
const [webauthnChallenge, setWebauthnChallenge] = useState<string | null>(null);
const [webauthnOptions, setWebauthnOptions] = useState<unknown>(null);
const [webauthnError, setWebauthnError] = useState('');
const [webauthnLoading, setWebauthnLoading] = useState(false);
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false);
@ -155,6 +162,14 @@ export default function LoginPage() {
setUseRecovery(false);
return;
}
if (data.requires_webauthn) {
setWebauthnChallenge(data.challenge_token ?? null);
setWebauthnOptions(data.webauthn_options ?? null);
setWebauthnError('');
// Prompt for the key immediately — the browser dialog is the whole step.
void verifyWebauthn(data.challenge_token ?? null, data.webauthn_options ?? null);
return;
}
handlePostLogin(data.user!);
} catch (err) {
setError(errMessage(err, 'Login failed.'));
@ -163,6 +178,32 @@ export default function LoginPage() {
}
};
const verifyWebauthn = async (challengeToken: string | null, options: unknown) => {
if (!challengeToken || !options) {
setWebauthnError('Security-key sign-in is unavailable. Please sign in again.');
return;
}
setWebauthnError('');
setWebauthnLoading(true);
try {
const response = await startAuthentication({
optionsJSON: options as Parameters<typeof startAuthentication>[0]['optionsJSON'],
});
const data = await api.webauthnChallenge({ challenge_token: challengeToken, response });
handlePostLogin((data as { user?: User }).user!);
} catch (err) {
// NotAllowedError = user cancelled / timed out at the browser prompt.
const cancelled = (err as Error)?.name === 'NotAllowedError';
setWebauthnError(
cancelled
? 'Security key prompt was cancelled. Try again, or go back and sign in again.'
: errMessage(err, 'Security key verification failed.'),
);
} finally {
setWebauthnLoading(false);
}
};
const handleTotpSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setTotpError('');
@ -340,8 +381,52 @@ export default function LoginPage() {
</div>
</div>
)}
{/* Sign-in card — hidden while auth mode resolves or during TOTP step */}
{/* WebAuthn step — shown after password is accepted for key-protected accounts */}
{webauthnChallenge && (
<div className="surface-elevated p-8 space-y-5">
<div className="flex gap-3">
<BrandGlyph name="security" className="h-11 w-11 rounded-2xl" />
<div>
<h1 className="text-lg font-semibold">Security key</h1>
<p className="text-sm text-muted-foreground mt-1">
Confirm sign-in with your passkey or hardware security key.
</p>
</div>
</div>
{webauthnError && (
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{webauthnError}
</div>
)}
<Button
type="button"
className="w-full"
disabled={webauthnLoading}
onClick={() => void verifyWebauthn(webauthnChallenge, webauthnOptions)}
>
{webauthnLoading ? 'Waiting for your key…' : 'Use security key'}
</Button>
<div className="text-center">
<button
type="button"
className="text-xs text-muted-foreground underline-offset-4 hover:underline hover:text-foreground transition-colors"
onClick={() => {
setWebauthnChallenge(null);
setWebauthnOptions(null);
setWebauthnError('');
}}
>
Back to sign in
</button>
</div>
</div>
)}
{/* Sign-in card — hidden while auth mode resolves or during a 2FA step */}
{!totpChallenge &&
!webauthnChallenge &&
(authMode === null ? (
<div className="surface-elevated flex min-h-[120px] items-center justify-center p-8">
<span className="text-sm text-muted-foreground">Loading</span>

View File

@ -1040,6 +1040,7 @@ function ChangePassword() {
return (
<>
<TotpSection />
<PasskeySection />
<SectionCard
title="Change Password"
icon={KeyRound}
@ -1367,6 +1368,235 @@ function TotpSection() {
);
}
interface PasskeyCredential {
id: number;
credential_id: string;
credential_name: string;
created_at?: string;
}
function PasskeySection() {
const { singleUserMode } = useAuth();
const [enabled, setEnabled] = useState<boolean | null>(null); // null = loading
const [credentials, setCredentials] = useState<PasskeyCredential[]>([]);
const [keyName, setKeyName] = useState('');
const [naming, setNaming] = useState(false); // show the name-your-key form
const [password, setPassword] = useState('');
const [removeTarget, setRemoveTarget] = useState<'all' | string | null>(null); // credential_id or 'all'
const [saving, setSaving] = useState(false);
const load = useCallback(() => {
if (singleUserMode) return;
api
.webauthnStatus()
.then((raw) => setEnabled(!!(raw as { enabled?: boolean }).enabled))
.catch(() => setEnabled(false));
api
.webauthnCredentials()
.then((raw) =>
setCredentials((raw as { credentials?: PasskeyCredential[] }).credentials ?? []),
)
.catch(() => setCredentials([]));
}, [singleUserMode]);
useEffect(() => {
load();
}, [load]);
if (singleUserMode) return null;
if (enabled === null) return null;
const enroll = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const { startRegistration } = await import('@simplewebauthn/browser');
const setup = (await api.webauthnSetup()) as { options?: unknown; challengeId?: string };
const response = await startRegistration({
optionsJSON: setup.options as Parameters<typeof startRegistration>[0]['optionsJSON'],
});
await api.webauthnEnable({
challengeId: setup.challengeId,
response,
credential_name: keyName.trim() || 'Security Key',
});
toast.success('Security key added.');
setNaming(false);
setKeyName('');
load();
} catch (err) {
const cancelled = (err as Error)?.name === 'NotAllowedError';
toast.error(
cancelled
? 'Security key prompt was cancelled.'
: errMessage(err, 'Failed to add security key.'),
);
} finally {
setSaving(false);
}
};
const confirmRemove = async (e: React.FormEvent) => {
e.preventDefault();
if (!removeTarget) return;
setSaving(true);
try {
if (removeTarget === 'all') {
await api.webauthnDisable({ password });
toast.success('Security keys removed.');
} else {
await api.webauthnDeleteCred(removeTarget, { password });
toast.success('Security key removed.');
}
setRemoveTarget(null);
setPassword('');
load();
} catch (err) {
toast.error(errMessage(err, 'Failed to remove security key.'));
} finally {
setSaving(false);
}
};
return (
<SectionCard
title="Passkeys & Security Keys"
icon={KeyRound}
subtitle="Phishing-resistant sign-in with a passkey, fingerprint, or hardware key. If an authenticator app is also enabled, the app code is used at sign-in."
>
<div className="px-6 py-5 space-y-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div
className={`h-2.5 w-2.5 rounded-full ${enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`}
/>
<span className="text-sm">
{enabled
? `${credentials.length} security ${credentials.length === 1 ? 'key' : 'keys'} registered`
: 'Not configured'}
</span>
</div>
<div className="flex gap-2">
{enabled && credentials.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setPassword('');
setRemoveTarget('all');
}}
>
Remove all
</Button>
)}
<Button
size="sm"
onClick={() => {
setKeyName('');
setNaming(true);
}}
disabled={saving}
>
Add key
</Button>
</div>
</div>
{credentials.length > 0 && (
<ul className="divide-y divide-border rounded-md border border-border">
{credentials.map((cred) => (
<li
key={cred.credential_id}
className="flex items-center justify-between px-4 py-2.5"
>
<div>
<div className="text-sm">{cred.credential_name || 'Security Key'}</div>
{cred.created_at && (
<div className="text-xs text-muted-foreground">
Added {new Date(cred.created_at).toLocaleDateString()}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPassword('');
setRemoveTarget(cred.credential_id);
}}
>
Remove
</Button>
</li>
))}
</ul>
)}
{naming && (
<form onSubmit={enroll} className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<label htmlFor="passkey-name" className="text-xs font-medium text-muted-foreground">
Key name
</label>
<Input
id="passkey-name"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
placeholder="e.g. YubiKey, This laptop"
maxLength={60}
autoFocus
/>
</div>
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Waiting for your key…' : 'Register key'}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setNaming(false)}>
Cancel
</Button>
</form>
)}
{removeTarget !== null && (
<form onSubmit={confirmRemove} className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<label
htmlFor="passkey-remove-password"
className="text-xs font-medium text-muted-foreground"
>
Confirm with your password to{' '}
{removeTarget === 'all' ? 'remove all keys' : 'remove this key'}
</label>
<Input
id="passkey-remove-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
/>
</div>
<Button type="submit" variant="destructive" size="sm" disabled={saving || !password}>
{saving ? 'Removing…' : 'Remove'}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setRemoveTarget(null);
setPassword('');
}}
>
Cancel
</Button>
</form>
)}
</div>
</SectionCard>
);
}
function PrivacySettings({
settings,
onSaved,

View File

@ -0,0 +1,76 @@
// WebAuthn passkey flow (QA-B1-01) — driven end-to-end with Playwright's CDP
// virtual authenticator, which retires Cycle 1's "needs a human with a key"
// assumption: enroll on /profile → sign out → password + key sign-in → remove
// the key (restoring the seeded user to password-only for the other specs).
const { test, expect } = require('@playwright/test');
const { E2E_USER, E2E_PASS } = require('./constants');
// Fresh context: this spec exercises the real login flow, not saved state.
test.use({ storageState: { cookies: [], origins: [] } });
test('enroll passkey → sign in with key → remove key', async ({ page }) => {
// Virtual authenticator: an "internal" (platform) CTAP2 key that auto-approves
// presence/verification prompts, so the browser dialog never blocks CI.
const cdp = await page.context().newCDPSession(page);
await cdp.send('WebAuthn.enable');
await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
// 1. Password sign-in.
await page.goto('/login');
await page.locator('#username').fill(E2E_USER);
await page.locator('#password').fill(E2E_PASS);
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
await page
.getByRole('button', { name: 'Got it' })
.click({ timeout: 5000 })
.catch(() => {});
// 2. Enroll a key on /profile.
await page.goto('/profile');
const passkeyCard = page.locator('section, div').filter({
has: page.getByRole('heading', { name: /passkeys & security keys/i }),
});
await page.getByRole('button', { name: 'Add key' }).click();
await page.locator('#passkey-name').fill('Virtual E2E Key');
await page.getByRole('button', { name: 'Register key' }).click();
await expect(page.getByText('Security key added.')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Virtual E2E Key')).toBeVisible();
// 3. Sign out (API logout keeps the flow deterministic), then key sign-in.
const csrf = await (await page.request.get('/api/auth/csrf-token')).json();
await page.request.post('/api/auth/logout', { headers: { 'x-csrf-token': csrf.token } });
await page.goto('/login');
await page.locator('#username').fill(E2E_USER);
await page.locator('#password').fill(E2E_PASS);
await page.getByRole('button', { name: /sign in/i }).click();
// The security-key step auto-fires startAuthentication; the virtual
// authenticator approves it and we land authenticated.
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
await page
.getByRole('button', { name: 'Got it' })
.click({ timeout: 5000 })
.catch(() => {});
await expect(page.getByRole('button', { name: 'Add Bill' })).toBeVisible({ timeout: 10_000 });
// 4. Remove the key (password-gated) so the seeded user is password-only
// again for every other spec in the run.
await page.goto('/profile');
await passkeyCard.getByRole('button', { name: 'Remove', exact: true }).first().click();
await page.locator('#passkey-remove-password').fill(E2E_PASS);
await page.getByRole('button', { name: 'Remove', exact: true }).last().click();
await expect(page.getByText('Security key removed.')).toBeVisible({ timeout: 10_000 });
// Both the TOTP and the passkey card now read "Not configured" (the seeded
// user has neither) — count is the unambiguous assertion.
await expect(page.getByText('Not configured')).toHaveCount(2);
});

View File

@ -59,7 +59,7 @@ module.exports = defineConfig({
// findings are fixed (it becomes a passing regression guard).
{
name: 'probe',
testMatch: /(api\.probe|a11y\.authed)\.spec\.js/,
testMatch: /(api\.probe|a11y\.authed|webauthn\.probe)\.spec\.js/,
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},

View File

@ -517,7 +517,14 @@ router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
if (!challengeId || !response) throw ValidationError('challengeId and response are required');
const db = getDb();
const result = await verifyRegistration(db, req.user.id, challengeId, response, credential_name);
const result = await verifyRegistration(
db,
req.user.id,
challengeId,
response,
credential_name,
req.get('origin'),
);
if (!result.verified) throw ValidationError(result.error || 'Registration failed');
db.prepare(
@ -608,7 +615,13 @@ router.post('/webauthn/challenge', async (req: Req, res: Res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId);
if (!user) throw AuthError('User not found.');
const result = await verifyAuthentication(db, session.userId, session.authChallengeId, response);
const result = await verifyAuthentication(
db,
session.userId,
session.authChallengeId,
response,
req.get('origin'),
);
if (!result.verified) {
logAudit({
user_id: session.userId,

View File

@ -18,11 +18,24 @@ function getRpId(): string {
return process.env.WEBAUTHN_RP_ID || 'localhost';
}
function getOrigin(): string {
// WEBAUTHN_ORIGIN is authoritative when set. Otherwise accept the API origin
// plus the browser's actual origin when its hostname equals the RP ID — this
// covers dev/e2e where the Vite UI port differs from the API port. It does not
// widen the trust boundary: the browser already refuses a ceremony whose RP ID
// is not a registrable suffix of the page origin.
function getOrigin(requestOrigin?: string): string | string[] {
const o = process.env.WEBAUTHN_ORIGIN;
if (o) return o;
const port = process.env.PORT || 3000;
return `http://localhost:${port}`;
const accepted = [`http://localhost:${port}`];
if (requestOrigin) {
try {
if (new URL(requestOrigin).hostname === getRpId()) accepted.push(requestOrigin);
} catch {
/* malformed Origin header — ignore */
}
}
return accepted;
}
// ── Registration ──────────────────────────────────────────────────────────────
@ -80,6 +93,7 @@ async function verifyRegistration(
challengeId: string,
response: any,
credentialName: any,
requestOrigin?: string,
) {
const row = db
.prepare(
@ -92,7 +106,7 @@ async function verifyRegistration(
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: row.challenge,
expectedOrigin: getOrigin(),
expectedOrigin: getOrigin(requestOrigin),
expectedRPID: getRpId(),
});
@ -162,7 +176,13 @@ async function createAuthenticationChallenge(db: Db, userId: number) {
return { options, challengeId };
}
async function verifyAuthentication(db: Db, userId: number, challengeId: string, response: any) {
async function verifyAuthentication(
db: Db,
userId: number,
challengeId: string,
response: any,
requestOrigin?: string,
) {
const row = db
.prepare(
"SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')",
@ -181,7 +201,7 @@ async function verifyAuthentication(db: Db, userId: number, challengeId: string,
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: row.challenge,
expectedOrigin: getOrigin(),
expectedOrigin: getOrigin(requestOrigin),
expectedRPID: getRpId(),
credential: {
id: cred.credential_id,

View File

@ -51,6 +51,17 @@ function validateEnv(): void {
'the DB) to a long random string.',
);
}
// WebAuthn credentials are cryptographically bound to the Relying Party ID.
// With the localhost default, passkeys registered in production would bind to
// "localhost" and silently fail to verify on the real domain.
if (isProd() && !process.env.WEBAUTHN_RP_ID) {
console.warn(
'[config] WARNING: WEBAUTHN_RP_ID is not set in production (defaults to "localhost"). ' +
'Passkeys/security keys will not work behind your real domain. Set it to the bare ' +
'hostname users sign in on, e.g. WEBAUTHN_RP_ID=bills.example.com.',
);
}
}
module.exports = { validateEnv };