From 5d29ede0a4498ec0a32532923e2f54052fd78051 Mon Sep 17 00:00:00 2001
From: 2witstudios <2witstudios@gmail.com>
Date: Sat, 11 Apr 2026 16:18:07 -0500
Subject: [PATCH 1/9] fix(auth): stabilize useConditionalPasskeyUI callback
reference
Destructure options fields into useCallback dependency array to prevent
infinite re-render loop caused by object identity changing every render.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../components/auth/PasskeyLoginButton.tsx | 52 +++++++++-----
.../__tests__/useConditionalPasskeyUI.test.ts | 69 +++++++++++++++++++
2 files changed, 104 insertions(+), 17 deletions(-)
create mode 100644 apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts
diff --git a/apps/web/src/components/auth/PasskeyLoginButton.tsx b/apps/web/src/components/auth/PasskeyLoginButton.tsx
index b21f5bf70a..29850e68f3 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, useEffect, useRef } from 'react';
import { startAuthentication } from '@simplewebauthn/browser';
import { Button } from '@/components/ui/button';
import { Fingerprint, Loader2 } from 'lucide-react';
@@ -162,26 +162,35 @@ export function PasskeyLoginButton({
/**
* Hook for conditional UI support (passkey autofill).
- * Call this on page load to start conditional UI in the background.
+ * Call startConditionalUI() after render so the input with
+ * autocomplete="email webauthn" is already in the DOM (per spec).
*/
export function useConditionalPasskeyUI(
csrfToken: string,
- onSuccess?: (redirectUrl: string) => void
+ options?: {
+ refreshToken?: () => Promise;
+ onSuccess?: (redirectUrl: string) => void;
+ }
) {
const [isAvailable, setIsAvailable] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(false);
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => { mountedRef.current = 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);
+ if (mountedRef.current) setIsAvailable(available);
};
checkAvailability();
@@ -201,17 +210,24 @@ export function useConditionalPasskeyUI(
body: JSON.stringify({ csrfToken }),
});
- if (!optionsRes.ok) return;
+ if (!optionsRes.ok || !mountedRef.current) return;
- const { options } = await optionsRes.json();
+ const { options: authOptions } = await optionsRes.json();
- setIsAuthenticating(true);
+ if (mountedRef.current) setIsAuthenticating(true);
const authResponse = await startAuthentication({
- optionsJSON: options,
+ optionsJSON: authOptions,
useBrowserAutofill: true,
});
+ if (!mountedRef.current) return;
+
+ // Refresh CSRF token before verify — user may have idled on the page
+ const freshToken = options?.refreshToken
+ ? (await options.refreshToken() ?? csrfToken)
+ : csrfToken;
+
const verifyRes = await fetch('/api/auth/passkey/authenticate', {
method: 'POST',
headers: {
@@ -219,12 +235,14 @@ export function useConditionalPasskeyUI(
},
body: JSON.stringify({
response: authResponse,
- expectedChallenge: options.challenge,
- csrfToken,
+ expectedChallenge: authOptions.challenge,
+ csrfToken: freshToken,
...platformFields,
}),
});
+ if (!mountedRef.current) return;
+
if (!verifyRes.ok) {
const error = await verifyRes.json();
toast.error(error.error || 'Authentication failed');
@@ -240,21 +258,21 @@ export function useConditionalPasskeyUI(
if (await handleDesktopAuthResponse(verifyData)) return;
- if (onSuccess) {
- onSuccess(verifyData.redirectUrl);
+ if (options?.onSuccess) {
+ options.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)
+ // Conditional UI cancelled or aborted — expected when user clicks
+ // the explicit passkey button or navigates away
if (err instanceof Error && err.name !== 'AbortError') {
console.debug('Conditional UI authentication failed:', err.message);
}
} finally {
- setIsAuthenticating(false);
+ if (mountedRef.current) setIsAuthenticating(false);
}
- }, [isAvailable, csrfToken, onSuccess]);
+ }, [isAvailable, csrfToken, options?.refreshToken, options?.onSuccess]);
return {
isAvailable,
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..dc550591c3
--- /dev/null
+++ b/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useConditionalPasskeyUI } from '../PasskeyLoginButton';
+
+// Mock all imports used by the hook
+vi.mock('@simplewebauthn/browser', () => ({
+ startAuthentication: vi.fn(),
+}));
+vi.mock('@/components/ui/button', () => ({ Button: 'button' }));
+vi.mock('@/lib/utils', () => ({ cn: (...args: string[]) => args.join(' ') }));
+vi.mock('@/lib/utils/persist-csrf-token', () => ({ persistCsrfToken: vi.fn() }));
+vi.mock('@/hooks/useWebAuthnSupport', () => ({ useWebAuthnSupport: () => true }));
+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() } }));
+vi.mock('lucide-react', () => ({ Fingerprint: 'span', Loader2: 'span' }));
+
+// Stub conditional mediation as unavailable so the hook doesn't fire real fetches
+beforeEach(() => {
+ vi.stubGlobal('PublicKeyCredential', {
+ isConditionalMediationAvailable: () => Promise.resolve(false),
+ });
+});
+
+describe('useConditionalPasskeyUI', () => {
+ it('should return a stable startConditionalUI reference when options values are unchanged', () => {
+ 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;
+
+ // Re-render with same csrfToken — options object is a new literal but values are identical
+ rerender({ token: 'csrf-1' });
+
+ const secondRef = result.current.startConditionalUI;
+
+ expect(secondRef).toBe(firstRef);
+ });
+
+ it('should update startConditionalUI when csrfToken changes', () => {
+ 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' });
+
+ const secondRef = result.current.startConditionalUI;
+
+ expect(secondRef).not.toBe(firstRef);
+ });
+});
From 468fcd760f01d11be9fe869f0ea0aef1b2798e86 Mon Sep 17 00:00:00 2001
From: 2witstudios <2witstudios@gmail.com>
Date: Sat, 11 Apr 2026 17:10:23 -0500
Subject: [PATCH 2/9] feat(auth): native passkey login via conditional UI and
WebAuthn hints
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace QR-first passkey flow with native platform authenticator experience.
Adds WebAuthn Level 3 hints: ['client-device'] to prefer Touch ID / Windows
Hello over hybrid/QR transport, and activates conditional UI (autofill) on
the signin page so passkeys appear in the browser dropdown without a button
click — per FIDO Alliance UX guidelines.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
apps/web/src/app/auth/signin/page.tsx | 33 ++++++++++++++++++-
.../web/src/components/auth/MagicLinkForm.tsx | 2 +-
.../auth/__tests__/passkey-service.test.ts | 8 +++--
packages/lib/src/auth/passkey-service.ts | 10 +++++-
4 files changed, 48 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/app/auth/signin/page.tsx b/apps/web/src/app/auth/signin/page.tsx
index 6b40fb1c1a..af914dbf50 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,16 @@ function SignInForm() {
}
}, [searchParams]);
+ // Conditional UI: passkey autofill on the email input
+ const { startConditionalUI } = useConditionalPasskeyUI(csrfToken ?? '', {
+ refreshToken,
+ onSuccess: (redirectUrl) => { window.location.href = redirectUrl; },
+ });
+
+ useEffect(() => {
+ if (csrfToken) startConditionalUI();
+ }, [csrfToken, startConditionalUI]);
+
// On-prem: passkey + magic link sign-in (no OAuth)
if (onPrem) {
return (
@@ -147,6 +160,24 @@ function SignInForm() {
+ {/* Email input — anchors conditional UI (passkey autofill) */}
+
+
+
+
+
+
+
{/* OAuth buttons */}
{isWaitingForExternalAuth ? (
@@ -162,7 +193,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/packages/lib/src/auth/__tests__/passkey-service.test.ts b/packages/lib/src/auth/__tests__/passkey-service.test.ts
index d107ebdcc3..cefa131c94 100644
--- a/packages/lib/src/auth/__tests__/passkey-service.test.ts
+++ b/packages/lib/src/auth/__tests__/passkey-service.test.ts
@@ -331,7 +331,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 +340,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 +356,9 @@ 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']);
+ }
});
});
diff --git a/packages/lib/src/auth/passkey-service.ts b/packages/lib/src/auth/passkey-service.ts
index 644fcc7a45..fa62214930 100644
--- a/packages/lib/src/auth/passkey-service.ts
+++ b/packages/lib/src/auth/passkey-service.ts
@@ -371,13 +371,21 @@ 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);
From 6723c0bcde6a7ff45678e339e4e56c50aec68a84 Mon Sep 17 00:00:00 2001
From: 2witstudios <2witstudios@gmail.com>
Date: Sat, 11 Apr 2026 17:46:20 -0500
Subject: [PATCH 3/9] fix(auth): stabilize conditional passkey UI with
ref-based callbacks
The useConditionalPasskeyUI hook recreated startConditionalUI every
render because callers passed inline onSuccess/refreshToken arrows.
Using refs for option callbacks decouples identity from the useCallback
dep array, so startConditionalUI stays stable across re-renders.
Also: extract hook to own module for testability, guard conditional UI
behind isAvailable check and on-prem flag, fix GenerateAuthOptionsResult
type to include hints field, remove redundant mountedRef initialization.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
apps/web/src/app/auth/signin/page.tsx | 55 ++++---
.../components/auth/PasskeyLoginButton.tsx | 125 +---------------
.../__tests__/useConditionalPasskeyUI.test.ts | 55 +++++--
.../auth/useConditionalPasskeyUI.ts | 139 ++++++++++++++++++
packages/lib/src/auth/passkey-service.ts | 7 +-
5 files changed, 220 insertions(+), 161 deletions(-)
create mode 100644 apps/web/src/components/auth/useConditionalPasskeyUI.ts
diff --git a/apps/web/src/app/auth/signin/page.tsx b/apps/web/src/app/auth/signin/page.tsx
index af914dbf50..dbedb812d4 100644
--- a/apps/web/src/app/auth/signin/page.tsx
+++ b/apps/web/src/app/auth/signin/page.tsx
@@ -92,15 +92,18 @@ function SignInForm() {
}
}, [searchParams]);
- // Conditional UI: passkey autofill on the email input
- const { startConditionalUI } = useConditionalPasskeyUI(csrfToken ?? '', {
- refreshToken,
- onSuccess: (redirectUrl) => { window.location.href = redirectUrl; },
- });
+ // 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) startConditionalUI();
- }, [csrfToken, startConditionalUI]);
+ if (csrfToken && !onPrem) startConditionalUI();
+ }, [csrfToken, startConditionalUI, onPrem]);
// On-prem: passkey + magic link sign-in (no OAuth)
if (onPrem) {
@@ -160,23 +163,27 @@ function SignInForm() {
- {/* Email input — anchors conditional UI (passkey autofill) */}
-
-
-
-
-
-
+ {/* 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 ? (
diff --git a/apps/web/src/components/auth/PasskeyLoginButton.tsx b/apps/web/src/components/auth/PasskeyLoginButton.tsx
index 29850e68f3..8ce9a6b605 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, useRef } 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,123 +160,6 @@ export function PasskeyLoginButton({
);
}
-/**
- * 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?: {
- refreshToken?: () => Promise;
- onSuccess?: (redirectUrl: string) => void;
- }
-) {
- const [isAvailable, setIsAvailable] = useState(false);
- const [isAuthenticating, setIsAuthenticating] = useState(false);
- const mountedRef = useRef(true);
-
- useEffect(() => {
- mountedRef.current = true;
- return () => { mountedRef.current = false; };
- }, []);
-
- 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;
-
- 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 || !mountedRef.current) return;
-
- const { options: authOptions } = await optionsRes.json();
-
- if (mountedRef.current) setIsAuthenticating(true);
-
- const authResponse = await startAuthentication({
- optionsJSON: authOptions,
- useBrowserAutofill: true,
- });
-
- if (!mountedRef.current) return;
-
- // Refresh CSRF token before verify — user may have idled on the page
- const freshToken = options?.refreshToken
- ? (await options.refreshToken() ?? csrfToken)
- : csrfToken;
-
- const verifyRes = await fetch('/api/auth/passkey/authenticate', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- response: authResponse,
- expectedChallenge: authOptions.challenge,
- csrfToken: freshToken,
- ...platformFields,
- }),
- });
-
- if (!mountedRef.current) return;
-
- 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 (options?.onSuccess) {
- options.onSuccess(verifyData.redirectUrl);
- } else {
- window.location.href = verifyData.redirectUrl;
- }
- } catch (err) {
- // Conditional UI cancelled or aborted — expected when user clicks
- // the explicit passkey button or navigates away
- if (err instanceof Error && err.name !== 'AbortError') {
- console.debug('Conditional UI authentication failed:', err.message);
- }
- } finally {
- if (mountedRef.current) setIsAuthenticating(false);
- }
- }, [isAvailable, csrfToken, options?.refreshToken, options?.onSuccess]);
-
- return {
- isAvailable,
- isAuthenticating,
- startConditionalUI,
- };
-}
+// Re-export hook from its own module (separated for testability)
+export { useConditionalPasskeyUI } from './useConditionalPasskeyUI';
+export type { ConditionalPasskeyOptions } from './useConditionalPasskeyUI';
diff --git a/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts b/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts
index dc550591c3..7a01db946a 100644
--- a/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts
+++ b/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts
@@ -1,15 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
-import { useConditionalPasskeyUI } from '../PasskeyLoginButton';
+import { useConditionalPasskeyUI } from '../useConditionalPasskeyUI';
-// Mock all imports used by the hook
vi.mock('@simplewebauthn/browser', () => ({
startAuthentication: vi.fn(),
}));
-vi.mock('@/components/ui/button', () => ({ Button: 'button' }));
-vi.mock('@/lib/utils', () => ({ cn: (...args: string[]) => args.join(' ') }));
vi.mock('@/lib/utils/persist-csrf-token', () => ({ persistCsrfToken: vi.fn() }));
-vi.mock('@/hooks/useWebAuthnSupport', () => ({ useWebAuthnSupport: () => true }));
vi.mock('@/stores/useAuthStore', () => ({
useAuthStore: { getState: () => ({ setAuthFailedPermanently: vi.fn() }) },
}));
@@ -18,9 +14,7 @@ vi.mock('@/lib/desktop-auth', () => ({
handleDesktopAuthResponse: vi.fn().mockResolvedValue(false),
}));
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
-vi.mock('lucide-react', () => ({ Fingerprint: 'span', Loader2: 'span' }));
-// Stub conditional mediation as unavailable so the hook doesn't fire real fetches
beforeEach(() => {
vi.stubGlobal('PublicKeyCredential', {
isConditionalMediationAvailable: () => Promise.resolve(false),
@@ -28,7 +22,7 @@ beforeEach(() => {
});
describe('useConditionalPasskeyUI', () => {
- it('should return a stable startConditionalUI reference when options values are unchanged', () => {
+ it('given stable csrfToken and stable option refs, should return stable startConditionalUI', () => {
const refreshToken = vi.fn();
const onSuccess = vi.fn();
@@ -40,15 +34,12 @@ describe('useConditionalPasskeyUI', () => {
const firstRef = result.current.startConditionalUI;
- // Re-render with same csrfToken — options object is a new literal but values are identical
rerender({ token: 'csrf-1' });
- const secondRef = result.current.startConditionalUI;
-
- expect(secondRef).toBe(firstRef);
+ expect(result.current.startConditionalUI).toBe(firstRef);
});
- it('should update startConditionalUI when csrfToken changes', () => {
+ it('given csrfToken changes, should return new startConditionalUI', () => {
const refreshToken = vi.fn();
const onSuccess = vi.fn();
@@ -62,8 +53,42 @@ describe('useConditionalPasskeyUI', () => {
rerender({ token: 'csrf-2' });
- const secondRef = result.current.startConditionalUI;
+ 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(secondRef).not.toBe(firstRef);
+ expect(result.current.startConditionalUI).toBe(firstRef);
});
});
diff --git a/apps/web/src/components/auth/useConditionalPasskeyUI.ts b/apps/web/src/components/auth/useConditionalPasskeyUI.ts
new file mode 100644
index 0000000000..30a7e13b9e
--- /dev/null
+++ b/apps/web/src/components/auth/useConditionalPasskeyUI.ts
@@ -0,0 +1,139 @@
+'use client';
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import { startAuthentication } 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';
+
+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; };
+ }, []);
+
+ 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;
+
+ 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 || !mountedRef.current) return;
+
+ const { options: authOptions } = await optionsRes.json();
+
+ if (mountedRef.current) setIsAuthenticating(true);
+
+ const authResponse = await startAuthentication({
+ optionsJSON: authOptions,
+ useBrowserAutofill: true,
+ });
+
+ if (!mountedRef.current) return;
+
+ // Refresh CSRF token before verify — user may have idled on the page
+ const freshToken = refreshTokenRef.current
+ ? (await refreshTokenRef.current() ?? csrfToken)
+ : csrfToken;
+
+ const verifyRes = await fetch('/api/auth/passkey/authenticate', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ response: authResponse,
+ expectedChallenge: authOptions.challenge,
+ csrfToken: freshToken,
+ ...platformFields,
+ }),
+ });
+
+ if (!mountedRef.current) return;
+
+ 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 (onSuccessRef.current) {
+ onSuccessRef.current(verifyData.redirectUrl);
+ } else {
+ window.location.href = verifyData.redirectUrl;
+ }
+ } catch (err) {
+ // Conditional UI cancelled or aborted — expected when user clicks
+ // the explicit passkey button or navigates away
+ if (err instanceof Error && err.name !== 'AbortError') {
+ console.debug('Conditional UI authentication failed:', err.message);
+ }
+ } finally {
+ if (mountedRef.current) setIsAuthenticating(false);
+ }
+ }, [isAvailable, csrfToken]);
+
+ return {
+ isAvailable,
+ isAuthenticating,
+ startConditionalUI,
+ };
+}
diff --git a/packages/lib/src/auth/passkey-service.ts b/packages/lib/src/auth/passkey-service.ts
index fa62214930..850fe23d57 100644
--- a/packages/lib/src/auth/passkey-service.ts
+++ b/packages/lib/src/auth/passkey-service.ts
@@ -110,8 +110,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 =
From c4276a737611a97532333544954ee909bf2724d8 Mon Sep 17 00:00:00 2001
From: 2witstudios <2witstudios@gmail.com>
Date: Sat, 11 Apr 2026 23:49:16 -0500
Subject: [PATCH 4/9] refactor(auth): clean up barrel export for extracted hook
Point index.ts directly at useConditionalPasskeyUI.ts and remove the
now-unnecessary re-export from PasskeyLoginButton.tsx.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
apps/web/src/components/auth/PasskeyLoginButton.tsx | 3 ---
apps/web/src/components/auth/index.ts | 3 ++-
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/apps/web/src/components/auth/PasskeyLoginButton.tsx b/apps/web/src/components/auth/PasskeyLoginButton.tsx
index 8ce9a6b605..224ed5a16e 100644
--- a/apps/web/src/components/auth/PasskeyLoginButton.tsx
+++ b/apps/web/src/components/auth/PasskeyLoginButton.tsx
@@ -160,6 +160,3 @@ export function PasskeyLoginButton({
);
}
-// Re-export hook from its own module (separated for testability)
-export { useConditionalPasskeyUI } from './useConditionalPasskeyUI';
-export type { ConditionalPasskeyOptions } from './useConditionalPasskeyUI';
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';
From 5fd818122cccc2d86a76ef01cf6e640f65a3297c Mon Sep 17 00:00:00 2001
From: 2witstudios <2witstudios@gmail.com>
Date: Sun, 12 Apr 2026 00:06:00 -0500
Subject: [PATCH 5/9] fix(auth): abort WebAuthn ceremony on unmount and handle
CEREMONY_ABORTED
Import WebAuthnAbortService and WebAuthnError from @simplewebauthn/browser.
Call cancelCeremony() in the cleanup effect so the conditional UI promise
doesn't leak across client-side navigation. Catch ERROR_CEREMONY_ABORTED
separately from generic AbortError to avoid spurious debug logging.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../__tests__/useConditionalPasskeyUI.test.ts | 2 ++
.../auth/useConditionalPasskeyUI.ts | 19 +++++++++++++++----
2 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts b/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts
index 7a01db946a..e240eb3109 100644
--- a/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts
+++ b/apps/web/src/components/auth/__tests__/useConditionalPasskeyUI.test.ts
@@ -4,6 +4,8 @@ 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', () => ({
diff --git a/apps/web/src/components/auth/useConditionalPasskeyUI.ts b/apps/web/src/components/auth/useConditionalPasskeyUI.ts
index 30a7e13b9e..86ee0065e4 100644
--- a/apps/web/src/components/auth/useConditionalPasskeyUI.ts
+++ b/apps/web/src/components/auth/useConditionalPasskeyUI.ts
@@ -1,7 +1,11 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
-import { startAuthentication } from '@simplewebauthn/browser';
+import {
+ startAuthentication,
+ WebAuthnAbortService,
+ WebAuthnError,
+} from '@simplewebauthn/browser';
import { toast } from 'sonner';
import { persistCsrfToken } from '@/lib/utils/persist-csrf-token';
import { useAuthStore } from '@/stores/useAuthStore';
@@ -35,7 +39,10 @@ export function useConditionalPasskeyUI(
onSuccessRef.current = options?.onSuccess;
useEffect(() => {
- return () => { mountedRef.current = false; };
+ return () => {
+ mountedRef.current = false;
+ WebAuthnAbortService.cancelCeremony();
+ };
}, []);
useEffect(() => {
@@ -121,8 +128,12 @@ export function useConditionalPasskeyUI(
window.location.href = verifyData.redirectUrl;
}
} catch (err) {
- // Conditional UI cancelled or aborted — expected when user clicks
- // the explicit passkey button or navigates away
+ // SimpleWebAuthn throws WebAuthnError with ERROR_CEREMONY_ABORTED when
+ // cancelCeremony() is called (unmount, new ceremony). Also ignore DOM
+ // AbortError for the same reason.
+ if (err instanceof WebAuthnError && err.code === 'ERROR_CEREMONY_ABORTED') {
+ return;
+ }
if (err instanceof Error && err.name !== 'AbortError') {
console.debug('Conditional UI authentication failed:', err.message);
}
From 5e72137dc4467e2b0f2587c09f8851c56b72c08f Mon Sep 17 00:00:00 2001
From: 2witstudios <2witstudios@gmail.com>
Date: Sun, 12 Apr 2026 22:35:34 -0500
Subject: [PATCH 6/9] fix(auth): refresh conditional passkey challenge on timer
+ scope cleanup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses two review concerns from PR #894 (Codex P1 + P2):
P2 — Refresh conditional UI challenge before long-idle verify
The hook fetched authentication options once and waited indefinitely
in conditional mediation; the server challenge expires after 5 min so
long-idle autofill failed with CHALLENGE_EXPIRED. The ceremony is now
driven by a pure state machine that proactively re-fetches options at
4 minutes (server TTL - 1 min buffer, derived from a shared constant
so client and server can't drift). If verify still returns
CHALLENGE_EXPIRED as a safety net the same retry path fires.
The ceremony logic is extracted into conditionalPasskeyCeremony.ts as
pure predicates + classifiers + asyncPipe-composed steps + a reducer
driven loop. The hook becomes a thin wiring layer and its public
surface is unchanged (existing callback-ref stability tests still
pass unmodified).
P1 — Stop clobbering concurrent sessions' in-flight challenges
generateAuthenticationOptions unconditionally deleted all unused
webauthn_auth rows for the cleanup user. For the user-keyed flow
that is fine (single in-flight ceremony per user), but for the
conditional-UI flow the cleanup user is the shared system user, so
one visitor's /options call was invalidating every other concurrent
visitor's challenge. The P2 refresh timer increases the /options
call rate which would have made this race more frequent, so the
cleanup is now split: user-keyed flows still wipe all unused rows,
the system-user flow only evicts EXPIRED rows.
New shared client-safe constant PASSKEY_CHALLENGE_EXPIRY_MINUTES is
exported from @pagespace/lib so the browser-side refresh derivation
has a single source of truth with PASSKEY_CONFIG.challengeExpiryMinutes.
Tests:
- 33 new pure unit tests for conditionalPasskeyCeremony (predicates,
classifiers, deriveRefreshIntervalMs, nextState reducer, driveCeremony
loop, handleCeremonyResult, integrated runCeremony pipe with fake-
timer refresh-abort path)
- 2 new tests asserting the cleanup-scope contract (conditional UI
adds lt(expiresAt, now); email-scoped flow does not)
- Existing useConditionalPasskeyUI callback-ref stability tests pass
unchanged
- Full lib test suite: 4028/4028 passing
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../conditionalPasskeyCeremony.test.ts | 367 ++++++++++++++++++
.../auth/conditionalPasskeyCeremony.ts | 290 ++++++++++++++
.../auth/useConditionalPasskeyUI.ts | 122 ++----
.../auth/__tests__/passkey-service.test.ts | 45 ++-
packages/lib/src/auth/index.ts | 1 +
.../lib/src/auth/passkey-client-constants.ts | 9 +
packages/lib/src/auth/passkey-service.ts | 51 ++-
packages/lib/src/client-safe.ts | 3 +
packages/lib/src/index.ts | 3 +
9 files changed, 795 insertions(+), 96 deletions(-)
create mode 100644 apps/web/src/components/auth/__tests__/conditionalPasskeyCeremony.test.ts
create mode 100644 apps/web/src/components/auth/conditionalPasskeyCeremony.ts
create mode 100644 packages/lib/src/auth/passkey-client-constants.ts
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..1029d573a0
--- /dev/null
+++ b/apps/web/src/components/auth/__tests__/conditionalPasskeyCeremony.test.ts
@@ -0,0 +1,367 @@
+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' });
+ });
+});
diff --git a/apps/web/src/components/auth/conditionalPasskeyCeremony.ts b/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
new file mode 100644
index 0000000000..941605e6f7
--- /dev/null
+++ b/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
@@ -0,0 +1,290 @@
+import {
+ WebAuthnAbortService,
+ WebAuthnError,
+ startAuthentication as webauthnStartAuthentication,
+ type AuthenticationResponseJSON,
+} from '@simplewebauthn/browser';
+import { PASSKEY_CHALLENGE_EXPIRY_MINUTES } from '@pagespace/lib';
+
+// ---------------------------------------------------------------------------
+// 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' };
+
+ let refreshTimer: ReturnType | undefined = 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 {
+ if (refreshTimer) clearTimeout(refreshTimer);
+ refreshTimer = undefined;
+ }
+};
+
+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,
+ );
+
+ const out = await pipe(ctx);
+ return isTerminal(out) ? out : { status: 'abort', reason: 'ceremony-error' };
+};
+
+// ---------------------------------------------------------------------------
+// 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/useConditionalPasskeyUI.ts b/apps/web/src/components/auth/useConditionalPasskeyUI.ts
index 86ee0065e4..08361d8287 100644
--- a/apps/web/src/components/auth/useConditionalPasskeyUI.ts
+++ b/apps/web/src/components/auth/useConditionalPasskeyUI.ts
@@ -1,15 +1,17 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
-import {
- startAuthentication,
- WebAuthnAbortService,
- WebAuthnError,
-} from '@simplewebauthn/browser';
+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;
@@ -63,83 +65,41 @@ export function useConditionalPasskeyUI(
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 || !mountedRef.current) return;
-
- const { options: authOptions } = await optionsRes.json();
-
- if (mountedRef.current) setIsAuthenticating(true);
-
- const authResponse = await startAuthentication({
- optionsJSON: authOptions,
- useBrowserAutofill: true,
- });
-
- if (!mountedRef.current) return;
-
- // Refresh CSRF token before verify — user may have idled on the page
- const freshToken = refreshTokenRef.current
- ? (await refreshTokenRef.current() ?? csrfToken)
- : csrfToken;
-
- const verifyRes = await fetch('/api/auth/passkey/authenticate', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- response: authResponse,
- expectedChallenge: authOptions.challenge,
- csrfToken: freshToken,
- ...platformFields,
+ 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) return;
-
- 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 (onSuccessRef.current) {
- onSuccessRef.current(verifyData.redirectUrl);
- } else {
- window.location.href = verifyData.redirectUrl;
- }
- } catch (err) {
- // SimpleWebAuthn throws WebAuthnError with ERROR_CEREMONY_ABORTED when
- // cancelCeremony() is called (unmount, new ceremony). Also ignore DOM
- // AbortError for the same reason.
- if (err instanceof WebAuthnError && err.code === 'ERROR_CEREMONY_ABORTED') {
- return;
- }
- if (err instanceof Error && err.name !== 'AbortError') {
- console.debug('Conditional UI authentication failed:', err.message);
- }
- } finally {
- if (mountedRef.current) setIsAuthenticating(false);
- }
+ });
+
+ 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 {
diff --git a/packages/lib/src/auth/__tests__/passkey-service.test.ts b/packages/lib/src/auth/__tests__/passkey-service.test.ts
index cefa131c94..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,
};
});
@@ -360,6 +361,48 @@ describe('passkey-service', () => {
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();
+ });
});
describe('verifyAuthentication', () => {
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 850fe23d57..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,
@@ -396,18 +397,40 @@ export async function generateAuthenticationOptions(
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.
From 5d3feb427215282f9bde7d2609947ba912877e14 Mon Sep 17 00:00:00 2001
From: 2witstudios <2witstudios@gmail.com>
Date: Sun, 12 Apr 2026 22:39:39 -0500
Subject: [PATCH 7/9] chore(auth): drop dead assignment in refresh-timer
finally
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Minor cleanup — the refreshTimer variable was being set to undefined in
the finally block after clearTimeout, but the variable is about to go
out of scope, so the assignment is noise.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
apps/web/src/components/auth/conditionalPasskeyCeremony.ts | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/components/auth/conditionalPasskeyCeremony.ts b/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
index 941605e6f7..9035f65a2c 100644
--- a/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
+++ b/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
@@ -163,7 +163,7 @@ const fetchAuthenticationOptions: Step = async (ctx) => {
const startAssertionWithRefreshTimer: Step = async (ctx) => {
if (!ctx.authOptions) return { status: 'abort', reason: 'ceremony-error' };
- let refreshTimer: ReturnType | undefined = setTimeout(() => {
+ const refreshTimer = setTimeout(() => {
if (ctx.isMounted()) ctx.cancelCeremony();
}, ctx.refreshIntervalMs);
@@ -177,8 +177,7 @@ const startAssertionWithRefreshTimer: Step = async (ctx) => {
} catch (err) {
return classifyCeremonyError({ err, mounted: ctx.isMounted() });
} finally {
- if (refreshTimer) clearTimeout(refreshTimer);
- refreshTimer = undefined;
+ clearTimeout(refreshTimer);
}
};
From 7a37c2cdebcacc20841b45a44afea534e4c6e834 Mon Sep 17 00:00:00 2001
From: 2witstudios <2witstudios@gmail.com>
Date: Sun, 12 Apr 2026 22:48:40 -0500
Subject: [PATCH 8/9] fix(auth): import passkey constant from
@pagespace/lib/client-safe
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The new conditionalPasskeyCeremony client module imported
PASSKEY_CHALLENGE_EXPIRY_MINUTES from '@pagespace/lib', which Next.js's
webpack resolves to the main server index (not the browser-conditional
client-safe entry), dragging the whole server tree into the client
bundle — google-auth-library then tried to pull node:fs/node:https/
node:buffer/child_process and the web#build step failed with
UnhandledSchemeError.
Switched the import to '@pagespace/lib/client-safe' explicitly, matching
the project convention used in all other client-side files. Constant is
re-exported from both entries so semantics are identical; only the
bundle path changes.
Verified locally with a full `pnpm --filter web build` — build now
succeeds. Targeted ceremony tests still pass (33/33).
Co-Authored-By: Claude Opus 4.6 (1M context)
---
apps/web/src/components/auth/conditionalPasskeyCeremony.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/components/auth/conditionalPasskeyCeremony.ts b/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
index 9035f65a2c..71f952e64b 100644
--- a/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
+++ b/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
@@ -4,7 +4,7 @@ import {
startAuthentication as webauthnStartAuthentication,
type AuthenticationResponseJSON,
} from '@simplewebauthn/browser';
-import { PASSKEY_CHALLENGE_EXPIRY_MINUTES } from '@pagespace/lib';
+import { PASSKEY_CHALLENGE_EXPIRY_MINUTES } from '@pagespace/lib/client-safe';
// ---------------------------------------------------------------------------
// Types
From 47146ffbad0c404a94c746a1093a9dbd9dda8d36 Mon Sep 17 00:00:00 2001
From: 2witstudios <2witstudios@gmail.com>
Date: Sun, 12 Apr 2026 22:51:30 -0500
Subject: [PATCH 9/9] fix(auth): wrap ceremony runner in top-level try/catch
safety net
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Only startAssertionWithRefreshTimer had its own catch block — a throw
from fetchAuthenticationOptions (e.g. network error, JSON parse error,
getDevicePlatformFields throwing) would propagate up to the hook's
useEffect as an unhandled rejection. The original hook's catch swallowed
non-abort errors with a console.debug, which this refactor had lost.
runCeremony now wraps the pipe in a try/catch that classifies the error
(distinguishing abort vs. ceremony-error) and preserves the original
console.debug behavior for non-abort failures, so anything thrown by a
step returns a terminal CeremonyResult instead of propagating.
Adds a test that a throwing getDevicePlatformFields is classified as
abort/ceremony-error and debug-logged once.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../conditionalPasskeyCeremony.test.ts | 19 +++++++++++++++++++
.../auth/conditionalPasskeyCeremony.ts | 18 ++++++++++++++++--
2 files changed, 35 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/auth/__tests__/conditionalPasskeyCeremony.test.ts b/apps/web/src/components/auth/__tests__/conditionalPasskeyCeremony.test.ts
index 1029d573a0..1639fe5784 100644
--- a/apps/web/src/components/auth/__tests__/conditionalPasskeyCeremony.test.ts
+++ b/apps/web/src/components/auth/__tests__/conditionalPasskeyCeremony.test.ts
@@ -364,4 +364,23 @@ describe('runCeremony (integrated pipe, injected deps)', () => {
});
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/conditionalPasskeyCeremony.ts b/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
index 71f952e64b..8b69c8829b 100644
--- a/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
+++ b/apps/web/src/components/auth/conditionalPasskeyCeremony.ts
@@ -235,8 +235,22 @@ export const runCeremony = async (deps: CeremonyDeps): Promise =
verifyAssertion,
);
- const out = await pipe(ctx);
- return isTerminal(out) ? out : { status: 'abort', reason: 'ceremony-error' };
+ // 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;
+ }
};
// ---------------------------------------------------------------------------