Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 85 additions & 7 deletions app/recovery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
Expand All @@ -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<Step>(STEPS.EMAIL_INPUT);
Expand All @@ -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
Expand Down Expand Up @@ -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,
});
Expand All @@ -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 () => {
Expand Down
12 changes: 11 additions & 1 deletion lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
{
Expand Down
98 changes: 98 additions & 0 deletions lib/utils/__tests__/passkey-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import {
buildRecoveryPasskeyName,
isPasskeyPromptError,
isTurnkeySessionError,
isValidTurnkeyPasskeyName,
mergeCredentialIds,
tryBase64urlToUint8Array,
} from '@/lib/utils/passkey';
Expand Down Expand Up @@ -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);
});
});
81 changes: 81 additions & 0 deletions lib/utils/passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
Loading