diff --git a/apps/web/src/app/auth/signin/page.tsx b/apps/web/src/app/auth/signin/page.tsx index 6b40fb1c1a..dbedb812d4 100644 --- a/apps/web/src/app/auth/signin/page.tsx +++ b/apps/web/src/app/auth/signin/page.tsx @@ -13,8 +13,11 @@ import { GoogleOneTap, MagicLinkForm, PasskeyLoginButton, + useConditionalPasskeyUI, ExternalAuthWaiting, } from "@/components/auth"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { useAuthCSRF } from "@/hooks/useAuthCSRF"; import { useOAuthSignIn } from "@/hooks/useOAuthSignIn"; import { isOnPrem } from "@/lib/deployment-mode"; @@ -89,6 +92,19 @@ function SignInForm() { } }, [searchParams]); + // Conditional UI: passkey autofill on the email input (cloud only) + const { isAvailable: conditionalUIAvailable, startConditionalUI } = useConditionalPasskeyUI( + onPrem ? '' : (csrfToken ?? ''), + { + refreshToken, + onSuccess: (redirectUrl) => { window.location.href = redirectUrl; }, + } + ); + + useEffect(() => { + if (csrfToken && !onPrem) startConditionalUI(); + }, [csrfToken, startConditionalUI, onPrem]); + // On-prem: passkey + magic link sign-in (no OAuth) if (onPrem) { return ( @@ -147,6 +163,28 @@ function SignInForm() {

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