diff --git a/apps/web/src/app/auth/signin/page.tsx b/apps/web/src/app/auth/signin/page.tsx
index 6b40fb1c1a..dbedb812d4 100644
--- a/apps/web/src/app/auth/signin/page.tsx
+++ b/apps/web/src/app/auth/signin/page.tsx
@@ -13,8 +13,11 @@ import {
GoogleOneTap,
MagicLinkForm,
PasskeyLoginButton,
+ useConditionalPasskeyUI,
ExternalAuthWaiting,
} from "@/components/auth";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
import { useAuthCSRF } from "@/hooks/useAuthCSRF";
import { useOAuthSignIn } from "@/hooks/useOAuthSignIn";
import { isOnPrem } from "@/lib/deployment-mode";
@@ -89,6 +92,19 @@ function SignInForm() {
}
}, [searchParams]);
+ // Conditional UI: passkey autofill on the email input (cloud only)
+ const { isAvailable: conditionalUIAvailable, startConditionalUI } = useConditionalPasskeyUI(
+ onPrem ? '' : (csrfToken ?? ''),
+ {
+ refreshToken,
+ onSuccess: (redirectUrl) => { window.location.href = redirectUrl; },
+ }
+ );
+
+ useEffect(() => {
+ if (csrfToken && !onPrem) startConditionalUI();
+ }, [csrfToken, startConditionalUI, onPrem]);
+
// On-prem: passkey + magic link sign-in (no OAuth)
if (onPrem) {
return (
@@ -147,6 +163,28 @@ function SignInForm() {
+ {/* Email input — anchors conditional UI (passkey autofill).
+ Only rendered when the browser supports conditional mediation,
+ otherwise it's a non-functional dead-end. */}
+ {conditionalUIAvailable && (
+
+
+
+
+
+
+ )}
+
{/* OAuth buttons */}
{isWaitingForExternalAuth ? (
@@ -162,7 +200,7 @@ function SignInForm() {
- {/* Passkey login */}
+ {/* Passkey login — fallback for browsers without conditional mediation */}
{csrfToken && (
setEmail(e.target.value)}
className="pl-10"
disabled={formState === 'sending'}
- autoComplete="email"
+ autoComplete="email webauthn"
autoFocus
/>
diff --git a/apps/web/src/components/auth/PasskeyLoginButton.tsx b/apps/web/src/components/auth/PasskeyLoginButton.tsx
index b21f5bf70a..224ed5a16e 100644
--- a/apps/web/src/components/auth/PasskeyLoginButton.tsx
+++ b/apps/web/src/components/auth/PasskeyLoginButton.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useCallback, useEffect } from 'react';
+import { useState, useCallback } from 'react';
import { startAuthentication } from '@simplewebauthn/browser';
import { Button } from '@/components/ui/button';
import { Fingerprint, Loader2 } from 'lucide-react';
@@ -160,105 +160,3 @@ export function PasskeyLoginButton({
);
}
-/**
- * Hook for conditional UI support (passkey autofill).
- * Call this on page load to start conditional UI in the background.
- */
-export function useConditionalPasskeyUI(
- csrfToken: string,
- onSuccess?: (redirectUrl: string) => void
-) {
- const [isAvailable, setIsAvailable] = useState(false);
- const [isAuthenticating, setIsAuthenticating] = useState(false);
-
- useEffect(() => {
- const checkAvailability = async () => {
- if (typeof window === 'undefined') return;
-
- // Check if conditional mediation is available
- const available = await (
- window.PublicKeyCredential?.isConditionalMediationAvailable?.() ??
- Promise.resolve(false)
- );
-
- setIsAvailable(available);
- };
-
- checkAvailability();
- }, []);
-
- const startConditionalUI = useCallback(async () => {
- if (!isAvailable || !csrfToken) return;
-
- try {
- const platformFields = await getDevicePlatformFields();
-
- const optionsRes = await fetch('/api/auth/passkey/authenticate/options', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ csrfToken }),
- });
-
- if (!optionsRes.ok) return;
-
- const { options } = await optionsRes.json();
-
- setIsAuthenticating(true);
-
- const authResponse = await startAuthentication({
- optionsJSON: options,
- useBrowserAutofill: true,
- });
-
- const verifyRes = await fetch('/api/auth/passkey/authenticate', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- response: authResponse,
- expectedChallenge: options.challenge,
- csrfToken,
- ...platformFields,
- }),
- });
-
- if (!verifyRes.ok) {
- const error = await verifyRes.json();
- toast.error(error.error || 'Authentication failed');
- return;
- }
-
- const verifyData = await verifyRes.json();
-
- persistCsrfToken();
- useAuthStore.getState().setAuthFailedPermanently(false);
-
- toast.success('Signed in successfully');
-
- if (await handleDesktopAuthResponse(verifyData)) return;
-
- if (onSuccess) {
- onSuccess(verifyData.redirectUrl);
- } else {
- window.location.href = verifyData.redirectUrl;
- }
- } catch (err) {
- // Conditional UI was cancelled or failed - this is expected behavior
- // Don't show error toast for AbortError (user cancelled)
- if (err instanceof Error && err.name !== 'AbortError') {
- console.debug('Conditional UI authentication failed:', err.message);
- }
- } finally {
- setIsAuthenticating(false);
- }
- }, [isAvailable, csrfToken, onSuccess]);
-
- return {
- isAvailable,
- isAuthenticating,
- startConditionalUI,
- };
-}
diff --git a/apps/web/src/components/auth/__tests__/conditionalPasskeyCeremony.test.ts b/apps/web/src/components/auth/__tests__/conditionalPasskeyCeremony.test.ts
new file mode 100644
index 0000000000..1639fe5784
--- /dev/null
+++ b/apps/web/src/components/auth/__tests__/conditionalPasskeyCeremony.test.ts
@@ -0,0 +1,386 @@
+import { describe, it, expect, vi } from 'vitest';
+import { WebAuthnError } from '@simplewebauthn/browser';
+import {
+ classifyCeremonyError,
+ classifyVerifyResponse,
+ deriveRefreshIntervalMs,
+ driveCeremony,
+ handleCeremonyResult,
+ isCeremonyAborted,
+ isChallengeExpired,
+ isRefreshAbort,
+ isUnmountAbort,
+ nextState,
+ runCeremony,
+ type CeremonyResult,
+} from '../conditionalPasskeyCeremony';
+
+const makeAbortError = () =>
+ new WebAuthnError({
+ code: 'ERROR_CEREMONY_ABORTED',
+ message: 'aborted',
+ cause: new Error('aborted'),
+ });
+
+describe('deriveRefreshIntervalMs', () => {
+ it('given default args, should return (ttl - 1 minute) in ms', () => {
+ expect(deriveRefreshIntervalMs()).toBe(4 * 60 * 1000);
+ });
+
+ it('given custom ttl and buffer, should subtract buffer from ttl', () => {
+ expect(deriveRefreshIntervalMs({ ttlMinutes: 10, bufferMinutes: 2 })).toBe(8 * 60 * 1000);
+ });
+
+ it('given buffer ≥ ttl, should clamp to a 1-minute minimum', () => {
+ expect(deriveRefreshIntervalMs({ ttlMinutes: 1, bufferMinutes: 5 })).toBe(60 * 1000);
+ });
+});
+
+describe('isChallengeExpired', () => {
+ it('given code CHALLENGE_EXPIRED, should return true', () => {
+ expect(isChallengeExpired({ code: 'CHALLENGE_EXPIRED' })).toBe(true);
+ });
+
+ it('given a different code, should return false', () => {
+ expect(isChallengeExpired({ code: 'CHALLENGE_NOT_FOUND' })).toBe(false);
+ });
+
+ it('given no code, should return false', () => {
+ expect(isChallengeExpired()).toBe(false);
+ });
+});
+
+describe('isCeremonyAborted / isUnmountAbort / isRefreshAbort', () => {
+ it('given a WebAuthnError with ERROR_CEREMONY_ABORTED, should identify it as aborted', () => {
+ expect(isCeremonyAborted({ err: makeAbortError() })).toBe(true);
+ });
+
+ it('given any other error, should not identify it as aborted', () => {
+ expect(isCeremonyAborted({ err: new Error('boom') })).toBe(false);
+ });
+
+ it('given ceremony aborted while unmounted, should classify as unmount abort', () => {
+ const err = makeAbortError();
+ expect(isUnmountAbort({ err, mounted: false })).toBe(true);
+ expect(isRefreshAbort({ err, mounted: false })).toBe(false);
+ });
+
+ it('given ceremony aborted while still mounted, should classify as refresh abort', () => {
+ const err = makeAbortError();
+ expect(isRefreshAbort({ err, mounted: true })).toBe(true);
+ expect(isUnmountAbort({ err, mounted: true })).toBe(false);
+ });
+});
+
+describe('classifyVerifyResponse', () => {
+ it('given ok=true with data, should return success', () => {
+ const data = { redirectUrl: '/dash' };
+ expect(classifyVerifyResponse({ ok: true, data })).toEqual({ status: 'success', data });
+ });
+
+ it('given ok=false with code CHALLENGE_EXPIRED, should signal retry with challenge-expired', () => {
+ expect(classifyVerifyResponse({ ok: false, code: 'CHALLENGE_EXPIRED' })).toEqual({
+ status: 'retry',
+ reason: 'challenge-expired',
+ });
+ });
+
+ it('given ok=false with a different code, should return failure with the message', () => {
+ expect(
+ classifyVerifyResponse({ ok: false, code: 'USER_NOT_FOUND', message: 'nope' }),
+ ).toEqual({ status: 'failure', message: 'nope' });
+ });
+
+ it('given ok=false with no message, should fall back to a default failure message', () => {
+ expect(classifyVerifyResponse({ ok: false })).toEqual({
+ status: 'failure',
+ message: 'Authentication failed',
+ });
+ });
+});
+
+describe('classifyCeremonyError', () => {
+ it('given an abort error while mounted, should signal retry with refresh-timer', () => {
+ expect(classifyCeremonyError({ err: makeAbortError(), mounted: true })).toEqual({
+ status: 'retry',
+ reason: 'refresh-timer',
+ });
+ });
+
+ it('given an abort error while unmounted, should return abort unmount', () => {
+ expect(classifyCeremonyError({ err: makeAbortError(), mounted: false })).toEqual({
+ status: 'abort',
+ reason: 'unmount',
+ });
+ });
+
+ it('given a non-abort error, should return abort ceremony-error', () => {
+ expect(classifyCeremonyError({ err: new Error('other'), mounted: true })).toEqual({
+ status: 'abort',
+ reason: 'ceremony-error',
+ });
+ });
+});
+
+describe('nextState', () => {
+ it('given idle state, should transition to running regardless of result', () => {
+ expect(nextState({ state: 'idle' })).toBe('running');
+ });
+
+ it('given running state with a retry result, should stay running', () => {
+ expect(
+ nextState({ state: 'running', result: { status: 'retry', reason: 'refresh-timer' } }),
+ ).toBe('running');
+ });
+
+ it('given running state with a success result, should transition to done', () => {
+ expect(
+ nextState({ state: 'running', result: { status: 'success', data: {} } }),
+ ).toBe('done');
+ });
+
+ it('given running state with a failure result, should transition to done', () => {
+ expect(
+ nextState({ state: 'running', result: { status: 'failure', message: 'x' } }),
+ ).toBe('done');
+ });
+
+ it('given running state with an abort result, should transition to done', () => {
+ expect(
+ nextState({ state: 'running', result: { status: 'abort', reason: 'unmount' } }),
+ ).toBe('done');
+ });
+});
+
+describe('driveCeremony', () => {
+ it('given two retries then a success, should invoke runOnce three times and return success', async () => {
+ const results: CeremonyResult[] = [
+ { status: 'retry', reason: 'refresh-timer' },
+ { status: 'retry', reason: 'challenge-expired' },
+ { status: 'success', data: { redirectUrl: '/dash' } },
+ ];
+ const runOnce = vi.fn(async () => results.shift()!);
+ const out = await driveCeremony({ runOnce, isMounted: () => true });
+ expect(runOnce).toHaveBeenCalledTimes(3);
+ expect(out).toEqual({ status: 'success', data: { redirectUrl: '/dash' } });
+ });
+
+ it('given isMounted flips to false after a retry, should stop looping', async () => {
+ let mounted = true;
+ const runOnce = vi.fn(async () => {
+ mounted = false;
+ return { status: 'retry', reason: 'refresh-timer' } as CeremonyResult;
+ });
+ const out = await driveCeremony({ runOnce, isMounted: () => mounted });
+ expect(runOnce).toHaveBeenCalledTimes(1);
+ expect(out).toEqual({ status: 'retry', reason: 'refresh-timer' });
+ });
+
+ it('given a terminal failure on first call, should stop after one invocation', async () => {
+ const runOnce = vi.fn(async () => ({ status: 'failure', message: 'nope' }) as CeremonyResult);
+ const out = await driveCeremony({ runOnce, isMounted: () => true });
+ expect(runOnce).toHaveBeenCalledTimes(1);
+ expect(out).toEqual({ status: 'failure', message: 'nope' });
+ });
+});
+
+describe('handleCeremonyResult', () => {
+ it('given a success result on web, should call onAuthenticated then onRedirect', async () => {
+ const calls: string[] = [];
+ const onAuthenticated = vi.fn(() => { calls.push('auth'); });
+ const onRedirect = vi.fn((url: string) => { calls.push(`redirect:${url}`); });
+ const onFailure = vi.fn();
+ await handleCeremonyResult({
+ result: { status: 'success', data: { redirectUrl: '/dashboard' } },
+ onAuthenticated,
+ onRedirect,
+ onFailure,
+ handleDesktopAuthResponse: async () => false,
+ });
+ expect(calls).toEqual(['auth', 'redirect:/dashboard']);
+ expect(onFailure).not.toHaveBeenCalled();
+ });
+
+ it('given a success result on desktop, should run onAuthenticated but skip onRedirect', async () => {
+ const onAuthenticated = vi.fn();
+ const onRedirect = vi.fn();
+ const desktopHandler = vi.fn(async () => true);
+ await handleCeremonyResult({
+ result: { status: 'success', data: { redirectUrl: '/dashboard' } },
+ onAuthenticated,
+ onRedirect,
+ onFailure: vi.fn(),
+ handleDesktopAuthResponse: desktopHandler,
+ });
+ expect(onAuthenticated).toHaveBeenCalled();
+ expect(desktopHandler).toHaveBeenCalled();
+ expect(onRedirect).not.toHaveBeenCalled();
+ });
+
+ it('given a failure result, should call onFailure with message and skip onAuthenticated', async () => {
+ const onAuthenticated = vi.fn();
+ const onRedirect = vi.fn();
+ const onFailure = vi.fn();
+ await handleCeremonyResult({
+ result: { status: 'failure', message: 'bad' },
+ onAuthenticated,
+ onRedirect,
+ onFailure,
+ handleDesktopAuthResponse: async () => false,
+ });
+ expect(onFailure).toHaveBeenCalledWith('bad');
+ expect(onAuthenticated).not.toHaveBeenCalled();
+ expect(onRedirect).not.toHaveBeenCalled();
+ });
+
+ it('given an abort or retry result, should be silent', async () => {
+ const onAuthenticated = vi.fn();
+ const onRedirect = vi.fn();
+ const onFailure = vi.fn();
+ const desktopHandler = vi.fn(async () => false);
+ await handleCeremonyResult({
+ result: { status: 'abort', reason: 'unmount' },
+ onAuthenticated,
+ onRedirect,
+ onFailure,
+ handleDesktopAuthResponse: desktopHandler,
+ });
+ await handleCeremonyResult({
+ result: { status: 'retry', reason: 'refresh-timer' },
+ onAuthenticated,
+ onRedirect,
+ onFailure,
+ handleDesktopAuthResponse: desktopHandler,
+ });
+ expect(onAuthenticated).not.toHaveBeenCalled();
+ expect(onRedirect).not.toHaveBeenCalled();
+ expect(onFailure).not.toHaveBeenCalled();
+ expect(desktopHandler).not.toHaveBeenCalled();
+ });
+});
+
+describe('runCeremony (integrated pipe, injected deps)', () => {
+ const makeFetchFn = (
+ optionsBody: Record,
+ verifyStatus: number,
+ verifyBody: Record,
+ ) => {
+ const fn = vi.fn(async (url: string) => {
+ if (typeof url === 'string' && url.includes('/options')) {
+ return new Response(JSON.stringify(optionsBody), { status: 200 });
+ }
+ return new Response(JSON.stringify(verifyBody), { status: verifyStatus });
+ });
+ return fn as unknown as typeof fetch;
+ };
+
+ it('given options+verify succeed, should return a success result', async () => {
+ const fetchFn = makeFetchFn(
+ { options: { challenge: 'abc' } },
+ 200,
+ { redirectUrl: '/dashboard' },
+ );
+ const startAuthentication = vi.fn(async () => ({ id: 'cred' }) as never);
+ const cancelCeremony = vi.fn();
+ const result = await runCeremony({
+ csrfToken: 'csrf',
+ refreshIntervalMs: 60_000,
+ getDevicePlatformFields: async () => ({}),
+ isMounted: () => true,
+ fetchFn,
+ startAuthentication,
+ cancelCeremony,
+ });
+ expect(result).toEqual({ status: 'success', data: { redirectUrl: '/dashboard' } });
+ expect(cancelCeremony).not.toHaveBeenCalled();
+ });
+
+ it('given refresh timer fires and aborts the ceremony, should return retry refresh-timer', async () => {
+ vi.useFakeTimers();
+ try {
+ const fetchFn = makeFetchFn({ options: { challenge: 'abc' } }, 200, {});
+ const startAuthentication = vi.fn(
+ () =>
+ new Promise((_, reject) => {
+ // Rejection is triggered by cancelCeremony below.
+ cancelRef.reject = reject;
+ }),
+ );
+ const cancelRef: { reject?: (err: unknown) => void } = {};
+ const cancelCeremony = vi.fn(() => {
+ cancelRef.reject?.(makeAbortError());
+ });
+
+ const promise = runCeremony({
+ csrfToken: 'csrf',
+ refreshIntervalMs: 1000,
+ getDevicePlatformFields: async () => ({}),
+ isMounted: () => true,
+ fetchFn,
+ startAuthentication: startAuthentication as never,
+ cancelCeremony,
+ });
+
+ await vi.runOnlyPendingTimersAsync();
+ await vi.advanceTimersByTimeAsync(1000);
+
+ const result = await promise;
+ expect(cancelCeremony).toHaveBeenCalled();
+ expect(result).toEqual({ status: 'retry', reason: 'refresh-timer' });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('given verify returns CHALLENGE_EXPIRED, should return retry challenge-expired', async () => {
+ const fetchFn = makeFetchFn(
+ { options: { challenge: 'abc' } },
+ 400,
+ { code: 'CHALLENGE_EXPIRED', error: 'expired' },
+ );
+ const startAuthentication = vi.fn(async () => ({ id: 'cred' }) as never);
+ const result = await runCeremony({
+ csrfToken: 'csrf',
+ refreshIntervalMs: 60_000,
+ getDevicePlatformFields: async () => ({}),
+ isMounted: () => true,
+ fetchFn,
+ startAuthentication,
+ cancelCeremony: vi.fn(),
+ });
+ expect(result).toEqual({ status: 'retry', reason: 'challenge-expired' });
+ });
+
+ it('given options fetch fails, should return abort options-failed', async () => {
+ const fetchFn = vi.fn(async () => new Response('x', { status: 500 })) as unknown as typeof fetch;
+ const result = await runCeremony({
+ csrfToken: 'csrf',
+ refreshIntervalMs: 60_000,
+ getDevicePlatformFields: async () => ({}),
+ isMounted: () => true,
+ fetchFn,
+ startAuthentication: vi.fn() as never,
+ cancelCeremony: vi.fn(),
+ });
+ expect(result).toEqual({ status: 'abort', reason: 'options-failed' });
+ });
+
+ it('given a step throws, should swallow and return abort ceremony-error', async () => {
+ const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
+ const fetchFn = vi.fn(async () => new Response('{}', { status: 200 })) as unknown as typeof fetch;
+ const result = await runCeremony({
+ csrfToken: 'csrf',
+ refreshIntervalMs: 60_000,
+ getDevicePlatformFields: async () => {
+ throw new Error('platform fields failed');
+ },
+ isMounted: () => true,
+ fetchFn,
+ startAuthentication: vi.fn() as never,
+ cancelCeremony: vi.fn(),
+ });
+ expect(result).toEqual({ status: 'abort', reason: 'ceremony-error' });
+ expect(debugSpy).toHaveBeenCalled();
+ debugSpy.mockRestore();
+ });
+});
diff --git a/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts b/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts
new file mode 100644
index 0000000000..e240eb3109
--- /dev/null
+++ b/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts
@@ -0,0 +1,96 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useConditionalPasskeyUI } from '../useConditionalPasskeyUI';
+
+vi.mock('@simplewebauthn/browser', () => ({
+ startAuthentication: vi.fn(),
+ WebAuthnAbortService: { cancelCeremony: vi.fn() },
+ WebAuthnError: class WebAuthnError extends Error { code: string; constructor(opts: { code: string; message: string }) { super(opts.message); this.code = opts.code; } },
+}));
+vi.mock('@/lib/utils/persist-csrf-token', () => ({ persistCsrfToken: vi.fn() }));
+vi.mock('@/stores/useAuthStore', () => ({
+ useAuthStore: { getState: () => ({ setAuthFailedPermanently: vi.fn() }) },
+}));
+vi.mock('@/lib/desktop-auth', () => ({
+ getDevicePlatformFields: vi.fn().mockResolvedValue({}),
+ handleDesktopAuthResponse: vi.fn().mockResolvedValue(false),
+}));
+vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
+
+beforeEach(() => {
+ vi.stubGlobal('PublicKeyCredential', {
+ isConditionalMediationAvailable: () => Promise.resolve(false),
+ });
+});
+
+describe('useConditionalPasskeyUI', () => {
+ it('given stable csrfToken and stable option refs, should return stable startConditionalUI', () => {
+ const refreshToken = vi.fn();
+ const onSuccess = vi.fn();
+
+ const { result, rerender } = renderHook(
+ ({ token }) =>
+ useConditionalPasskeyUI(token, { refreshToken, onSuccess }),
+ { initialProps: { token: 'csrf-1' } }
+ );
+
+ const firstRef = result.current.startConditionalUI;
+
+ rerender({ token: 'csrf-1' });
+
+ expect(result.current.startConditionalUI).toBe(firstRef);
+ });
+
+ it('given csrfToken changes, should return new startConditionalUI', () => {
+ const refreshToken = vi.fn();
+ const onSuccess = vi.fn();
+
+ const { result, rerender } = renderHook(
+ ({ token }) =>
+ useConditionalPasskeyUI(token, { refreshToken, onSuccess }),
+ { initialProps: { token: 'csrf-1' } }
+ );
+
+ const firstRef = result.current.startConditionalUI;
+
+ rerender({ token: 'csrf-2' });
+
+ expect(result.current.startConditionalUI).not.toBe(firstRef);
+ });
+
+ it('given inline onSuccess arrow (new ref each render), should still return stable startConditionalUI', () => {
+ // This matches real usage in signin/page.tsx where onSuccess is an inline arrow
+ const { result, rerender } = renderHook(
+ ({ token }) =>
+ useConditionalPasskeyUI(token, {
+ refreshToken: async () => 'refreshed',
+ onSuccess: (url) => { window.location.href = url; },
+ }),
+ { initialProps: { token: 'csrf-1' } }
+ );
+
+ const firstRef = result.current.startConditionalUI;
+
+ // Re-render — options object and its callbacks are new refs
+ rerender({ token: 'csrf-1' });
+
+ expect(result.current.startConditionalUI).toBe(firstRef);
+ });
+
+ it('given inline refreshToken (new ref each render), should still return stable startConditionalUI', () => {
+ const { result, rerender } = renderHook(
+ ({ token }) =>
+ useConditionalPasskeyUI(token, {
+ refreshToken: async () => token,
+ onSuccess: vi.fn(),
+ }),
+ { initialProps: { token: 'csrf-1' } }
+ );
+
+ const firstRef = result.current.startConditionalUI;
+
+ rerender({ token: 'csrf-1' });
+
+ expect(result.current.startConditionalUI).toBe(firstRef);
+ });
+});
diff --git a/apps/web/src/components/auth/conditionalPasskeyCeremony.ts b/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
new file mode 100644
index 0000000000..8b69c8829b
--- /dev/null
+++ b/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
@@ -0,0 +1,303 @@
+import {
+ WebAuthnAbortService,
+ WebAuthnError,
+ startAuthentication as webauthnStartAuthentication,
+ type AuthenticationResponseJSON,
+} from '@simplewebauthn/browser';
+import { PASSKEY_CHALLENGE_EXPIRY_MINUTES } from '@pagespace/lib/client-safe';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export type VerifyResponseData = {
+ redirectUrl?: string;
+ redirectTo?: string;
+ sessionToken?: string;
+ csrfToken?: string;
+ deviceToken?: string;
+};
+
+export type CeremonyResult =
+ | { status: 'success'; data: VerifyResponseData }
+ | { status: 'retry'; reason: 'refresh-timer' | 'challenge-expired' }
+ | { status: 'abort'; reason: 'unmount' | 'options-failed' | 'ceremony-error' }
+ | { status: 'failure'; message: string };
+
+export type CeremonyState = 'idle' | 'running' | 'done';
+
+type PlatformFields = Record;
+
+type AuthOptions = { challenge: string } & Record;
+
+export type CeremonyDeps = {
+ csrfToken: string;
+ refreshIntervalMs: number;
+ refreshToken?: () => Promise;
+ getDevicePlatformFields: () => Promise;
+ isMounted: () => boolean;
+ fetchFn?: typeof fetch;
+ startAuthentication?: typeof webauthnStartAuthentication;
+ cancelCeremony?: () => void;
+};
+
+type CeremonyContext = Required> &
+ Pick & {
+ authOptions?: AuthOptions;
+ platformFields?: PlatformFields;
+ authResponse?: AuthenticationResponseJSON;
+ verifyCsrfToken?: string;
+ };
+
+type StepOut = CeremonyContext | CeremonyResult;
+type Step = (ctx: CeremonyContext) => Promise;
+
+// ---------------------------------------------------------------------------
+// Pure predicates and classifiers
+// ---------------------------------------------------------------------------
+
+const SAFETY_BUFFER_MINUTES = 1;
+
+export const deriveRefreshIntervalMs = ({
+ ttlMinutes = PASSKEY_CHALLENGE_EXPIRY_MINUTES,
+ bufferMinutes = SAFETY_BUFFER_MINUTES,
+}: { ttlMinutes?: number; bufferMinutes?: number } = {}): number =>
+ Math.max(ttlMinutes - bufferMinutes, 1) * 60 * 1000;
+
+export const isCeremonyAborted = ({ err }: { err?: unknown } = {}): boolean =>
+ err instanceof WebAuthnError && err.code === 'ERROR_CEREMONY_ABORTED';
+
+export const isChallengeExpired = ({ code }: { code?: string } = {}): boolean =>
+ code === 'CHALLENGE_EXPIRED';
+
+export const isUnmountAbort = ({
+ err,
+ mounted,
+}: { err?: unknown; mounted?: boolean } = {}): boolean =>
+ isCeremonyAborted({ err }) && mounted === false;
+
+export const isRefreshAbort = ({
+ err,
+ mounted,
+}: { err?: unknown; mounted?: boolean } = {}): boolean =>
+ isCeremonyAborted({ err }) && mounted === true;
+
+export const classifyVerifyResponse = ({
+ ok,
+ code,
+ data,
+ message,
+}: {
+ ok: boolean;
+ code?: string;
+ data?: VerifyResponseData;
+ message?: string;
+}): CeremonyResult => {
+ if (ok && data) return { status: 'success', data };
+ if (isChallengeExpired({ code })) return { status: 'retry', reason: 'challenge-expired' };
+ return { status: 'failure', message: message ?? 'Authentication failed' };
+};
+
+export const classifyCeremonyError = ({
+ err,
+ mounted,
+}: {
+ err: unknown;
+ mounted: boolean;
+}): CeremonyResult => {
+ if (isUnmountAbort({ err, mounted })) return { status: 'abort', reason: 'unmount' };
+ if (isRefreshAbort({ err, mounted })) return { status: 'retry', reason: 'refresh-timer' };
+ return { status: 'abort', reason: 'ceremony-error' };
+};
+
+export const nextState = ({
+ state = 'idle',
+ result,
+}: {
+ state?: CeremonyState;
+ result?: CeremonyResult;
+} = {}): CeremonyState => {
+ if (state === 'idle') return 'running';
+ if (state === 'running' && result?.status === 'retry') return 'running';
+ return 'done';
+};
+
+// ---------------------------------------------------------------------------
+// AsyncPipe with terminal short-circuit
+// ---------------------------------------------------------------------------
+
+const isTerminal = (out: StepOut): out is CeremonyResult =>
+ out != null && typeof out === 'object' && 'status' in out;
+
+const asyncPipe =
+ (...fns: Step[]) =>
+ async (ctx: CeremonyContext): Promise => {
+ let acc: StepOut = ctx;
+ for (const fn of fns) {
+ if (isTerminal(acc)) return acc;
+ acc = await fn(acc);
+ }
+ return acc;
+ };
+
+// ---------------------------------------------------------------------------
+// Pipe steps (impure, but composed from pure classifiers)
+// ---------------------------------------------------------------------------
+
+const fetchAuthenticationOptions: Step = async (ctx) => {
+ const platformFields = await ctx.getDevicePlatformFields();
+
+ const res = await ctx.fetchFn('/api/auth/passkey/authenticate/options', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ csrfToken: ctx.csrfToken }),
+ });
+ if (!res.ok || !ctx.isMounted()) {
+ return { status: 'abort', reason: 'options-failed' };
+ }
+
+ const { options } = (await res.json()) as { options: AuthOptions };
+ return { ...ctx, platformFields, authOptions: options };
+};
+
+const startAssertionWithRefreshTimer: Step = async (ctx) => {
+ if (!ctx.authOptions) return { status: 'abort', reason: 'ceremony-error' };
+
+ const refreshTimer = setTimeout(() => {
+ if (ctx.isMounted()) ctx.cancelCeremony();
+ }, ctx.refreshIntervalMs);
+
+ try {
+ const authResponse = await ctx.startAuthentication({
+ optionsJSON: ctx.authOptions,
+ useBrowserAutofill: true,
+ });
+ if (!ctx.isMounted()) return { status: 'abort', reason: 'unmount' };
+ return { ...ctx, authResponse };
+ } catch (err) {
+ return classifyCeremonyError({ err, mounted: ctx.isMounted() });
+ } finally {
+ clearTimeout(refreshTimer);
+ }
+};
+
+const refreshCsrfToken: Step = async (ctx) => {
+ const fresh = ctx.refreshToken ? await ctx.refreshToken() : null;
+ return { ...ctx, verifyCsrfToken: fresh ?? ctx.csrfToken };
+};
+
+const verifyAssertion: Step = async (ctx) => {
+ const res = await ctx.fetchFn('/api/auth/passkey/authenticate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ response: ctx.authResponse,
+ expectedChallenge: ctx.authOptions?.challenge,
+ csrfToken: ctx.verifyCsrfToken,
+ ...(ctx.platformFields ?? {}),
+ }),
+ });
+ if (!ctx.isMounted()) return { status: 'abort', reason: 'unmount' };
+
+ if (!res.ok) {
+ const error = (await res.json().catch(() => ({}))) as { code?: string; error?: string };
+ return classifyVerifyResponse({
+ ok: false,
+ code: error.code,
+ message: error.error,
+ });
+ }
+
+ const data = (await res.json()) as VerifyResponseData;
+ return classifyVerifyResponse({ ok: true, data });
+};
+
+// ---------------------------------------------------------------------------
+// Composed ceremony runner
+// ---------------------------------------------------------------------------
+
+export const runCeremony = async (deps: CeremonyDeps): Promise => {
+ const ctx: CeremonyContext = {
+ csrfToken: deps.csrfToken,
+ refreshIntervalMs: deps.refreshIntervalMs,
+ refreshToken: deps.refreshToken,
+ getDevicePlatformFields: deps.getDevicePlatformFields,
+ isMounted: deps.isMounted,
+ fetchFn: deps.fetchFn ?? fetch,
+ startAuthentication: deps.startAuthentication ?? webauthnStartAuthentication,
+ cancelCeremony: deps.cancelCeremony ?? (() => WebAuthnAbortService.cancelCeremony()),
+ };
+
+ const pipe = asyncPipe(
+ fetchAuthenticationOptions,
+ startAssertionWithRefreshTimer,
+ refreshCsrfToken,
+ verifyAssertion,
+ );
+
+ // Top-level safety net: any throw from a step that does not wrap its own
+ // try/catch (fetch network error in fetchAuthenticationOptions, JSON parse
+ // error, etc.) is classified here instead of propagating to the hook's
+ // useEffect and becoming an unhandled rejection.
+ try {
+ const out = await pipe(ctx);
+ return isTerminal(out) ? out : { status: 'abort', reason: 'ceremony-error' };
+ } catch (err) {
+ const terminal = classifyCeremonyError({ err, mounted: ctx.isMounted() });
+ if (terminal.status === 'abort' && terminal.reason === 'ceremony-error') {
+ if (err instanceof Error && err.name !== 'AbortError') {
+ console.debug('Conditional UI ceremony failed:', err.message);
+ }
+ }
+ return terminal;
+ }
+};
+
+// ---------------------------------------------------------------------------
+// Loop driver
+// ---------------------------------------------------------------------------
+
+export const driveCeremony = async ({
+ runOnce,
+ isMounted,
+}: {
+ runOnce: () => Promise;
+ isMounted: () => boolean;
+}): Promise => {
+ let state: CeremonyState = 'idle';
+ let result: CeremonyResult | undefined;
+ while (isMounted() && nextState({ state, result }) === 'running') {
+ state = 'running';
+ result = await runOnce();
+ }
+ return result;
+};
+
+// ---------------------------------------------------------------------------
+// Result handler (side effects)
+// ---------------------------------------------------------------------------
+
+export const handleCeremonyResult = async ({
+ result,
+ onAuthenticated,
+ onRedirect,
+ onFailure,
+ handleDesktopAuthResponse,
+}: {
+ result?: CeremonyResult;
+ onAuthenticated?: () => void;
+ onRedirect: (redirectUrl: string) => void;
+ onFailure: (message: string) => void;
+ handleDesktopAuthResponse: (data: VerifyResponseData) => Promise;
+}): Promise => {
+ if (!result) return;
+ if (result.status === 'abort' || result.status === 'retry') return;
+ if (result.status === 'failure') {
+ onFailure(result.message);
+ return;
+ }
+ onAuthenticated?.();
+ if (await handleDesktopAuthResponse(result.data)) return;
+ const redirectUrl = result.data.redirectUrl ?? result.data.redirectTo ?? '/dashboard';
+ onRedirect(redirectUrl);
+};
diff --git a/apps/web/src/components/auth/index.ts b/apps/web/src/components/auth/index.ts
index ea9f56b14a..75479b1eee 100644
--- a/apps/web/src/components/auth/index.ts
+++ b/apps/web/src/components/auth/index.ts
@@ -6,5 +6,6 @@ export { MagicLinkForm } from './MagicLinkForm';
export { PasskeySignupButton } from './PasskeySignupButton';
export { useWebAuthnSupport } from '@/hooks/useWebAuthnSupport';
-export { PasskeyLoginButton, useConditionalPasskeyUI } from './PasskeyLoginButton';
+export { PasskeyLoginButton } from './PasskeyLoginButton';
+export { useConditionalPasskeyUI } from './useConditionalPasskeyUI';
export { ExternalAuthWaiting } from './ExternalAuthWaiting';
diff --git a/apps/web/src/components/auth/useConditionalPasskeyUI.ts b/apps/web/src/components/auth/useConditionalPasskeyUI.ts
new file mode 100644
index 0000000000..08361d8287
--- /dev/null
+++ b/apps/web/src/components/auth/useConditionalPasskeyUI.ts
@@ -0,0 +1,110 @@
+'use client';
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import { WebAuthnAbortService } from '@simplewebauthn/browser';
+import { toast } from 'sonner';
+import { persistCsrfToken } from '@/lib/utils/persist-csrf-token';
+import { useAuthStore } from '@/stores/useAuthStore';
+import { getDevicePlatformFields, handleDesktopAuthResponse } from '@/lib/desktop-auth';
+import {
+ deriveRefreshIntervalMs,
+ driveCeremony,
+ handleCeremonyResult,
+ runCeremony,
+} from './conditionalPasskeyCeremony';
+
+export interface ConditionalPasskeyOptions {
+ refreshToken?: () => Promise;
+ onSuccess?: (redirectUrl: string) => void;
+}
+
+/**
+ * Hook for conditional UI support (passkey autofill).
+ * Call startConditionalUI() after render so the input with
+ * autocomplete="email webauthn" is already in the DOM (per spec).
+ */
+export function useConditionalPasskeyUI(
+ csrfToken: string,
+ options?: ConditionalPasskeyOptions
+) {
+ const [isAvailable, setIsAvailable] = useState(false);
+ const [isAuthenticating, setIsAuthenticating] = useState(false);
+ const mountedRef = useRef(true);
+
+ // Refs for callbacks — keeps startConditionalUI stable when callers
+ // pass inline arrows (common in JSX). The callback always reads the
+ // latest ref, so callers don't need to memoize.
+ const refreshTokenRef = useRef(options?.refreshToken);
+ refreshTokenRef.current = options?.refreshToken;
+
+ const onSuccessRef = useRef(options?.onSuccess);
+ onSuccessRef.current = options?.onSuccess;
+
+ useEffect(() => {
+ return () => {
+ mountedRef.current = false;
+ WebAuthnAbortService.cancelCeremony();
+ };
+ }, []);
+
+ useEffect(() => {
+ const checkAvailability = async () => {
+ if (typeof window === 'undefined') return;
+
+ const available = await (
+ window.PublicKeyCredential?.isConditionalMediationAvailable?.() ??
+ Promise.resolve(false)
+ );
+
+ if (mountedRef.current) setIsAvailable(available);
+ };
+
+ checkAvailability();
+ }, []);
+
+ const startConditionalUI = useCallback(async () => {
+ if (!isAvailable || !csrfToken) return;
+
+ if (mountedRef.current) setIsAuthenticating(true);
+
+ const result = await driveCeremony({
+ isMounted: () => mountedRef.current,
+ runOnce: () =>
+ runCeremony({
+ csrfToken,
+ refreshIntervalMs: deriveRefreshIntervalMs(),
+ refreshToken: refreshTokenRef.current,
+ getDevicePlatformFields,
+ isMounted: () => mountedRef.current,
+ }),
+ });
+
+ if (mountedRef.current) setIsAuthenticating(false);
+
+ await handleCeremonyResult({
+ result,
+ handleDesktopAuthResponse,
+ onFailure: (message) => {
+ toast.error(message);
+ },
+ onAuthenticated: () => {
+ persistCsrfToken();
+ useAuthStore.getState().setAuthFailedPermanently(false);
+ toast.success('Signed in successfully');
+ },
+ onRedirect: (redirectUrl) => {
+ if (onSuccessRef.current) {
+ onSuccessRef.current(redirectUrl);
+ } else {
+ window.location.href = redirectUrl;
+ }
+ },
+ });
+ }, [isAvailable, csrfToken]);
+
+ return {
+ isAvailable,
+ isAuthenticating,
+ startConditionalUI,
+ };
+}
diff --git a/packages/lib/src/auth/__tests__/passkey-service.test.ts b/packages/lib/src/auth/__tests__/passkey-service.test.ts
index d107ebdcc3..38781972b9 100644
--- a/packages/lib/src/auth/__tests__/passkey-service.test.ts
+++ b/packages/lib/src/auth/__tests__/passkey-service.test.ts
@@ -14,6 +14,7 @@ vi.mock('@pagespace/db', () => {
const eq = vi.fn((a, b) => ({ op: 'eq', a, b }));
const and = vi.fn((...args: unknown[]) => ({ op: 'and', args }));
const isNull = vi.fn((a) => ({ op: 'isNull', a }));
+ const lt = vi.fn((a, b) => ({ op: 'lt', a, b }));
const sql = vi.fn();
return {
@@ -53,7 +54,7 @@ vi.mock('@pagespace/db', () => {
id: 'vt.id', userId: 'vt.userId', tokenHash: 'vt.tokenHash', tokenPrefix: 'vt.tokenPrefix',
type: 'vt.type', expiresAt: 'vt.expiresAt', usedAt: 'vt.usedAt', metadata: 'vt.metadata',
},
- eq, and, isNull, sql,
+ eq, and, isNull, lt, sql,
};
});
@@ -331,7 +332,7 @@ describe('passkey-service', () => {
if (!result.ok) expect(result.error.code).toBe('VALIDATION_FAILED');
});
- it('should generate options without email (conditional UI)', async () => {
+ it('should generate options without email (conditional UI) with client-device hints', async () => {
mockGenAuthOptions.mockResolvedValueOnce({ challenge: 'auth-challenge' });
mockDb.delete.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
mockDb.insert.mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) });
@@ -340,11 +341,12 @@ describe('passkey-service', () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.options.challenge).toBe('auth-challenge');
+ expect(result.data.options.hints).toEqual(['client-device']);
expect(result.data.challengeId).toBe('test-cuid');
}
});
- it('should generate options with email when user exists', async () => {
+ it('should generate options with email when user exists, including hints', async () => {
mockDb.query.users.findFirst.mockResolvedValueOnce({ id: 'user-1' });
mockDb.query.passkeys.findMany.mockResolvedValueOnce([
{ credentialId: 'cred-1', transports: ['internal'] },
@@ -355,6 +357,51 @@ describe('passkey-service', () => {
const result = await generateAuthenticationOptions({ email: 'test@example.com' });
expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data.options.hints).toEqual(['client-device']);
+ }
+ });
+
+ it('given conditional UI (no email), should only delete EXPIRED challenges for the system user', async () => {
+ mockGenAuthOptions.mockResolvedValueOnce({ challenge: 'auth-challenge' });
+ const whereSpy = vi.fn().mockResolvedValue(undefined);
+ mockDb.delete.mockReturnValue({ where: whereSpy });
+ mockDb.insert.mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) });
+
+ const before = Date.now();
+ const result = await generateAuthenticationOptions({});
+ expect(result.ok).toBe(true);
+
+ // The cleanup where() should have been called with an `and(...)` whose
+ // args include an `lt(expiresAt, )` predicate — proving concurrent
+ // unexpired sessions' challenges are preserved across visitors.
+ expect(whereSpy).toHaveBeenCalledTimes(1);
+ const andClause = whereSpy.mock.calls[0][0] as { op: string; args: Array<{ op: string; b?: Date }> };
+ expect(andClause.op).toBe('and');
+ const ltClause = andClause.args.find((p) => p?.op === 'lt');
+ expect(ltClause).toBeDefined();
+ expect(ltClause?.b).toBeInstanceOf(Date);
+ expect(ltClause!.b!.getTime()).toBeGreaterThanOrEqual(before);
+ });
+
+ it('given email-scoped flow, should delete ALL unused challenges for that user (no lt clause)', async () => {
+ mockDb.query.users.findFirst.mockResolvedValueOnce({ id: 'user-1' });
+ mockDb.query.passkeys.findMany.mockResolvedValueOnce([
+ { credentialId: 'cred-1', transports: ['internal'] },
+ ]);
+ mockGenAuthOptions.mockResolvedValueOnce({ challenge: 'auth-challenge' });
+ const whereSpy = vi.fn().mockResolvedValue(undefined);
+ mockDb.delete.mockReturnValue({ where: whereSpy });
+ mockDb.insert.mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) });
+
+ const result = await generateAuthenticationOptions({ email: 'test@example.com' });
+ expect(result.ok).toBe(true);
+
+ expect(whereSpy).toHaveBeenCalledTimes(1);
+ const andClause = whereSpy.mock.calls[0][0] as { op: string; args: Array<{ op: string }> };
+ expect(andClause.op).toBe('and');
+ const ltClause = andClause.args.find((p) => p?.op === 'lt');
+ expect(ltClause).toBeUndefined();
});
});
diff --git a/packages/lib/src/auth/index.ts b/packages/lib/src/auth/index.ts
index c21c38b3ae..3be038a6c2 100644
--- a/packages/lib/src/auth/index.ts
+++ b/packages/lib/src/auth/index.ts
@@ -21,4 +21,5 @@ export * from './verification-utils';
export * from './exchange-codes';
export * from './magic-link-service';
export { generatePKCE, consumePKCEVerifier } from './pkce';
+export * from './passkey-client-constants';
export * from './passkey-service';
diff --git a/packages/lib/src/auth/passkey-client-constants.ts b/packages/lib/src/auth/passkey-client-constants.ts
new file mode 100644
index 0000000000..c92c303db1
--- /dev/null
+++ b/packages/lib/src/auth/passkey-client-constants.ts
@@ -0,0 +1,9 @@
+/**
+ * Passkey constants safe to import from browser bundles.
+ *
+ * Kept separate from passkey-service.ts (which pulls in db/zod/server-only code)
+ * so the web app can derive refresh intervals from the same source of truth
+ * without dragging server dependencies into the client bundle.
+ */
+
+export const PASSKEY_CHALLENGE_EXPIRY_MINUTES = 5;
diff --git a/packages/lib/src/auth/passkey-service.ts b/packages/lib/src/auth/passkey-service.ts
index 644fcc7a45..7cafbe8468 100644
--- a/packages/lib/src/auth/passkey-service.ts
+++ b/packages/lib/src/auth/passkey-service.ts
@@ -8,7 +8,7 @@
*/
import { z } from 'zod';
-import { db, users, passkeys, verificationTokens, eq, and, isNull, sql } from '@pagespace/db';
+import { db, users, passkeys, verificationTokens, eq, and, isNull, lt, sql } from '@pagespace/db';
import { createId } from '@paralleldrive/cuid2';
import {
generateRegistrationOptions as simpleGenerateRegistrationOptions,
@@ -20,13 +20,14 @@ import {
type AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import { hashToken, generateToken } from './token-utils';
+import { PASSKEY_CHALLENGE_EXPIRY_MINUTES } from './passkey-client-constants';
// Configuration
export const PASSKEY_CONFIG = {
rpName: process.env.WEBAUTHN_RP_NAME || 'PageSpace',
rpId: process.env.WEBAUTHN_RP_ID || 'localhost',
origin: process.env.WEBAUTHN_ORIGIN || 'http://localhost:3000',
- challengeExpiryMinutes: 5,
+ challengeExpiryMinutes: PASSKEY_CHALLENGE_EXPIRY_MINUTES,
maxPasskeysPerUser: 10,
timeout: 60000,
maxNameLength: 255,
@@ -110,8 +111,13 @@ export type VerifyRegistrationResult =
| { ok: true; data: { passkeyId: string } }
| { ok: false; error: PasskeyError };
+export type AuthenticationOptionsWithHints =
+ Awaited> & {
+ hints?: readonly string[];
+ };
+
export type GenerateAuthOptionsResult =
- | { ok: true; data: { options: Awaited>; challengeId: string } }
+ | { ok: true; data: { options: AuthenticationOptionsWithHints; challengeId: string } }
| { ok: false; error: PasskeyError };
export type VerifyAuthResult =
@@ -371,30 +377,60 @@ export async function generateAuthenticationOptions(
}
}
- const options = await simpleGenerateAuthenticationOptions({
+ const rawOptions = await simpleGenerateAuthenticationOptions({
rpID: PASSKEY_CONFIG.rpId,
userVerification: 'required',
timeout: PASSKEY_CONFIG.timeout,
allowCredentials,
});
+ // WebAuthn Level 3: hint browser to prefer platform authenticator over QR/hybrid.
+ // SimpleWebAuthn v13 doesn't accept hints in generateAuthenticationOptions,
+ // so we add it manually. Progressive enhancement — Safari ignores it.
+ const options = {
+ ...rawOptions,
+ hints: ['client-device' as const],
+ };
+
// Store challenge - use a system user ID if no specific user
const challengeId = createId();
const challengeHash = hashToken(options.challenge);
const expiresAt = new Date(Date.now() + PASSKEY_CONFIG.challengeExpiryMinutes * 60 * 1000);
- // Clean up old unused auth challenges for this user (or system user)
- // This prevents unbounded accumulation of stale challenges
- const cleanupUserId = challengeUserId ?? PASSKEY_CONFIG.systemUserId;
- await db
- .delete(verificationTokens)
- .where(
- and(
- eq(verificationTokens.userId, cleanupUserId),
- eq(verificationTokens.type, 'webauthn_auth'),
- isNull(verificationTokens.usedAt)
- )
- );
+ // Clean up old unused auth challenges.
+ //
+ // For a user-keyed flow (challengeUserId set) we can safely delete all
+ // unused challenges for that user because there is only one active flow
+ // per user at a time.
+ //
+ // For the shared system user (conditional UI, no email) we must only
+ // delete EXPIRED challenges. The system user is shared across every
+ // anonymous visitor, so deleting unexpired "unused" rows would clobber
+ // other concurrent sessions' in-flight challenges and cause
+ // CHALLENGE_NOT_FOUND at verify time. Expired rows are always safe to
+ // evict.
+ if (challengeUserId) {
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.userId, challengeUserId),
+ eq(verificationTokens.type, 'webauthn_auth'),
+ isNull(verificationTokens.usedAt)
+ )
+ );
+ } else {
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.userId, PASSKEY_CONFIG.systemUserId),
+ eq(verificationTokens.type, 'webauthn_auth'),
+ isNull(verificationTokens.usedAt),
+ lt(verificationTokens.expiresAt, new Date())
+ )
+ );
+ }
// For authentication challenges without a specific user, we need a special approach
// Store with a placeholder user ID or use a different table
diff --git a/packages/lib/src/client-safe.ts b/packages/lib/src/client-safe.ts
index 2b057cc8e3..336ec927a4 100644
--- a/packages/lib/src/client-safe.ts
+++ b/packages/lib/src/client-safe.ts
@@ -58,5 +58,8 @@ export function parseBytes(size: string): number {
export * from './notifications/types';
export * from './notifications/guards';
+// Passkey constants (numeric; no server deps)
+export * from './auth/passkey-client-constants';
+
// Note: Server-side modules like permissions, auth-utils, logger-config, etc.
// are NOT exported here to prevent Node.js dependencies in browser bundles.
diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts
index 5159db9d0a..5da9f7be99 100644
--- a/packages/lib/src/index.ts
+++ b/packages/lib/src/index.ts
@@ -121,5 +121,8 @@ export * from './file-processing';
// Real-time and broadcasting utilities (server-only)
export * from './auth/broadcast-auth';
+// Passkey client-safe constants (also re-exported from './client-safe')
+export * from './auth/passkey-client-constants';
+
// Note: This index includes server-side dependencies and should NOT be imported
// from client-side components. Use '@pagespace/lib/client-safe' for client-side imports.