diff --git a/app/recovery.tsx b/app/recovery.tsx index 38645b3b..b352664a 100644 --- a/app/recovery.tsx +++ b/app/recovery.tsx @@ -5,6 +5,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Image } from 'expo-image'; import { useRouter } from 'expo-router'; import { zodResolver } from '@hookform/resolvers/zod'; +import * as Sentry from '@sentry/react-native'; import { StamperType, useTurnkey } from '@turnkey/react-native-wallet-kit'; import { z } from 'zod'; @@ -19,6 +20,7 @@ import { path } from '@/constants/path'; import { useDimension } from '@/hooks/useDimension'; import { initRecoveryOtp, verifyRecoveryOtp } from '@/lib/api'; import { getAsset } from '@/lib/assets'; +import { buildRecoveryPasskeyName, isTurnkeySessionError } from '@/lib/utils/passkey'; import { useUserStore } from '@/store/useUserStore'; // Validation schemas @@ -45,10 +47,22 @@ const STEPS = { type Step = (typeof STEPS)[keyof typeof STEPS]; +/** + * The recovery session is minted from a single-use code and cannot be renewed + * from the add-passkey screen, so it is checked before the passkey prompt + * rather than after. The margin covers the prompt itself: creating a passkey + * involves the platform sheet, biometrics and the password manager's own flow, + * and a session that lapses midway leaves an orphaned passkey on the device + * that Turnkey never registers. + */ +const SESSION_MARGIN_MS = 60 * 1000; + +const SESSION_EXPIRED_MESSAGE = 'Your recovery session expired. Request a new code to continue.'; + export default function RecoveryPasskey() { const router = useRouter(); const { isDesktop } = useDimension(); - const { createApiKeyPair, addPasskey, storeSession, httpClient } = useTurnkey(); + const { createApiKeyPair, addPasskey, storeSession, httpClient, session } = useTurnkey(); const setCredentialIdsForIdentity = useUserStore(state => state.setCredentialIdsForIdentity); const [step, setStep] = useState(STEPS.EMAIL_INPUT); @@ -61,6 +75,7 @@ export default function RecoveryPasskey() { credentialBundle: string; userId: string; organizationId: string; + expiresAt?: number; } | null>(null); // Step 1: Send OTP to user's email via backend @@ -147,18 +162,50 @@ export default function RecoveryPasskey() { [httpClient], ); + // When the recovery session is gone, no retry on this screen can succeed — + // the code that minted it was single-use. Send the user back to the OTP step, + // where "Resend code" issues a fresh one, instead of leaving them tapping a + // button that fails identically every time. + const sendBackForNewCode = useCallback((message = SESSION_EXPIRED_MESSAGE) => { + setRecoveryData(null); + setApiError(message); + setStep(STEPS.OTP_VERIFY); + }, []); + + /** + * Whether the session that has to stamp the add-passkey request is still good + * for long enough to finish it. Prefers the SDK's own session (that is the + * key doing the stamping) and falls back to the expiry the backend reported. + */ + const hasUsableSession = useCallback( + (expiresAt?: number) => { + const deadline = session?.expiry ? session.expiry * 1000 : expiresAt; + if (!deadline) return true; // Nothing to go on - let the request decide. + return deadline - Date.now() > SESSION_MARGIN_MS; + }, + [session], + ); + // Step 3: Add new passkey const handleAddPasskey = useCallback(async () => { + if (!recoveryData) { + sendBackForNewCode('Recovery session not found. Request a new code to continue.'); + return; + } + + // Checked before the prompt, not after: a passkey created against a dead + // session is one Turnkey never registers, and it stays on the device. + if (!hasUsableSession(recoveryData.expiresAt)) { + sendBackForNewCode(); + return; + } + setLoading(true); setApiError(''); try { - if (!recoveryData) { - throw new Error('Recovery data not found'); - } - await addPasskey({ - name: `Recovery Passkey - ${new Date().toLocaleDateString()}`, + name: buildRecoveryPasskeyName(), userId: recoveryData.userId, organizationId: recoveryData.organizationId, }); @@ -176,11 +223,42 @@ export default function RecoveryPasskey() { setStep(STEPS.SUCCESS); } catch (err: any) { console.error('Failed to add passkey:', err); + + // `addPasskey` reports every failure downstream of the passkey prompt as + // a bare "Failed to add passkey" - the underlying Turnkey error only + // exists on `cause`. Report it, or this step stays undiagnosable: the + // signup passkey flow is instrumented and this one was not, so none of + // these failures reached Sentry at all. + Sentry.captureException(err, { + tags: { type: 'recovery_passkey_creation_error', turnkey_error_code: err?.code }, + extra: { + email, + turnkeyUserId: recoveryData.userId, + organizationId: recoveryData.organizationId, + cause: err?.cause?.message, + sessionExpiry: session?.expiry, + }, + }); + + if (isTurnkeySessionError(err)) { + sendBackForNewCode(); + return; + } + setApiError(err?.message || 'Failed to create passkey. Please try again.'); } finally { setLoading(false); } - }, [addPasskey, recoveryData, readCredentialIds, setCredentialIdsForIdentity, email]); + }, [ + addPasskey, + recoveryData, + readCredentialIds, + setCredentialIdsForIdentity, + email, + hasUsableSession, + sendBackForNewCode, + session, + ]); // Resend OTP const handleResendOtp = useCallback(async () => { diff --git a/lib/api.ts b/lib/api.ts index ac45f706..601f83e3 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -3534,7 +3534,17 @@ export const verifyRecoveryOtp = async ( otpCode: string, email: string, publicKey: string, -): Promise<{ credentialBundle: string; userId: string; organizationId: string }> => { +): Promise<{ + credentialBundle: string; + userId: string; + organizationId: string; + /** + * When the session minted here stops being able to act, as epoch ms. The + * add-passkey step that follows cannot renew it, so it checks this rather + * than offering a retry that cannot succeed. + */ + expiresAt?: number; +}> => { const response = await fetch( `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/auths/verify-recovery-otp`, { diff --git a/lib/utils/__tests__/passkey-credentials.test.ts b/lib/utils/__tests__/passkey-credentials.test.ts index d96c91bb..31c9f5a6 100644 --- a/lib/utils/__tests__/passkey-credentials.test.ts +++ b/lib/utils/__tests__/passkey-credentials.test.ts @@ -1,5 +1,8 @@ import { + buildRecoveryPasskeyName, isPasskeyPromptError, + isTurnkeySessionError, + isValidTurnkeyPasskeyName, mergeCredentialIds, tryBase64urlToUint8Array, } from '@/lib/utils/passkey'; @@ -102,3 +105,98 @@ describe('tryBase64urlToUint8Array', () => { expect(tryBase64urlToUint8Array('not valid !!')).toBeUndefined(); }); }); + +/** + * Turnkey rejects an authenticator name outside its ASCII pattern before the + * passkey prompt is ever shown, and rejects a name the account already holds. + * The name the recovery flow sends therefore has to be locale-independent and + * different on every attempt. + */ +describe('buildRecoveryPasskeyName', () => { + it('produces a name Turnkey accepts', () => { + expect(isValidTurnkeyPasskeyName(buildRecoveryPasskeyName(new Date(0)))).toBe(true); + }); + + it('stays within the 64 character limit', () => { + expect(buildRecoveryPasskeyName(new Date(0)).length).toBeLessThanOrEqual(64); + }); + + it('is unique per attempt, not per day', () => { + // Day granularity meant a second attempt on the same date re-sent a name + // the account already had, which Turnkey refuses. + const first = buildRecoveryPasskeyName(new Date('2026-09-07T09:51:00.123Z')); + const second = buildRecoveryPasskeyName(new Date('2026-09-07T09:51:00.124Z')); + expect(first).not.toEqual(second); + }); + + it('does not follow the device locale', () => { + // `toLocaleDateString()` on an Arabic, Persian or Bengali device returns + // non-Latin digits and embedded RTL marks, which the pattern rejects. + const name = buildRecoveryPasskeyName(new Date('2026-09-07T09:51:00.000Z')); + expect(name).toBe('Recovery Passkey - 2026-09-07T09:51:00.000Z'); + expect(name).not.toMatch(/[^\x20-\x7E]/); + }); +}); + +describe('isValidTurnkeyPasskeyName', () => { + it('rejects the Arabic-locale date the flow used to send', () => { + // ar-EG `toLocaleDateString()`: Arabic-Indic digits plus U+200F RTL marks. + expect(isValidTurnkeyPasskeyName('Recovery Passkey - ٧‏/٩‏/٢٠٢٦')).toBe(false); + }); + + it('accepts the en-US date the flow used to send', () => { + // Which is why this only ever failed for some users. + expect(isValidTurnkeyPasskeyName('Recovery Passkey - 9/7/2026')).toBe(true); + }); + + it('rejects a name longer than 64 characters', () => { + expect(isValidTurnkeyPasskeyName('a'.repeat(65))).toBe(false); + }); +}); + +/** + * A recovery session is minted from a single-use code and cannot be renewed + * from the add-passkey screen, so a lapsed session has to be told apart from a + * failure that a retry could clear. + */ +describe('isTurnkeySessionError', () => { + it.each([ + ['the SDK session code', { code: 'SESSION_EXPIRED', message: 'Session API key has expired' }], + ['a missing session', { code: 'NO_SESSION_FOUND', message: 'No active session found.' }], + ['an expired api key', { message: 'Unauthenticated desc = expired api key publicKey 02ab' }], + ['an unknown public key', { message: 'could not find public key in organization' }], + ])('recognises %s', (_label, error) => { + expect(isTurnkeySessionError(error)).toBe(true); + }); + + it('finds the session failure the SDK hid behind its own message', () => { + // `addPasskey` wraps everything downstream of the prompt in a bare + // "Failed to add passkey" and leaves the real error on `cause`. + expect( + isTurnkeySessionError({ + code: 'ADD_PASSKEY_ERROR', + message: 'Failed to add passkey', + cause: { message: 'Unauthenticated desc = expired api key publicKey 02ab' }, + }), + ).toBe(true); + }); + + it.each([ + [ + 'a duplicate name', + { code: 'ADD_PASSKEY_ERROR', message: 'authenticator name already exists' }, + ], + ['a cancelled prompt', { code: 'SELECT_PASSKEY_CANCELLED', message: 'cancelled by the user' }], + ['a network failure', { name: 'TypeError', message: 'Network request failed' }], + ])('leaves %s to the retry path', (_label, error) => { + // These can clear on retry, so they must not throw away the session. + expect(isTurnkeySessionError(error)).toBe(false); + }); + + it('handles a missing error and a cyclic cause without hanging', () => { + expect(isTurnkeySessionError(null)).toBe(false); + const cyclic: { message: string; cause?: unknown } = { message: 'boom' }; + cyclic.cause = cyclic; + expect(isTurnkeySessionError(cyclic)).toBe(false); + }); +}); diff --git a/lib/utils/passkey.ts b/lib/utils/passkey.ts index 571750e7..07220a5a 100644 --- a/lib/utils/passkey.ts +++ b/lib/utils/passkey.ts @@ -72,6 +72,87 @@ export const mergeCredentialIds = ( return merged; }; +/** + * Turnkey's own validation for an authenticator name, mirrored here so a name + * this app builds can be checked before it reaches the SDK. + * + * See `isValidPasskeyName` in @turnkey/core: React Native is capped at 64 + * characters, and both platforms allow only these ASCII characters. + */ +const TURNKEY_PASSKEY_NAME_PATTERN = /^[a-zA-Z0-9 _\-:/.]{1,64}$/; + +export const isValidTurnkeyPasskeyName = (name: string): boolean => + TURNKEY_PASSKEY_NAME_PATTERN.test(name); + +/** + * Name for the passkey that a recovery adds to the account. + * + * Turnkey requires the name to match {@link TURNKEY_PASSKEY_NAME_PATTERN} and + * treats names as unique per resource, so it has to be ASCII *and* different on + * every attempt. A locale-formatted date satisfied neither: + * + * - `toLocaleDateString()` follows the device locale, so on an Arabic, Persian + * or Bengali device it returns non-Latin digits and embedded RTL marks + * (`ar-EG` gives `٧‏/٩‏/٢٠٢٦`). Those fail the pattern, and the SDK rejects + * the name before it ever shows the passkey prompt. + * - Its granularity is one day, so a second attempt on the same date re-sent a + * name the account already had, which Turnkey refuses. + * + * An ISO timestamp is ASCII by construction, passes the pattern as-is, and is + * unique per millisecond — the same approach the SDK takes when no name is + * given (`Turnkey Passkey-${Date.now()}`). + */ +export const buildRecoveryPasskeyName = (now: Date = new Date()): string => + `Recovery Passkey - ${now.toISOString()}`; + +/** + * Turnkey error codes that mean "this session can no longer act", as opposed to + * a failure of the passkey prompt or of the activity itself. + */ +const TURNKEY_SESSION_ERROR_CODES = ['SESSION_EXPIRED', 'NO_SESSION_FOUND']; + +/** + * Message fragments Turnkey returns for a lapsed session. The SDK only + * translates two exact strings into `SESSION_EXPIRED`, so anything else arrives + * wrapped in the calling method's generic message (for `addPasskey`, a bare + * "Failed to add passkey") and has to be recognised from the text. + */ +const TURNKEY_SESSION_ERROR_PATTERNS = [ + /session (?:has )?expired/i, + /expired api key/i, + /could not find public key/i, + /no active session/i, + /unauthenticated/i, +]; + +/** + * Whether a failure means the Turnkey session is gone rather than that the + * passkey step itself failed. + * + * This is the difference between a retry that can work and one that cannot: a + * recovery session is minted from a single-use code, so once it lapses no + * number of retries on the same screen will succeed — the user needs a new + * code. Errors are inspected recursively because the SDK wraps the underlying + * failure in its own `TurnkeyError` and only exposes it via `cause`. + */ +export const isTurnkeySessionError = (error: unknown, depth = 0): boolean => { + const err = error as { code?: unknown; message?: unknown; cause?: unknown } | null; + if (!err || depth > 4) return false; + + if (typeof err.code === 'string' && TURNKEY_SESSION_ERROR_CODES.includes(err.code)) { + return true; + } + + if ( + typeof err.message === 'string' && + TURNKEY_SESSION_ERROR_PATTERNS.some(pattern => pattern.test(err.message as string)) + ) { + return true; + } + + return isTurnkeySessionError(err.cause, depth + 1); +}; + /** WebAuthn / Credential Manager DOMException names raised by a failed prompt. */ const PASSKEY_ERROR_NAMES = ['NotAllowedError', 'AbortError', 'InvalidStateError'];