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
|
||||
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
{
|
||||
name: 'probe',
|
||||
testMatch: /(api\.probe|a11y\.authed)\.spec\.js/,
|
||||
testMatch: /(api\.probe|a11y\.authed|webauthn\.probe)\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
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');
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue