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:
parent
68ca7cbb43
commit
88a32a983f
|
|
@ -61,6 +61,14 @@ NODE_ENV=production
|
||||||
#
|
#
|
||||||
# TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-string-at-least-32-chars
|
# 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) ─────────────────────────────────────────────────────
|
# ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
|
||||||
# Enable/disable bank sync from the Admin panel. Users connect their own
|
# Enable/disable bank sync from the Admin panel. Users connect their own
|
||||||
# SimpleFIN Bridge from the Data page. No environment config required.
|
# SimpleFIN Bridge from the Data page. No environment config required.
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,13 @@ export const api = {
|
||||||
get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
|
get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
|
||||||
authMode: () => get('/auth/mode'),
|
authMode: () => get('/auth/mode'),
|
||||||
login: (data: Body) =>
|
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'),
|
logout: () => post('/auth/logout'),
|
||||||
logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'),
|
logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'),
|
||||||
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
|
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect, type ComponentType, type ReactNode } from 'react';
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react';
|
import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { errMessage } from '@/lib/utils';
|
import { errMessage } from '@/lib/utils';
|
||||||
import { useAuth, type User } from '@/hooks/useAuth';
|
import { useAuth, type User } from '@/hooks/useAuth';
|
||||||
|
|
@ -81,6 +82,12 @@ export default function LoginPage() {
|
||||||
const [totpLoading, setTotpLoading] = useState(false);
|
const [totpLoading, setTotpLoading] = useState(false);
|
||||||
const [useRecovery, setUseRecovery] = 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 [newPw, setNewPw] = useState('');
|
||||||
const [confirmPw, setConfirmPw] = useState('');
|
const [confirmPw, setConfirmPw] = useState('');
|
||||||
const [pwLoading, setPwLoading] = useState(false);
|
const [pwLoading, setPwLoading] = useState(false);
|
||||||
|
|
@ -155,6 +162,14 @@ export default function LoginPage() {
|
||||||
setUseRecovery(false);
|
setUseRecovery(false);
|
||||||
return;
|
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!);
|
handlePostLogin(data.user!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errMessage(err, 'Login failed.'));
|
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) => {
|
const handleTotpSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setTotpError('');
|
setTotpError('');
|
||||||
|
|
@ -340,8 +381,52 @@ export default function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
</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 &&
|
{!totpChallenge &&
|
||||||
|
!webauthnChallenge &&
|
||||||
(authMode === null ? (
|
(authMode === null ? (
|
||||||
<div className="surface-elevated flex min-h-[120px] items-center justify-center p-8">
|
<div className="surface-elevated flex min-h-[120px] items-center justify-center p-8">
|
||||||
<span className="text-sm text-muted-foreground">Loading…</span>
|
<span className="text-sm text-muted-foreground">Loading…</span>
|
||||||
|
|
|
||||||
|
|
@ -1040,6 +1040,7 @@ function ChangePassword() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TotpSection />
|
<TotpSection />
|
||||||
|
<PasskeySection />
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Change Password"
|
title="Change Password"
|
||||||
icon={KeyRound}
|
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({
|
function PrivacySettings({
|
||||||
settings,
|
settings,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -59,7 +59,7 @@ module.exports = defineConfig({
|
||||||
// findings are fixed (it becomes a passing regression guard).
|
// findings are fixed (it becomes a passing regression guard).
|
||||||
{
|
{
|
||||||
name: 'probe',
|
name: 'probe',
|
||||||
testMatch: /(api\.probe|a11y\.authed)\.spec\.js/,
|
testMatch: /(api\.probe|a11y\.authed|webauthn\.probe)\.spec\.js/,
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
dependencies: ['setup'],
|
dependencies: ['setup'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -517,7 +517,14 @@ router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
|
||||||
if (!challengeId || !response) throw ValidationError('challengeId and response are required');
|
if (!challengeId || !response) throw ValidationError('challengeId and response are required');
|
||||||
|
|
||||||
const db = getDb();
|
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');
|
if (!result.verified) throw ValidationError(result.error || 'Registration failed');
|
||||||
|
|
||||||
db.prepare(
|
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);
|
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId);
|
||||||
if (!user) throw AuthError('User not found.');
|
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) {
|
if (!result.verified) {
|
||||||
logAudit({
|
logAudit({
|
||||||
user_id: session.userId,
|
user_id: session.userId,
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,24 @@ function getRpId(): string {
|
||||||
return process.env.WEBAUTHN_RP_ID || 'localhost';
|
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;
|
const o = process.env.WEBAUTHN_ORIGIN;
|
||||||
if (o) return o;
|
if (o) return o;
|
||||||
const port = process.env.PORT || 3000;
|
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 ──────────────────────────────────────────────────────────────
|
// ── Registration ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -80,6 +93,7 @@ async function verifyRegistration(
|
||||||
challengeId: string,
|
challengeId: string,
|
||||||
response: any,
|
response: any,
|
||||||
credentialName: any,
|
credentialName: any,
|
||||||
|
requestOrigin?: string,
|
||||||
) {
|
) {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -92,7 +106,7 @@ async function verifyRegistration(
|
||||||
const verification = await verifyRegistrationResponse({
|
const verification = await verifyRegistrationResponse({
|
||||||
response,
|
response,
|
||||||
expectedChallenge: row.challenge,
|
expectedChallenge: row.challenge,
|
||||||
expectedOrigin: getOrigin(),
|
expectedOrigin: getOrigin(requestOrigin),
|
||||||
expectedRPID: getRpId(),
|
expectedRPID: getRpId(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -162,7 +176,13 @@ async function createAuthenticationChallenge(db: Db, userId: number) {
|
||||||
return { options, challengeId };
|
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
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')",
|
"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({
|
const verification = await verifyAuthenticationResponse({
|
||||||
response,
|
response,
|
||||||
expectedChallenge: row.challenge,
|
expectedChallenge: row.challenge,
|
||||||
expectedOrigin: getOrigin(),
|
expectedOrigin: getOrigin(requestOrigin),
|
||||||
expectedRPID: getRpId(),
|
expectedRPID: getRpId(),
|
||||||
credential: {
|
credential: {
|
||||||
id: cred.credential_id,
|
id: cred.credential_id,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,17 @@ function validateEnv(): void {
|
||||||
'the DB) to a long random string.',
|
'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 };
|
module.exports = { validateEnv };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue