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; + } }; // ---------------------------------------------------------------------------