From 88a32a983f724ba97eb1ccb5acc33c061eb78665 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 10 Jul 2026 17:30:14 -0500 Subject: [PATCH] =?UTF-8?q?feat(auth):=20passkey=20/=20security-key=20UI?= =?UTF-8?q?=20=E2=80=94=20WebAuthn=20ships=20end=20to=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 8 ++ client/api.ts | 8 +- client/pages/LoginPage.tsx | 87 ++++++++++++- client/pages/ProfilePage.tsx | 230 +++++++++++++++++++++++++++++++++++ e2e/webauthn.probe.spec.js | 76 ++++++++++++ playwright.config.js | 2 +- routes/auth.cts | 17 ++- services/webauthnService.cts | 30 ++++- utils/env.cts | 11 ++ 9 files changed, 459 insertions(+), 10 deletions(-) create mode 100644 e2e/webauthn.probe.spec.js diff --git a/.env.example b/.env.example index d2e36c8..dee0316 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/client/api.ts b/client/api.ts index 6675833..53900a5 100644 --- a/client/api.ts +++ b/client/api.ts @@ -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'), diff --git a/client/pages/LoginPage.tsx b/client/pages/LoginPage.tsx index df9201f..c3c8586 100644 --- a/client/pages/LoginPage.tsx +++ b/client/pages/LoginPage.tsx @@ -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(null); + const [webauthnOptions, setWebauthnOptions] = useState(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[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() { )} - {/* Sign-in card — hidden while auth mode resolves or during TOTP step */} + {/* WebAuthn step — shown after password is accepted for key-protected accounts */} + {webauthnChallenge && ( +
+
+ +
+

Security key

+

+ Confirm sign-in with your passkey or hardware security key. +

+
+
+ + {webauthnError && ( +
+ {webauthnError} +
+ )} + + + +
+ +
+
+ )} + {/* Sign-in card — hidden while auth mode resolves or during a 2FA step */} {!totpChallenge && + !webauthnChallenge && (authMode === null ? (
Loading… diff --git a/client/pages/ProfilePage.tsx b/client/pages/ProfilePage.tsx index 304c291..0738bfe 100644 --- a/client/pages/ProfilePage.tsx +++ b/client/pages/ProfilePage.tsx @@ -1040,6 +1040,7 @@ function ChangePassword() { return ( <> + (null); // null = loading + const [credentials, setCredentials] = useState([]); + 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[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 ( + +
+
+
+
+ + {enabled + ? `${credentials.length} security ${credentials.length === 1 ? 'key' : 'keys'} registered` + : 'Not configured'} + +
+
+ {enabled && credentials.length > 0 && ( + + )} + +
+
+ + {credentials.length > 0 && ( +
    + {credentials.map((cred) => ( +
  • +
    +
    {cred.credential_name || 'Security Key'}
    + {cred.created_at && ( +
    + Added {new Date(cred.created_at).toLocaleDateString()} +
    + )} +
    + +
  • + ))} +
+ )} + + {naming && ( +
+
+ + setKeyName(e.target.value)} + placeholder="e.g. YubiKey, This laptop" + maxLength={60} + autoFocus + /> +
+ + +
+ )} + + {removeTarget !== null && ( +
+
+ + setPassword(e.target.value)} + required + autoFocus + /> +
+ + +
+ )} +
+ + ); +} + function PrivacySettings({ settings, onSaved, diff --git a/e2e/webauthn.probe.spec.js b/e2e/webauthn.probe.spec.js new file mode 100644 index 0000000..a342ebf --- /dev/null +++ b/e2e/webauthn.probe.spec.js @@ -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); +}); diff --git a/playwright.config.js b/playwright.config.js index 03fa72b..8904dee 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -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'], }, diff --git a/routes/auth.cts b/routes/auth.cts index 8842ddb..8004900 100644 --- a/routes/auth.cts +++ b/routes/auth.cts @@ -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, diff --git a/services/webauthnService.cts b/services/webauthnService.cts index 1d0d48e..35d6157 100644 --- a/services/webauthnService.cts +++ b/services/webauthnService.cts @@ -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, diff --git a/utils/env.cts b/utils/env.cts index 5c2a903..0b71633 100644 --- a/utils/env.cts +++ b/utils/env.cts @@ -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 };