From 7ca5b77df4acb66de7d4b58074229be0e5f643c7 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Wed, 28 Oct 2026 10:41:33 +0530 Subject: [PATCH 1/5] feat(passkey): support identifier verification during passkey signup When a database connection requires the user's email or phone number to be verified, passkey signup now carries the one-time codes end to end on iOS, Android and web. - passkeySignupChallenge accepts deliveryMethod ('text' | 'voice') for phone codes and returns verificationRequired, the identifiers to verify. - getTokenByPasskey accepts a verification map (identifier -> code). - A wrong code while the session is still alive rejects with a PasskeyError of type PASSKEY_VERIFICATION_RETRYABLE. error.getVerificationRetryPayload() returns authSession (the session to retry with) and verificationRequired (the identifiers to ask for again), so the app retries with the same credential and the user never creates a second passkey. A missing or empty code is not retryable. - The retry details reach JavaScript the same way on every platform: the native bridges reject with them in userInfo, and the web adapter reads them from the auth0-spa-js error. One helper decides which identifiers and codes are known, so the platforms cannot drift apart. - PasskeyVerificationMethod and PasskeyVerificationRetryPayload are new public types (additive). - Requires Auth0.swift 3.2.0 (the podspec pin moves from 3.0.1) and Auth0.Android 4.2.x and auth0-spa-js 2.29.0, which master already pins. Co-Authored-By: Claude --- A0Auth0.podspec | 2 +- EXAMPLES-WEB.md | 2 + EXAMPLES.md | 161 +++++++++++--- MIGRATION_GUIDE.md | 4 +- README.md | 6 +- .../java/com/auth0/react/A0Auth0Module.kt | 47 +++- .../A0Auth0ModulePasskeyVerificationTest.kt | 89 ++++++++ example/ios/Podfile.lock | 6 +- example/src/App.web.tsx | 54 ++++- example/src/features/PasskeysClass.tsx | 171 +++++++++++++-- example/src/features/PasskeysHooks.tsx | 173 +++++++++++++-- ios/A0Auth0.mm | 6 +- ios/NativeBridge.swift | 62 +++++- src/__tests__/fixtures/frozenPublicApi.ts | 2 + src/core/interfaces/Auth0Client.ts | 13 ++ src/core/models/PasskeyError.ts | 76 +++++++ .../models/__tests__/PasskeyError.spec.ts | 127 +++++++++++ .../__tests__/passkeyVerification.spec.ts | 78 +++++++ src/core/utils/index.ts | 1 + src/core/utils/passkeyVerification.ts | 56 +++++ src/hooks/__tests__/Auth0Provider.spec.tsx | 135 ++++++++++++ .../native/adapters/NativeAuth0Client.ts | 19 +- .../__tests__/NativeAuth0Client.spec.ts | 155 +++++++++++++- src/platforms/native/bridge/NativeBridge.ts | 8 +- .../native/bridge/NativeBridgeManager.ts | 17 +- .../__tests__/NativeBridgeManager.spec.ts | 108 ++++++++++ src/platforms/web/adapters/WebAuth0Client.ts | 28 ++- .../WebAuth0Client.passkeyRetry.spec.ts | 139 ++++++++++++ .../adapters/__tests__/WebAuth0Client.spec.ts | 200 ++++++++++++++++++ src/specs/NativeA0Auth0.ts | 12 +- src/types/parameters.ts | 66 ++++++ 31 files changed, 1918 insertions(+), 105 deletions(-) create mode 100644 android/src/test/java/com/auth0/react/A0Auth0ModulePasskeyVerificationTest.kt create mode 100644 src/core/utils/__tests__/passkeyVerification.spec.ts create mode 100644 src/core/utils/passkeyVerification.ts create mode 100644 src/platforms/web/adapters/__tests__/WebAuth0Client.passkeyRetry.spec.ts diff --git a/A0Auth0.podspec b/A0Auth0.podspec index 5ed048bec..aacad0dfe 100644 --- a/A0Auth0.podspec +++ b/A0Auth0.podspec @@ -17,7 +17,7 @@ Pod::Spec.new do |s| s.resource_bundles = { 'A0Auth0_privacy' => 'ios/PrivacyInfo.xcprivacy' } s.requires_arc = true - s.dependency 'Auth0', '3.0.1' + s.dependency 'Auth0', '3.2.0' s.dependency 'JWTDecode', '4.0.0' s.dependency 'SimpleKeychain', '1.3.0' diff --git a/EXAMPLES-WEB.md b/EXAMPLES-WEB.md index e62ba88dd..b810245a9 100644 --- a/EXAMPLES-WEB.md +++ b/EXAMPLES-WEB.md @@ -285,6 +285,8 @@ Passkeys are supported on web via `@auth0/auth0-spa-js`. The flow is the same th Because `navigator.credentials.create()`/`.get()` require a user gesture, call `passkeySignupChallenge` / `passkeyLoginChallenge` from within a click handler (not, for example, from a `useEffect`). +**Identifier verification.** When the database connection requires the user's email or phone number to be verified, `passkeySignupChallenge` returns `verificationRequired`. Collect a one-time code for each identifier **before** calling `navigator.credentials.create()`, then pass them as `verification` to `getTokenByPasskey`. A wrong code rejects with `PASSKEY_VERIFICATION_RETRYABLE`: read `error.getVerificationRetryPayload()` for `authSession` (the session to retry with) and `verificationRequired` (the identifiers to ask for again), then call `getTokenByPasskey` again with the same `PublicKeyCredential`. `deliveryMethod` (`'text'` or `'voice'`) chooses how a phone code is delivered. See [Signup with Passkey and Identifier Verification](./EXAMPLES.md#signup-with-passkey-identifier-verification) for the full flow. + ```tsx import { useAuth0, PasskeyError } from 'react-native-auth0'; diff --git a/EXAMPLES.md b/EXAMPLES.md index ca247ec58..d4b34f0df 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -58,6 +58,7 @@ - [Overview](#overview-1) - [Prerequisites](#prerequisites-1) - [Signup with Passkey](#signup-with-passkey) + - [Signup with Passkey and Identifier Verification](#signup-with-passkey-identifier-verification) - [Signin with Passkey](#signin-with-passkey) - [Signup with Passkey (Web)](#signup-with-passkey-web) - [Signin with Passkey (Web)](#signin-with-passkey-web) @@ -115,7 +116,7 @@ ## iOS framework linkage (`use_frameworks!`) -This SDK's native iOS dependencies — Auth0 3.0.1, JWTDecode 4.0.0, and SimpleKeychain 1.3.0 — are Swift pods. They install with the default React Native static-library linkage, so **most apps need no extra Podfile changes**: +This SDK's native iOS dependencies — Auth0 3.2.0, JWTDecode 4.0.0, and SimpleKeychain 1.3.0 — are Swift pods. They install with the default React Native static-library linkage, so **most apps need no extra Podfile changes**: ```bash cd ios && pod install @@ -1427,6 +1428,114 @@ function PasskeySignupScreen() { } ``` + + +### Signup with Passkey and Identifier Verification + +When your database connection requires the user's email address or phone number to be verified, Auth0 sends a one-time code (OTP) to each identifier that needs verification when you request the signup challenge. The user has to enter those codes before the signup can complete. + +> **iOS, Android and web.** The WebAuthn ceremony is the only difference: on native, `authResponse` is the JSON string from your credential manager; on web it is the `PublicKeyCredential` from `navigator.credentials.create()` (see [Signup with Passkey (Web)](#signup-with-passkey-web)). + +The flow is the regular [signup](#signup-with-passkey) with two additions: + +1. The signup challenge lists the identifiers to verify in `verificationRequired` (`'email'` and/or `'phone'`). It is `undefined` when no verification is needed, so signups that don't need it behave exactly as before. +2. You pass the codes the user entered to `getTokenByPasskey` as `verification`, a map from identifier to code. + +> **Prerequisites:** the same as a [regular signup](#passkeys-prerequisites). In addition, identifier verification must be enabled for your tenant and required by the database connection. Otherwise `verificationRequired` is `undefined`. + +**Collect the codes before you create the passkey.** Auth0 checks the codes only in `getTokenByPasskey`, after the passkey exists. Collecting them first lets the user cancel before a passkey is created, but it does not prevent an unused passkey if the exchange fails without a retry. A retryable failure reuses the same `authResponse`, so it does not prompt for a second passkey. + +If a code is wrong while its session is still alive, `getTokenByPasskey` rejects with a `PasskeyError` of type `PASSKEY_VERIFICATION_RETRYABLE`, and the signup can be retried without starting over. + +**What the retryable error carries.** Call `error.getVerificationRetryPayload()`. It is the same call on iOS, Android and web, so don't read `error.json` yourself. It returns `{ authSession, verificationRequired }` for this error, and `null` for any other error. + +| Field | Type | What to do with it | +| ---------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `authSession` | `string \| undefined` | The session to send on the retry. Auth0 returns it with every retryable error: it is the same, still valid session. Fall back to the session of the original challenge when it is `undefined`. | +| `verificationRequired` | `('email' \| 'phone')[] \| undefined` | The identifiers whose codes must be asked for again. Keep the codes that were right. When it is `undefined`, Auth0 did not say which failed: ask again for every identifier of the original challenge. | + +To retry: + +- Call `getTokenByPasskey` again with the **same** `authResponse`, the returned `authSession` and the codes collected again, so the user is not asked to create the passkey a second time. +- With `useAuth0()`, a retryable error is also stored in the hook's `error` until a retry succeeds. Don't treat it as a final failure while you are retrying. + +Any other failure is not retryable: `getVerificationRetryPayload()` returns `null`, so handle the error by its `error.type`. Wrong codes after Auth0's attempt limit, an expired session and a missing code (the identifier's key is absent from `verification`), which Auth0 rejects as an `invalid_request` error, reject with `PASSKEY_EXCHANGE_FAILED` on every platform: start over from `passkeySignupChallenge`. An empty or whitespace-only code is caught on every platform before anything is sent and rejects with `PASSKEY_INVALID_PARAMETER`: the session is untouched, so ask for the code and call again. Auth0 limits wrong codes but doesn't say how many attempts are left, so cap the number of retries in your app. + +```tsx +import { + useAuth0, + PasskeyError, + DeliveryMethod, + type PasskeyVerificationMethod, +} from 'react-native-auth0'; + +type Codes = Partial>; + +const MAX_ATTEMPTS = 3; // retry limit; adjust it to suit your app + +function PasskeySignupWithVerificationScreen() { + const { passkeySignupChallenge, getTokenByPasskey } = useAuth0(); + + const handleSignup = async () => { + try { + // Step 1: Request the challenge. Auth0 sends a code to each identifier that must be verified. + const challenge = await passkeySignupChallenge({ + email: 'user@example.com', + phoneNumber: '+14155552671', + realm: 'Username-Password-Authentication', + deliveryMethod: DeliveryMethod.TEXT, // optional: how the phone code is delivered ('text' or 'voice') + }); + + // Step 2: Collect a code for every identifier BEFORE creating the passkey. + let verification: Codes = await promptForCodes( + challenge.verificationRequired ?? [] + ); // app-defined + + // Step 3: Create the passkey once. The same credential is reused for every retry. + const credentialJson = await yourCredentialManagerCreate( + challenge.authParamsPublicKey + ); + + // Step 4: Exchange the credential and the codes for tokens. + let authSession = challenge.authSession; + for (let attempt = 1; ; attempt++) { + try { + const credentials = await getTokenByPasskey({ + authSession, + authResponse: credentialJson, + realm: 'Username-Password-Authentication', + verification, + }); + console.log('Signed up with passkey'); + return credentials; + } catch (error) { + const retry = + error instanceof PasskeyError + ? error.getVerificationRetryPayload() + : null; + if (!retry || attempt >= MAX_ATTEMPTS) throw error; // not retryable: restart the signup + + // A code was wrong. Ask again for the listed identifiers and keep the other codes. + authSession = retry.authSession ?? authSession; + verification = { + ...verification, + ...(await promptForCodes( + retry.verificationRequired ?? challenge.verificationRequired ?? [] + )), + }; + } + } + } catch (error) { + if (error instanceof PasskeyError) { + console.error('Passkey signup failed:', error.type, error.message); + } + } + }; + + return