From 64600a117be29dfc2464dacb4def1649a33289af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:25:05 +0000 Subject: [PATCH] Fix the passkey a recovery adds, and stop hiding why it failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things kept the add-passkey step after a recovery from working, and a fourth kept anyone from seeing it. The name was built from `toLocaleDateString()`. Turnkey validates an authenticator name against /^[a-zA-Z0-9 _\-:\/\.]{1,64}$/, and on an Arabic, Persian or Bengali device that call returns non-Latin digits and embedded RTL marks (ar-EG gives `٧‏/٩‏/٢٠٢٦`). The SDK rejected the name before it ever showed the passkey prompt, so the step could not work at all on those devices. Its granularity was also one day, so a second attempt on the same date re-sent a name the account already held, which Turnkey refuses - one failed attempt poisoned the rest of the day. An ISO timestamp is ASCII by construction and unique per millisecond, which is what the SDK itself does when no name is given. The screen also offered a retry that could not succeed. The session is minted from a single-use code and cannot be renewed here, so once it lapses every tap fails the same way. It is now checked before the prompt rather than after - a passkey created against a dead session is one Turnkey never registers, and it stays on the device - and a session failure sends the user back to the OTP step, where "Resend code" issues a fresh one. Finally, none of this was reportable. `addPasskey` collapses every failure downstream of the prompt into a bare "Failed to add passkey" and leaves the real Turnkey error on `cause`, and this flow only logged to the console while the signup passkey flow captured to Sentry. That is why the screenshot's error appears nowhere in Sentry. Capture it with the Turnkey code and cause attached. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Us4dxLHKKdmSKQQ643mmaP --- app/recovery.tsx | 92 +++++++++++++++-- lib/api.ts | 12 ++- .../__tests__/passkey-credentials.test.ts | 98 +++++++++++++++++++ lib/utils/passkey.ts | 81 +++++++++++++++ 4 files changed, 275 insertions(+), 8 deletions(-) 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'];