diff --git a/.gitignore b/.gitignore
index ad341773..7fd22282 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,6 +62,8 @@ node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
+# This repo uses Yarn; npm's lock file must not be committed
+package-lock.json
# BUCK
buck-out/
diff --git a/A0Auth0.podspec b/A0Auth0.podspec
index 5ed048be..aacad0df 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 e62ba88d..94727a98 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`. Pass a non-empty code for every identifier in `verificationRequired`: an empty or whitespace-only code is rejected before anything is sent (`PASSKEY_INVALID_PARAMETER`), but on web a missing code is not checked locally, so it is sent and Auth0 rejects the exchange (`PASSKEY_EXCHANGE_FAILED`). `verificationRequired` is accepted but ignored on web (auth0-spa-js works from `verification` alone); passing it keeps one code path for native and web. A wrong code rejects with `PASSKEY_VERIFICATION_RETRYABLE`: read `error.getVerificationRetryPayload()` for `authSession` (the session to retry with) and `verificationRequired` (the identifiers whose code was wrong), then call `getTokenByPasskey` again with the same `PublicKeyCredential` and new codes for those identifiers only. `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 ca247ec5..32f26cfa 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, and the identifiers they are for as `verificationRequired`. On iOS and Android, `verificationRequired` is passed to Auth0.swift / Auth0.Android, which check that every listed identifier has a code before sending anything. On web it is ignored. Both are optional: when the challenge has no `verificationRequired`, leave both out.
+
+> **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 code was wrong. Ask for new codes for these only: the others are already verified in this session. 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` (so the user is not asked to create the passkey a second time), the returned `authSession`, the returned `verificationRequired`, and the new codes for those identifiers only as `verification`. This is how Auth0.swift and auth0-spa-js document the retry.
+- 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 and an expired session reject with `PASSKEY_EXCHANGE_FAILED` on every platform: start over from `passkeySignupChallenge`. A missing code (an identifier in `verificationRequired` whose key is absent from `verification`) also rejects with `PASSKEY_EXCHANGE_FAILED`: on iOS and Android, Auth0.swift / Auth0.Android catch it before sending anything, so the session is untouched and you can call again with the code; on web, or when you leave `verificationRequired` out, Auth0 rejects it as an `invalid_request` error. 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 = Record;
+
+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 verificationRequired: PasskeyVerificationMethod[] | undefined =
+ challenge.verificationRequired; // undefined when nothing must be verified
+ let verification: Codes = await promptForCodes(
+ 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',
+ verificationRequired,
+ 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. Retry with new codes for the listed identifiers only.
+ authSession = retry.authSession ?? authSession;
+ verificationRequired =
+ retry.verificationRequired ?? challenge.verificationRequired;
+ verification = await promptForCodes(verificationRequired ?? []);
+ }
+ }
+ } catch (error) {
+ if (error instanceof PasskeyError) {
+ console.error('Passkey signup failed:', error.type, error.message);
+ }
+ }
+ };
+
+ return ;
+}
+```
+
### Signin with Passkey
The login flow requests an assertion challenge from Auth0, then you use the platform credential manager to assert an existing passkey, and finally exchange the credential for Auth0 tokens.
@@ -1478,6 +1587,8 @@ function PasskeySigninScreen() {
Web uses the **exact same `passkeySignupChallenge` / `passkeyLoginChallenge` / `getTokenByPasskey` functions as native** — no web-specific methods were added. Only step 2 (the WebAuthn ceremony) differs: the app calls the browser's built-in [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) — `navigator.credentials.create()` for signup and `navigator.credentials.get()` for login — instead of a native module or third-party library. Unlike native, `authResponse` on web accepts the raw `PublicKeyCredential` object returned directly by `navigator.credentials` — no manual serialization needed. (See [Auth Response Format](#passkeys-auth-response-format) for the native, JSON-string form.)
+> **Identifier verification** works on web too. Collect the one-time codes _before_ calling `navigator.credentials.create()`, then pass them as `verification` to `getTokenByPasskey` together with the `PublicKeyCredential`. See [Signup with Passkey and Identifier Verification](#signup-with-passkey-identifier-verification): the flow is the same, with the `PublicKeyCredential` as `authResponse`. `verificationRequired` is accepted but ignored on web, so the same code runs on every platform.
+
```tsx
import { useAuth0, PasskeyError } from 'react-native-auth0';
@@ -1661,19 +1772,20 @@ const loginCredentials = await auth0.getTokenByPasskey({
The `passkeySignupChallenge` method accepts the following parameters to create a user profile along with the passkey. At least one of `email`, `phoneNumber`, or `username` is required — which of these your database connection actually accepts depends on its configuration (e.g. **Flexible Identifiers**). The SDK does not validate this client-side; an unsupported or missing identifier is rejected by the Auth0 API and surfaces as a `PASSKEY_CHALLENGE_FAILED` error.
-| Parameter | Type | Description |
-| -------------- | ------------------------- | ------------------------------------ |
-| `email` | `string?` | User's email address |
-| `phoneNumber` | `string?` | User's phone number |
-| `username` | `string?` | Username |
-| `name` | `string?` | Full name |
-| `givenName` | `string?` | First/given name |
-| `familyName` | `string?` | Last/family name |
-| `nickname` | `string?` | Nickname |
-| `picture` | `string?` | Profile picture URL |
-| `userMetadata` | `Record?` | Custom user metadata key-value pairs |
-| `realm` | `string?` | Database connection name |
-| `organization` | `string?` | Auth0 organization ID |
+| Parameter | Type | Description |
+| ---------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------ |
+| `email` | `string?` | User's email address |
+| `phoneNumber` | `string?` | User's phone number |
+| `username` | `string?` | Username |
+| `name` | `string?` | Full name |
+| `givenName` | `string?` | First/given name |
+| `familyName` | `string?` | Last/family name |
+| `nickname` | `string?` | Nickname |
+| `picture` | `string?` | Profile picture URL |
+| `userMetadata` | `Record?` | Custom user metadata key-value pairs |
+| `realm` | `string?` | Database connection name |
+| `organization` | `string?` | Auth0 organization ID |
+| `deliveryMethod` | `'text' \| 'voice'`? | How to deliver the code when a phone number must be [verified](#signup-with-passkey-identifier-verification) |
@@ -1681,16 +1793,17 @@ The `passkeySignupChallenge` method accepts the following parameters to create a
Passkey operations throw `PasskeyError` (extends `AuthError`) with a normalized `type` property. Use `PasskeyErrorCodes` for type-safe error handling:
-| Error Code | Description |
-| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `PASSKEY_CHALLENGE_FAILED` | Auth0 challenge request failed |
-| `PASSKEY_EXCHANGE_FAILED` | Token exchange with credential response failed |
-| `PASSKEY_NOT_AVAILABLE` | Passkeys not available on this device/OS version, or WebAuthn is not supported in this browser |
-| `PASSKEY_UNSUPPORTED_PLATFORM` | Passkeys not supported on this platform |
-| `PASSKEY_INVALID_PARAMETER` | **Native only.** `authResponse` passed to `getTokenByPasskey` was not a JSON string |
-| `PASSKEY_INVALID_CREDENTIAL` | **Web only.** The credential passed to `getTokenByPasskey` is neither a valid attestation (signup) nor assertion (login) response |
-| `PASSKEY_MFA_REQUIRED` | **Web only.** MFA is required to complete the exchange — use `error.getMfaRequiredPayload()` to extract `mfaToken` and `mfaRequirements`, then continue with `mfa.challenge()`/`mfa.verify()` |
-| `PASSKEY_UNKNOWN_ERROR` | Unknown or uncategorized passkey error — check `error.message` for the underlying description |
+| Error Code | Description |
+| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `PASSKEY_CHALLENGE_FAILED` | Auth0 challenge request failed |
+| `PASSKEY_EXCHANGE_FAILED` | Token exchange with credential response failed |
+| `PASSKEY_NOT_AVAILABLE` | Passkeys not available on this device/OS version, or WebAuthn is not supported in this browser |
+| `PASSKEY_UNSUPPORTED_PLATFORM` | Passkeys not supported on this platform |
+| `PASSKEY_INVALID_PARAMETER` | `authResponse` passed to `getTokenByPasskey` has the wrong type for the platform (a JSON string on web, a non-string on native), or a code in `verification` was empty or only whitespace |
+| `PASSKEY_INVALID_CREDENTIAL` | **Web only.** The credential passed to `getTokenByPasskey` is neither a valid attestation (signup) nor assertion (login) response |
+| `PASSKEY_VERIFICATION_RETRYABLE` | A one-time code for [identifier verification](#signup-with-passkey-identifier-verification) was wrong while its session is still alive, so the signup can be retried. `error.getVerificationRetryPayload()` returns `{ authSession, verificationRequired }`: the session to retry with and the identifiers to ask for again |
+| `PASSKEY_MFA_REQUIRED` | **Web only.** MFA is required to complete the exchange — use `error.getMfaRequiredPayload()` to extract `mfaToken` and `mfaRequirements`, then continue with `mfa.challenge()`/`mfa.verify()` |
+| `PASSKEY_UNKNOWN_ERROR` | Unknown or uncategorized passkey error — check `error.message` for the underlying description |
```typescript
import { PasskeyError, PasskeyErrorCodes } from 'react-native-auth0';
diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md
index 876be0da..4d086c57 100644
--- a/MIGRATION_GUIDE.md
+++ b/MIGRATION_GUIDE.md
@@ -79,12 +79,12 @@ The library itself now builds with `targetSdk` 36. That does not change your app
### 4. iOS: Auth0.swift 3.0 ✅
-v6 adopts **Auth0.swift 3.0.1**, which is built with the **Swift 6** compiler.
+v6 adopts **Auth0.swift 3.2.0**, which is built with the **Swift 6** compiler.
**✅ Action Required:**
1. Your iOS deployment target must meet React Native 0.82's floor (`min_ios_version_supported`, currently **15.1**). The podspec inherits this value, so no explicit `platform :ios` bump is needed beyond what RN 0.82 already requires.
-2. Run `pod install --repo-update` in your `ios` directory to pick up Auth0.swift 3.0.1.
+2. Run `pod install --repo-update` in your `ios` directory to pick up Auth0.swift 3.2.0.
3. Use **Xcode 16** or later.
> See §9 for the public-API changes that come with these native majors.
diff --git a/README.md b/README.md
index fae6ed0a..38f6923e 100644
--- a/README.md
+++ b/README.md
@@ -75,7 +75,7 @@ Then, you need to run the following command to install the ios app pods with Coc
#### iOS framework linkage (`use_frameworks!`)
-This SDK's native 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 (no `use_frameworks!`), so **most apps need no extra Podfile changes**.
+This SDK's native 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 (no `use_frameworks!`), so **most apps need no extra Podfile changes**.
If your project requires `use_frameworks!` (for example, because another dependency ships as a framework), both linkage modes are supported — this was verified by building the example app under each:
@@ -1033,9 +1033,9 @@ This library provides a unified API across Native (iOS/Android) and Web platform
| `auth.passwordless...()` | ✅ | ❌ | **Not supported on Web.** Passwordless flows on the web should be configured via Universal Login and initiated with `webAuth.authorize()`. |
| `auth.loginWith...()` (OTP/SMS etc) | ✅ | ❌ | **Not supported on Web.** These direct grant flows are not secure for public clients like browsers. |
| **Passkeys** | | | --- |
-| `passkeySignupChallenge()` | ✅ | ✅ | Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. |
+| `passkeySignupChallenge()` | ✅ | ✅ | Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. Supports identifier verification. |
| `passkeyLoginChallenge()` | ✅ | ✅ | Gets a WebAuthn assertion challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. |
-| `getTokenByPasskey()` | ✅ | ✅ | Exchanges a passkey credential response for Auth0 tokens. On Web, uses `@auth0/auth0-spa-js`. |
+| `getTokenByPasskey()` | ✅ | ✅ | Exchanges a passkey credential response for Auth0 tokens. On Web, uses `@auth0/auth0-spa-js`. Accepts identifier verification codes for signup. |
| **Token & User Management** | | | --- |
| `auth.refreshToken()` | ✅ | ❌ | **Not supported on Web.** Token refresh is handled automatically by `getCredentials()` via `getTokenSilently()` on the web. |
| `auth.userInfo()` | ✅ | ✅ | Fetches the user's profile from the `/userinfo` endpoint using an access token. |
diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt
index 02219842..d5259be3 100644
--- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt
+++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt
@@ -12,6 +12,7 @@ import com.auth0.android.Auth0
import com.auth0.android.Auth0Exception
import com.auth0.android.authentication.AuthenticationAPIClient
import com.auth0.android.authentication.AuthenticationException
+import com.auth0.android.authentication.passwordless.DeliveryMethod
import com.auth0.android.authentication.request.ActorToken
import com.auth0.android.authentication.storage.CredentialsManagerException
import com.auth0.android.authentication.storage.LocalAuthenticationOptions
@@ -29,6 +30,7 @@ import com.auth0.android.result.APICredentials
import com.auth0.android.result.Credentials
import com.auth0.android.result.PasskeyChallenge
import com.auth0.android.result.PasskeyRegistrationChallenge
+import com.auth0.android.result.PasskeyVerificationMethod
import com.facebook.react.bridge.ActivityEventListener
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
@@ -56,6 +58,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
private const val CREDENTIAL_MANAGER_ERROR_CODE = "CREDENTIAL_MANAGER_ERROR"
private const val INVALID_NETWORKING_OPTIONS_CODE = "INVALID_NETWORKING_OPTIONS"
+ private const val PASSKEY_VERIFICATION_RETRYABLE_CODE = "PASSKEY_VERIFICATION_RETRYABLE"
private const val BIOMETRICS_AUTHENTICATION_ERROR_CODE = "BIOMETRICS_CONFIGURATION_ERROR"
// DPoP-specific error codes
@@ -101,6 +104,29 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
return builder.build()
}
+ // An unknown value falls back to the server default.
+ internal fun deliveryMethodFrom(value: String?): DeliveryMethod? =
+ DeliveryMethod.values().firstOrNull { it.value == value?.trim() }
+
+ // Only the identifiers the SDK recognizes are forwarded, matching the iOS bridge.
+ internal fun verificationCodes(codes: ReadableMap?): Map =
+ codes?.let {
+ PasskeyVerificationMethod.values().mapNotNull { method ->
+ val code = if (it.hasKey(method.value)) it.getString(method.value) else null
+ code?.let { method to it }
+ }.toMap()
+ }.orEmpty()
+
+ // Only the identifiers the SDK recognizes are kept, in order, matching the iOS bridge.
+ internal fun verificationMethods(names: ReadableArray?): List =
+ names?.toArrayList().orEmpty().mapNotNull { name ->
+ PasskeyVerificationMethod.values().firstOrNull { it.value == name }
+ }
+
+ // Gson leaves a null for an identifier the SDK doesn't know, despite the non-null element type.
+ internal fun verificationMethodNames(methods: List?): List =
+ methods.orEmpty().filterNotNull().map { it.value }
+
// Auth0.getInstance() returns a shared singleton per clientId/domain: a sibling client
// (or this same client on re-init) must not inherit another initialization's networking
// config, so this always resolves to a fresh DefaultClient() when options are absent
@@ -782,6 +808,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
userMetadata: ReadableMap?,
realm: String?,
organization: String?,
+ deliveryMethod: String?,
promise: Promise
) {
val authClient = AuthenticationAPIClient(auth0!!)
@@ -800,6 +827,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
val finalUserMetadata = userMetadata?.toHashMap()?.mapValues { it.value?.toString() ?: "" }?.ifEmpty { null }
val finalRealm = realm?.trim()?.ifEmpty { null }
val finalOrg = organization?.trim()?.ifEmpty { null }
+ val finalDeliveryMethod = deliveryMethodFrom(deliveryMethod)
val userData = UserData(
email = finalEmail,
phoneNumber = finalPhone,
@@ -812,13 +840,18 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
userMetadata = finalUserMetadata
)
- authClient.signupWithPasskey(userData, finalRealm, finalOrg)
+ authClient.signupWithPasskey(userData, finalRealm, finalOrg, finalDeliveryMethod)
.start(object : com.auth0.android.callback.Callback {
override fun onSuccess(challenge: PasskeyRegistrationChallenge) {
val result = WritableNativeMap().apply {
putString("authSession", challenge.authSession)
val authParamsJson = Gson().toJson(challenge.authParamsPublicKey)
putMap("authParamsPublicKey", JsonUtils.jsonToWritableMap(authParamsJson))
+ // Left out when no verification is needed, so existing signups see an unchanged response.
+ val verificationRequired = verificationMethodNames(challenge.verificationRequired)
+ if (verificationRequired.isNotEmpty()) {
+ putArray("verificationRequired", Arguments.fromList(verificationRequired))
+ }
}
promise.resolve(result)
}
@@ -868,6 +901,8 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
audience: String?,
scope: String?,
organization: String?,
+ verification: ReadableMap?,
+ verificationRequired: ReadableArray?,
promise: Promise
) {
val authClient = AuthenticationAPIClient(auth0!!)
@@ -879,6 +914,9 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
val finalRealm = realm?.trim()?.ifEmpty { null }
val finalAudience = audience?.trim()?.ifEmpty { null }
val finalOrg = organization?.trim()?.ifEmpty { null }
+ val finalVerification = verificationCodes(verification)
+ // Auth0.Android checks the codes against these identifiers before sending the request.
+ val finalVerificationRequired = verificationMethods(verificationRequired)
val publicKeyCredentials = try {
Gson().fromJson(authResponse, PublicKeyCredentials::class.java)
@@ -887,7 +925,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
return
}
- val request = authClient.signinWithPasskey(authSession, publicKeyCredentials, finalRealm, finalOrg)
+ val request = authClient.signinWithPasskey(authSession, publicKeyCredentials, finalRealm, finalOrg, finalVerification, finalVerificationRequired)
finalAudience?.let { request.setAudience(it) }
request.setScope(finalScope)
request.validateClaims()
@@ -898,7 +936,19 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
}
override fun onFailure(error: AuthenticationException) {
- promise.reject("PASSKEY_EXCHANGE_FAILED", error.getDescription(), error)
+ if (error.isPasskeyVerificationRetryable) {
+ // A code was wrong and the session is still usable: hand JS what a retry needs.
+ val userInfo = WritableNativeMap().apply {
+ error.passkeyAuthSession?.let { putString("authSession", it) }
+ val verificationRequired = verificationMethodNames(error.passkeyVerificationRequired)
+ if (verificationRequired.isNotEmpty()) {
+ putArray("verificationRequired", Arguments.fromList(verificationRequired))
+ }
+ }
+ promise.reject(PASSKEY_VERIFICATION_RETRYABLE_CODE, error.getDescription(), error, userInfo)
+ } else {
+ promise.reject("PASSKEY_EXCHANGE_FAILED", error.getDescription(), error)
+ }
}
})
}
diff --git a/android/src/test/java/com/auth0/react/A0Auth0ModulePasskeyVerificationTest.kt b/android/src/test/java/com/auth0/react/A0Auth0ModulePasskeyVerificationTest.kt
new file mode 100644
index 00000000..2a3e8d32
--- /dev/null
+++ b/android/src/test/java/com/auth0/react/A0Auth0ModulePasskeyVerificationTest.kt
@@ -0,0 +1,114 @@
+package com.auth0.react
+
+import com.auth0.android.authentication.passwordless.DeliveryMethod
+import com.auth0.android.result.PasskeyVerificationMethod
+import com.facebook.react.bridge.JavaOnlyArray
+import com.facebook.react.bridge.JavaOnlyMap
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+// Covers the pure helpers behind the passkey identifier-verification bridge code. The retryable
+// rejection's userInfo is built from WritableNativeMap/Arguments, which need React Native's
+// native libraries and cannot run in a plain JVM unit test.
+class A0Auth0ModulePasskeyVerificationTest {
+
+ @Test
+ fun `verificationMethodNames returns the wire value of each method in order`() {
+ val names = A0Auth0Module.verificationMethodNames(
+ listOf(PasskeyVerificationMethod.PHONE, PasskeyVerificationMethod.EMAIL)
+ )
+
+ assertEquals(listOf("phone", "email"), names)
+ }
+
+ @Test
+ fun `verificationMethodNames drops the nulls Gson leaves for unknown identifiers`() {
+ val names = A0Auth0Module.verificationMethodNames(
+ listOf(PasskeyVerificationMethod.EMAIL, null)
+ )
+
+ assertEquals(listOf("email"), names)
+ }
+
+ @Test
+ fun `verificationMethodNames is empty for a null or empty list`() {
+ assertEquals(emptyList(), A0Auth0Module.verificationMethodNames(null))
+ assertEquals(emptyList(), A0Auth0Module.verificationMethodNames(emptyList()))
+ }
+
+ @Test
+ fun `verificationCodes maps the known identifiers to their codes`() {
+ val codes = A0Auth0Module.verificationCodes(JavaOnlyMap.of("email", "123456", "phone", "654321"))
+
+ assertEquals(
+ mapOf(
+ PasskeyVerificationMethod.EMAIL to "123456",
+ PasskeyVerificationMethod.PHONE to "654321"
+ ),
+ codes
+ )
+ }
+
+ @Test
+ fun `verificationCodes drops unknown identifiers`() {
+ val codes = A0Auth0Module.verificationCodes(JavaOnlyMap.of("email", "123456", "username", "111111"))
+
+ assertEquals(mapOf(PasskeyVerificationMethod.EMAIL to "123456"), codes)
+ }
+
+ @Test
+ fun `verificationCodes is empty for a missing map, an empty map or only unknown identifiers`() {
+ assertEquals(emptyMap(), A0Auth0Module.verificationCodes(null))
+ assertEquals(emptyMap(), A0Auth0Module.verificationCodes(JavaOnlyMap()))
+ assertEquals(
+ emptyMap(),
+ A0Auth0Module.verificationCodes(JavaOnlyMap.of("username", "111111"))
+ )
+ }
+
+ @Test
+ fun `verificationMethods maps the known identifiers in order`() {
+ val methods = A0Auth0Module.verificationMethods(JavaOnlyArray.of("phone", "email"))
+
+ assertEquals(listOf(PasskeyVerificationMethod.PHONE, PasskeyVerificationMethod.EMAIL), methods)
+ }
+
+ @Test
+ fun `verificationMethods drops unknown identifiers and values that are not strings`() {
+ val methods = A0Auth0Module.verificationMethods(JavaOnlyArray.of("email", "username", 7, null))
+
+ assertEquals(listOf(PasskeyVerificationMethod.EMAIL), methods)
+ }
+
+ @Test
+ fun `verificationMethods is empty for a missing array, an empty array or only unknown identifiers`() {
+ assertEquals(emptyList(), A0Auth0Module.verificationMethods(null))
+ assertEquals(emptyList(), A0Auth0Module.verificationMethods(JavaOnlyArray()))
+ assertEquals(
+ emptyList(),
+ A0Auth0Module.verificationMethods(JavaOnlyArray.of("username"))
+ )
+ }
+
+ @Test
+ fun `deliveryMethodFrom matches the wire value of each DeliveryMethod`() {
+ DeliveryMethod.values().forEach { method ->
+ assertEquals(method, A0Auth0Module.deliveryMethodFrom(method.value))
+ }
+ }
+
+ @Test
+ fun `deliveryMethodFrom trims whitespace around the value`() {
+ val method = DeliveryMethod.values().first()
+
+ assertEquals(method, A0Auth0Module.deliveryMethodFrom(" ${method.value} "))
+ }
+
+ @Test
+ fun `deliveryMethodFrom falls back to null for a missing or unknown value`() {
+ assertNull(A0Auth0Module.deliveryMethodFrom(null))
+ assertNull(A0Auth0Module.deliveryMethodFrom(""))
+ assertNull(A0Auth0Module.deliveryMethodFrom("carrier_pigeon"))
+ }
+}
diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock
index be294b06..60ba762f 100644
--- a/example/ios/Podfile.lock
+++ b/example/ios/Podfile.lock
@@ -1,6 +1,6 @@
PODS:
- - A0Auth0 (6.0.0-beta.0):
- - Auth0 (= 3.0.1)
+ - A0Auth0 (6.0.0):
+ - Auth0 (= 3.2.0)
- hermes-engine
- JWTDecode (= 4.0.0)
- RCTRequired
@@ -25,7 +25,7 @@ PODS:
- ReactNativeDependencies
- SimpleKeychain (= 1.3.0)
- Yoga
- - Auth0 (3.0.1):
+ - Auth0 (3.2.0):
- JWTDecode (= 4.0)
- SimpleKeychain (= 1.3.0)
- boost (1.84.0):
@@ -2126,8 +2126,8 @@ EXTERNAL SOURCES:
:path: build/rncore-facades/Yoga
SPEC CHECKSUMS:
- A0Auth0: 70fbb5b36d9b34139c8354820b968f321879861e
- Auth0: c94e637a662b4e07aeb1e6e5acf3f8787e451e1c
+ A0Auth0: 0c4eb7421f5c25d8ff14f0c0950312f24d025a6d
+ Auth0: a46f78a751b6b77b533c22d4ede90d985cb6112c
boost: 8502bfdc46e2804408af2e163049ece181009af4
DoubleConversion: 501a575c40c0b50c89685762dd3e415e6bb26fb0
fast_float: 7c925d1cebf8328d4018d061f49377e9f887e3b4
diff --git a/example/src/App.web.tsx b/example/src/App.web.tsx
index ce06c008..f6ac5aae 100644
--- a/example/src/App.web.tsx
+++ b/example/src/App.web.tsx
@@ -15,6 +15,7 @@ import type {
MfaEnrollmentChallenge,
MfaChallengeResult,
DPoPHeadersParameters,
+ PasskeyVerificationMethod,
} from 'react-native-auth0';
import config from './auth0-configuration';
@@ -894,6 +895,15 @@ function MyAccountSection() {
// Hooks — Passkeys (browser WebAuthn)
// ============================================================
+// Asks the user for the one-time code Auth0 sent to each identifier.
+const promptForCodes = (methods: PasskeyVerificationMethod[]) =>
+ Object.fromEntries(
+ methods.map((method) => [
+ method,
+ window.prompt(`Enter the code sent to your ${method}`) ?? '',
+ ])
+ ) as Record;
+
function PasskeysSection() {
const { passkeySignupChallenge, passkeyLoginChallenge, getTokenByPasskey } =
useAuth0();
@@ -901,16 +911,26 @@ function PasskeysSection() {
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const [passkeyEmail, setPasskeyEmail] = useState('');
+ const [passkeyPhone, setPasskeyPhone] = useState('');
+ // What each retryable wrong-code error said; the session itself is not shown.
+ const [retries, setRetries] = useState([]);
const onSignup = async () => {
setResult(null);
setError(null);
+ setRetries([]);
setLoading(true);
try {
const challenge = await passkeySignupChallenge({
email: passkeyEmail || undefined,
+ phoneNumber: passkeyPhone || undefined,
realm: 'Username-Password-Authentication',
});
+ // Collect the one-time codes BEFORE creating the passkey, so the user can
+ // cancel before a passkey exists. Auth0 checks them only in
+ // getTokenByPasskey, so a failed exchange can still leave an unused one.
+ let verificationRequired = challenge.verificationRequired;
+ let verification = promptForCodes(verificationRequired ?? []);
let credential: PublicKeyCredential;
try {
credential = (await navigator.credentials.create({
@@ -920,11 +940,38 @@ function PasskeysSection() {
} catch (e) {
throw new PasskeyError(e as Error);
}
- const creds = await getTokenByPasskey({
- authSession: challenge.authSession,
- authResponse: credential,
- realm: 'Username-Password-Authentication',
- });
+ // A wrong code can be retried with the SAME credential.
+ let authSession = challenge.authSession;
+ let creds;
+ for (let attempt = 1; ; attempt++) {
+ try {
+ creds = await getTokenByPasskey({
+ authSession,
+ authResponse: credential,
+ realm: 'Username-Password-Authentication',
+ // Ignored on web, where spa-js works from the codes alone; kept for parity with native.
+ verificationRequired,
+ verification,
+ });
+ break;
+ } catch (e) {
+ const retry =
+ e instanceof PasskeyError ? e.getVerificationRetryPayload() : null;
+ if (!retry || attempt >= 3) throw e;
+ authSession = retry.authSession ?? authSession;
+ verificationRequired =
+ retry.verificationRequired ?? challenge.verificationRequired;
+ setRetries((previous) => [
+ ...previous,
+ `Retry #${attempt}: wrong code for ${
+ retry.verificationRequired?.join(', ') ??
+ '(not listed, asking for all again)'
+ }; authSession ${retry.authSession ? 'returned' : 'missing'}.`,
+ ]);
+ // The other identifiers are verified already: fresh codes for the flagged ones only.
+ verification = promptForCodes(verificationRequired ?? []);
+ }
+ }
setResult({
success: true,
accessToken: creds.accessToken.substring(0, 30) + '...',
@@ -982,16 +1029,25 @@ function PasskeysSection() {
onChangeText={setPasskeyEmail}
keyboardType="email-address"
/>
+
+ {retries.map((line) => (
+ {line}
+ ))}
diff --git a/example/src/features/PasskeysClass.tsx b/example/src/features/PasskeysClass.tsx
index fb590036..3e595b14 100644
--- a/example/src/features/PasskeysClass.tsx
+++ b/example/src/features/PasskeysClass.tsx
@@ -1,10 +1,36 @@
import React, { useState } from 'react';
-import { Button, TextInput, View } from 'react-native';
+import { Button, Text, TextInput, View } from 'react-native';
+import {
+ DeliveryMethod,
+ PasskeyError,
+ type PasskeyVerificationMethod,
+ type PasswordlessDeliveryMethod,
+} from 'react-native-auth0';
import auth0 from '../shared/api';
import Section from '../shared/Section';
import ResultView from '../shared/ResultView';
import { createPasskey, getPasskey } from '../passkey/PasskeyModule';
+const REALM = 'Username-Password-Authentication';
+
+// A signup on a connection that verifies identifiers is split in two. The challenge lists what to verify; the
+// passkey is only created once the codes are in, and a wrong code is retried with the same credential.
+type PendingSignup = {
+ authSession: string;
+ authParamsPublicKey: Record;
+ // The challenge's identifiers, then only the ones the last wrong-code error flagged.
+ verificationRequired: PasskeyVerificationMethod[];
+ credentialJson?: string;
+ retries: number;
+ // What the last retryable error said; the session itself is not shown.
+ lastRetry?: {
+ flagged?: PasskeyVerificationMethod[];
+ hasAuthSession: boolean;
+ };
+};
+
+type Codes = Record;
+
// Class-based reference for Passkeys. Same operations as PasskeysHooks.tsx,
// expressed against the Auth0 class instance. Not imported by the app — kept
// as a side-by-side reference.
@@ -12,6 +38,11 @@ const PasskeysClass = () => {
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [email, setEmail] = useState('');
+ const [phoneNumber, setPhoneNumber] = useState('');
+ const [deliveryMethod, setDeliveryMethod] =
+ useState(DeliveryMethod.TEXT);
+ const [pending, setPending] = useState(null);
+ const [codes, setCodes] = useState({});
const run = async (fn: () => Promise) => {
setError(null);
@@ -24,6 +55,91 @@ const PasskeysClass = () => {
}
};
+ const cancelSignup = () => {
+ setPending(null);
+ setCodes({});
+ };
+
+ const finishSignup = async (signup: PendingSignup) => {
+ // Created once, after every code is entered; a retry reuses it instead of prompting for a second passkey.
+ const credentialJson =
+ signup.credentialJson ??
+ (await createPasskey(signup.authParamsPublicKey));
+ const current = { ...signup, credentialJson };
+ setPending(current);
+
+ try {
+ const credentials = await auth0.getTokenByPasskey({
+ authSession: current.authSession,
+ authResponse: credentialJson,
+ realm: REALM,
+ verificationRequired: current.verificationRequired,
+ verification: codes,
+ });
+ cancelSignup();
+ return credentials;
+ } catch (e) {
+ const retry =
+ e instanceof PasskeyError ? e.getVerificationRetryPayload() : null;
+ if (!retry) {
+ // Not retryable (attempts used up, session expired, ...): start the signup over.
+ cancelSignup();
+ throw e;
+ }
+ const failed = retry.verificationRequired?.length
+ ? retry.verificationRequired
+ : current.verificationRequired;
+ setPending({
+ ...current,
+ authSession: retry.authSession ?? current.authSession,
+ verificationRequired: failed,
+ retries: current.retries + 1,
+ lastRetry: {
+ flagged: retry.verificationRequired,
+ hasAuthSession: retry.authSession !== undefined,
+ },
+ });
+ // The other identifiers are verified already: the retry sends fresh codes for the flagged ones only.
+ setCodes({});
+ throw e;
+ }
+ };
+
+ const startSignup = async () => {
+ const challenge = await auth0.passkeySignupChallenge({
+ email: email || undefined,
+ phoneNumber: phoneNumber || undefined,
+ realm: REALM,
+ deliveryMethod: phoneNumber ? deliveryMethod : undefined,
+ });
+
+ if (challenge.verificationRequired?.length) {
+ // Auth0 sent a code to each identifier; wait for the user to enter them.
+ setCodes({});
+ setPending({
+ authSession: challenge.authSession,
+ authParamsPublicKey: challenge.authParamsPublicKey,
+ verificationRequired: challenge.verificationRequired,
+ retries: 0,
+ });
+ return {
+ verificationRequired: challenge.verificationRequired,
+ next: 'Enter the codes below, then verify.',
+ };
+ }
+
+ const credentialJson = await createPasskey(challenge.authParamsPublicKey);
+ return auth0.getTokenByPasskey({
+ authSession: challenge.authSession,
+ authResponse: credentialJson,
+ realm: REALM,
+ });
+ };
+
+ const missingCode = pending?.verificationRequired.some(
+ (method) => !codes[method]
+ );
+
return (
@@ -33,32 +149,69 @@ const PasskeysClass = () => {
value={email}
onChangeText={setEmail}
/>
+
run(async () => {
const challenge = await auth0.passkeyLoginChallenge({
- realm: 'Username-Password-Authentication',
+ realm: REALM,
});
const credentialJson = await getPasskey(
challenge.authParamsPublicKey
@@ -66,7 +219,7 @@ const PasskeysClass = () => {
return auth0.getTokenByPasskey({
authSession: challenge.authSession,
authResponse: credentialJson,
- realm: 'Username-Password-Authentication',
+ realm: REALM,
});
})
}
diff --git a/example/src/features/PasskeysHooks.tsx b/example/src/features/PasskeysHooks.tsx
index 68d61333..5564ce0c 100644
--- a/example/src/features/PasskeysHooks.tsx
+++ b/example/src/features/PasskeysHooks.tsx
@@ -1,10 +1,36 @@
import React, { useState } from 'react';
-import { Button, TextInput, View } from 'react-native';
-import { useAuth0 } from 'react-native-auth0';
+import { Button, Text, TextInput, View } from 'react-native';
+import {
+ DeliveryMethod,
+ PasskeyError,
+ useAuth0,
+ type PasskeyVerificationMethod,
+ type PasswordlessDeliveryMethod,
+} from 'react-native-auth0';
import Section from '../shared/Section';
import ResultView from '../shared/ResultView';
import { createPasskey, getPasskey } from '../passkey/PasskeyModule';
+const REALM = 'Username-Password-Authentication';
+
+// A signup on a connection that verifies identifiers is split in two. The challenge lists what to verify; the
+// passkey is only created once the codes are in, and a wrong code is retried with the same credential.
+type PendingSignup = {
+ authSession: string;
+ authParamsPublicKey: Record;
+ // The challenge's identifiers, then only the ones the last wrong-code error flagged.
+ verificationRequired: PasskeyVerificationMethod[];
+ credentialJson?: string;
+ retries: number;
+ // What the last retryable error said; the session itself is not shown.
+ lastRetry?: {
+ flagged?: PasskeyVerificationMethod[];
+ hasAuthSession: boolean;
+ };
+};
+
+type Codes = Record;
+
const PasskeysHooks = () => {
const { passkeySignupChallenge, passkeyLoginChallenge, getTokenByPasskey } =
useAuth0();
@@ -12,6 +38,11 @@ const PasskeysHooks = () => {
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [email, setEmail] = useState('');
+ const [phoneNumber, setPhoneNumber] = useState('');
+ const [deliveryMethod, setDeliveryMethod] =
+ useState(DeliveryMethod.TEXT);
+ const [pending, setPending] = useState(null);
+ const [codes, setCodes] = useState({});
const run = async (fn: () => Promise) => {
setError(null);
@@ -24,6 +55,91 @@ const PasskeysHooks = () => {
}
};
+ const cancelSignup = () => {
+ setPending(null);
+ setCodes({});
+ };
+
+ const finishSignup = async (signup: PendingSignup) => {
+ // Created once, after every code is entered; a retry reuses it instead of prompting for a second passkey.
+ const credentialJson =
+ signup.credentialJson ??
+ (await createPasskey(signup.authParamsPublicKey));
+ const current = { ...signup, credentialJson };
+ setPending(current);
+
+ try {
+ const credentials = await getTokenByPasskey({
+ authSession: current.authSession,
+ authResponse: credentialJson,
+ realm: REALM,
+ verificationRequired: current.verificationRequired,
+ verification: codes,
+ });
+ cancelSignup();
+ return credentials;
+ } catch (e) {
+ const retry =
+ e instanceof PasskeyError ? e.getVerificationRetryPayload() : null;
+ if (!retry) {
+ // Not retryable (attempts used up, session expired, ...): start the signup over.
+ cancelSignup();
+ throw e;
+ }
+ const failed = retry.verificationRequired?.length
+ ? retry.verificationRequired
+ : current.verificationRequired;
+ setPending({
+ ...current,
+ authSession: retry.authSession ?? current.authSession,
+ verificationRequired: failed,
+ retries: current.retries + 1,
+ lastRetry: {
+ flagged: retry.verificationRequired,
+ hasAuthSession: retry.authSession !== undefined,
+ },
+ });
+ // The other identifiers are verified already: the retry sends fresh codes for the flagged ones only.
+ setCodes({});
+ throw e;
+ }
+ };
+
+ const startSignup = async () => {
+ const challenge = await passkeySignupChallenge({
+ email: email || undefined,
+ phoneNumber: phoneNumber || undefined,
+ realm: REALM,
+ deliveryMethod: phoneNumber ? deliveryMethod : undefined,
+ });
+
+ if (challenge.verificationRequired?.length) {
+ // Auth0 sent a code to each identifier; wait for the user to enter them.
+ setCodes({});
+ setPending({
+ authSession: challenge.authSession,
+ authParamsPublicKey: challenge.authParamsPublicKey,
+ verificationRequired: challenge.verificationRequired,
+ retries: 0,
+ });
+ return {
+ verificationRequired: challenge.verificationRequired,
+ next: 'Enter the codes below, then verify.',
+ };
+ }
+
+ const credentialJson = await createPasskey(challenge.authParamsPublicKey);
+ return getTokenByPasskey({
+ authSession: challenge.authSession,
+ authResponse: credentialJson,
+ realm: REALM,
+ });
+ };
+
+ const missingCode = pending?.verificationRequired.some(
+ (method) => !codes[method]
+ );
+
return (
@@ -33,32 +149,69 @@ const PasskeysHooks = () => {
value={email}
onChangeText={setEmail}
/>
+
- run(async () => {
- const challenge = await passkeySignupChallenge({
- email: email || undefined,
- realm: 'Username-Password-Authentication',
- });
- const credentialJson = await createPasskey(
- challenge.authParamsPublicKey
- );
- return getTokenByPasskey({
- authSession: challenge.authSession,
- authResponse: credentialJson,
- realm: 'Username-Password-Authentication',
- });
- })
+ setDeliveryMethod((current) =>
+ current === DeliveryMethod.TEXT
+ ? DeliveryMethod.VOICE
+ : DeliveryMethod.TEXT
+ )
}
+ disabled={!phoneNumber}
+ />
+ run(startSignup)}
+ disabled={pending !== null}
/>
+ {pending && (
+
+
+ Enter the code sent to each identifier to finish the signup.
+
+ {pending.lastRetry && (
+
+ {`Retry #${pending.retries}: wrong code for ${
+ pending.lastRetry.flagged?.join(', ') ??
+ '(not listed, asking for all again)'
+ }; authSession ${
+ pending.lastRetry.hasAuthSession ? 'returned' : 'missing'
+ }.`}
+
+ )}
+ {pending.verificationRequired.map((method) => (
+
+ setCodes((previous) => ({ ...previous, [method]: code }))
+ }
+ />
+ ))}
+ run(() => finishSignup(pending))}
+ disabled={missingCode}
+ />
+
+
+ )}
run(async () => {
const challenge = await passkeyLoginChallenge({
- realm: 'Username-Password-Authentication',
+ realm: REALM,
});
const credentialJson = await getPasskey(
challenge.authParamsPublicKey
@@ -66,7 +219,7 @@ const PasskeysHooks = () => {
return getTokenByPasskey({
authSession: challenge.authSession,
authResponse: credentialJson,
- realm: 'Username-Password-Authentication',
+ realm: REALM,
});
})
}
diff --git a/ios/A0Auth0.mm b/ios/A0Auth0.mm
index c6a8753e..5986d56d 100644
--- a/ios/A0Auth0.mm
+++ b/ios/A0Auth0.mm
@@ -249,9 +249,10 @@ - (dispatch_queue_t)methodQueue
userMetadata:(NSDictionary * _Nullable)userMetadata
realm:(NSString * _Nullable)realm
organization:(NSString * _Nullable)organization
+ deliveryMethod:(NSString * _Nullable)deliveryMethod
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject) {
- [self.nativeBridge passkeySignupChallengeWithEmail:email phoneNumber:phoneNumber username:username name:name givenName:givenName familyName:familyName nickname:nickname picture:picture userMetadata:userMetadata realm:realm organization:organization resolve:resolve reject:reject];
+ [self.nativeBridge passkeySignupChallengeWithEmail:email phoneNumber:phoneNumber username:username name:name givenName:givenName familyName:familyName nickname:nickname picture:picture userMetadata:userMetadata realm:realm organization:organization deliveryMethod:deliveryMethod resolve:resolve reject:reject];
}
RCT_EXPORT_METHOD(passkeyLoginChallenge:(NSString * _Nullable)realm
@@ -267,9 +268,11 @@ - (dispatch_queue_t)methodQueue
audience:(NSString * _Nullable)audience
scope:(NSString * _Nullable)scope
organization:(NSString * _Nullable)organization
+ verification:(NSDictionary * _Nullable)verification
+ verificationRequired:(NSArray * _Nullable)verificationRequired
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject) {
- [self.nativeBridge getTokenByPasskeyWithAuthSession:authSession authResponse:authResponse realm:realm audience:audience scope:scope organization:organization resolve:resolve reject:reject];
+ [self.nativeBridge getTokenByPasskeyWithAuthSession:authSession authResponse:authResponse realm:realm audience:audience scope:scope organization:organization verification:verification verificationRequired:verificationRequired resolve:resolve reject:reject];
}
RCT_EXPORT_METHOD(passwordlessChallengeWithEmail:(NSString *)email
diff --git a/ios/NativeBridge.swift b/ios/NativeBridge.swift
index 1575de4a..e3b33a11 100644
--- a/ios/NativeBridge.swift
+++ b/ios/NativeBridge.swift
@@ -27,6 +27,7 @@ public class NativeBridge: NSObject {
nonisolated static let dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'";
nonisolated static let credentialsManagerErrorCode = "CREDENTIAL_MANAGER_ERROR"
+ nonisolated static let passkeyVerificationRetryableErrorCode = "PASSKEY_VERIFICATION_RETRYABLE"
nonisolated static let biometricsAuthenticationErrorCode = "BIOMETRICS_CONFIGURATION_ERROR"
// DPoP error codes
@@ -476,7 +477,7 @@ public class NativeBridge: NSObject {
// MARK: - Passkey Methods (challenge / exchange)
- @objc public func passkeySignupChallenge(email: String?, phoneNumber: String?, username: String?, name: String?, givenName: String?, familyName: String?, nickname: String?, picture: String?, userMetadata: [String: String]?, realm: String?, organization: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
+ @objc public func passkeySignupChallenge(email: String?, phoneNumber: String?, username: String?, name: String?, givenName: String?, familyName: String?, nickname: String?, picture: String?, userMetadata: [String: String]?, realm: String?, organization: String?, deliveryMethod: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
guard #available(iOS 16.6, *) else {
reject("PASSKEY_NOT_AVAILABLE", "Passkeys require iOS 16.6 or later", nil)
return
@@ -494,6 +495,8 @@ public class NativeBridge: NSObject {
let userMetadataValue = userMetadata?.isEmpty == true ? nil : userMetadata
let realmValue = realm?.isEmpty == true ? nil : realm
let orgValue = organization?.isEmpty == true ? nil : organization
+ // An unknown value falls back to the server default, like `Passwordless.challengeWithPhoneNumber`.
+ let deliveryMethodValue = deliveryMethod.flatMap { DeliveryMethod(rawValue: $0) }
var auth = Auth0.authentication(clientId: self.clientId, domain: self.domain)
if self.useDPoP {
@@ -512,7 +515,8 @@ public class NativeBridge: NSObject {
picture: pictureValue,
userMetadata: userMetadataValue,
connection: realmValue,
- organization: orgValue
+ organization: orgValue,
+ deliveryMethod: deliveryMethodValue
).start { result in
switch result {
case .success(let challenge):
@@ -524,10 +528,15 @@ public class NativeBridge: NSObject {
"name": challenge.userName
]
]
- let response: [String: Any] = [
+ var response: [String: Any] = [
"authSession": challenge.authenticationSession,
"authParamsPublicKey": authParamsPublicKey
]
+ // Left out when no verification is needed, so existing signups see an unchanged response.
+ let verificationRequired = NativeBridge.verificationMethodNames(challenge.verificationRequired)
+ if !verificationRequired.isEmpty {
+ response["verificationRequired"] = verificationRequired
+ }
resolve(response)
case .failure(let error):
reject("PASSKEY_CHALLENGE_FAILED", error.localizedDescription, error)
@@ -571,7 +580,7 @@ public class NativeBridge: NSObject {
}
}
- @objc public func getTokenByPasskey(authSession: String, authResponse: String, realm: String?, audience: String?, scope: String?, organization: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
+ @objc public func getTokenByPasskey(authSession: String, authResponse: String, realm: String?, audience: String?, scope: String?, organization: String?, verification: [String: String]?, verificationRequired: [String]?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
guard #available(iOS 16.6, *) else {
reject("PASSKEY_EXCHANGE_FAILED", "Passkeys require iOS 16.6 or later", nil)
return
@@ -581,6 +590,23 @@ public class NativeBridge: NSObject {
let audienceValue = audience?.isEmpty == true ? nil : audience
let orgValue = organization?.isEmpty == true ? nil : organization
let finalScope = scope?.isEmpty == true ? "openid profile email" : (scope ?? "openid profile email")
+ // Only the identifiers the SDK recognizes are forwarded, matching the Android bridge.
+ var verificationCodes: [PasskeyVerificationMethod: String] = [:]
+ for (identifier, code) in verification ?? [:] {
+ switch identifier {
+ case "email": verificationCodes[.email] = code
+ case "phone": verificationCodes[.phone] = code
+ default: break
+ }
+ }
+ // Auth0.swift checks the codes against these identifiers before sending the request.
+ let verificationMethods: [PasskeyVerificationMethod] = (verificationRequired ?? []).compactMap { identifier in
+ switch identifier {
+ case "email": return .email
+ case "phone": return .phone
+ default: return nil
+ }
+ }
guard let responseData = authResponse.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any],
@@ -615,7 +641,8 @@ public class NativeBridge: NSObject {
relyingPartyId: self.domain,
userId: Data(),
userName: "",
- challengeData: Data()
+ challengeData: Data(),
+ verificationRequired: verificationMethods
)
auth.login(
passkey: passkey,
@@ -623,13 +650,20 @@ public class NativeBridge: NSObject {
connection: realmValue,
audience: audienceValue,
scope: finalScope,
- organization: orgValue
+ organization: orgValue,
+ verification: verificationCodes
).start { result in
switch result {
case .success(let credentials):
resolve(credentials.asDictionary())
case .failure(let error):
- reject("PASSKEY_EXCHANGE_FAILED", error.localizedDescription, error)
+ if error.isPasskeyVerificationRetryable {
+ reject(NativeBridge.passkeyVerificationRetryableErrorCode,
+ error.localizedDescription,
+ NativeBridge.passkeyVerificationRetryableError(from: error))
+ } else {
+ reject("PASSKEY_EXCHANGE_FAILED", error.localizedDescription, error)
+ }
}
}
} else {
@@ -678,6 +712,31 @@ public class NativeBridge: NSObject {
}
}
+ /// The identifiers this SDK reports to JavaScript; ones it doesn't recognize are left out.
+ nonisolated private static func verificationMethodNames(_ methods: [PasskeyVerificationMethod]?) -> [String] {
+ return (methods ?? []).compactMap { method in
+ switch method {
+ case .email: return "email"
+ case .phone: return "phone"
+ case .unknown: return nil
+ }
+ }
+ }
+
+ /// An error whose `userInfo` reaches JavaScript with what a retry needs. React Native only forwards `userInfo`
+ /// from an `NSError`, and `AuthenticationError` doesn't carry it.
+ nonisolated private static func passkeyVerificationRetryableError(from error: AuthenticationError) -> NSError {
+ var userInfo: [String: Any] = [NSLocalizedDescriptionKey: error.localizedDescription]
+ if let authSession = error.passkeyAuthSession {
+ userInfo["authSession"] = authSession
+ }
+ let verificationRequired = verificationMethodNames(error.passkeyVerificationRequired)
+ if !verificationRequired.isEmpty {
+ userInfo["verificationRequired"] = verificationRequired
+ }
+ return NSError(domain: passkeyVerificationRetryableErrorCode, code: error.statusCode, userInfo: userInfo)
+ }
+
@objc public func getClientId() -> String {
return clientId
}
diff --git a/src/__tests__/fixtures/frozenPublicApi.ts b/src/__tests__/fixtures/frozenPublicApi.ts
index e37d6ef9..4cda9efc 100644
--- a/src/__tests__/fixtures/frozenPublicApi.ts
+++ b/src/__tests__/fixtures/frozenPublicApi.ts
@@ -106,6 +106,8 @@ export const FROZEN_PUBLIC_API = [
'PasskeyErrorCodes',
'PasskeyLoginChallengeParameters',
'PasskeySignupChallengeParameters',
+ 'PasskeyVerificationMethod',
+ 'PasskeyVerificationRetryPayload',
'PasswordRealmParameters',
'PasswordlessChallenge',
'PasswordlessChallengeEmailParameters',
diff --git a/src/core/interfaces/Auth0Client.ts b/src/core/interfaces/Auth0Client.ts
index 3432c40f..38650000 100644
--- a/src/core/interfaces/Auth0Client.ts
+++ b/src/core/interfaces/Auth0Client.ts
@@ -112,6 +112,12 @@ export interface Auth0Client {
* credential manager to create a new passkey. After the credential is created,
* call `getTokenByPasskey` with the auth session and credential response.
*
+ * When the database connection requires identifier
+ * verification, the response lists the identifiers to verify in
+ * `verificationRequired`. Auth0 sends a one-time code to each of them: collect
+ * the codes before creating the passkey and pass them to `getTokenByPasskey`
+ * as `verification`.
+ *
* @param parameters The passkey signup challenge parameters.
* @returns A promise resolving with the challenge response.
*/
@@ -139,6 +145,18 @@ export interface Auth0Client {
* Call this after the platform credential manager returns the passkey
* credential (either from signup or login flow).
*
+ * When completing a signup that requires identifier verification, pass the
+ * one-time codes as `verification` and the identifiers they are for as
+ * `verificationRequired` (checked by the iOS and Android SDKs, ignored on
+ * web). Both are optional: omit them when the challenge has no
+ * `verificationRequired`. If a code is wrong, this rejects with a
+ * `PasskeyError` of type `PASSKEY_VERIFICATION_RETRYABLE`. Read
+ * `error.getVerificationRetryPayload()` for `authSession` (the session to
+ * retry with) and `verificationRequired` (the identifiers whose code was
+ * wrong), then call this again with the same `authResponse`, those
+ * identifiers as `verificationRequired` and fresh codes for them as
+ * `verification`.
+ *
* @param parameters The exchange parameters including auth session and credential response.
* @returns A promise resolving with Auth0 credentials.
*/
diff --git a/src/core/models/PasskeyError.ts b/src/core/models/PasskeyError.ts
index 71340fda..12e7ebb0 100644
--- a/src/core/models/PasskeyError.ts
+++ b/src/core/models/PasskeyError.ts
@@ -1,5 +1,7 @@
import { AuthError } from './AuthError';
import type { MfaRequiredErrorPayload } from '../../types/common';
+import { toVerificationMethods } from '../utils/passkeyVerification';
+import type { PasskeyVerificationRetryPayload } from '../../types/parameters';
/**
* Platform-agnostic error code constants for Passkey operations.
@@ -60,6 +62,14 @@ export const PasskeyErrorCodes = {
* continue with the `mfa` client (`mfa.challenge()` / `mfa.verify()`).
*/
MFA_REQUIRED: 'PASSKEY_MFA_REQUIRED',
+ /**
+ * A one-time code sent for passkey signup identifier verification was wrong
+ * while the session is still alive, so the signup can be retried.
+ * {@link PasskeyError.getVerificationRetryPayload} returns `authSession` (the
+ * session to retry with) and `verificationRequired` (the identifiers to ask
+ * for again). Call `getTokenByPasskey` again with the same credential.
+ */
+ VERIFICATION_RETRYABLE: 'PASSKEY_VERIFICATION_RETRYABLE',
/** Unknown or uncategorized passkey error */
UNKNOWN_ERROR: 'PASSKEY_UNKNOWN_ERROR',
} as const;
@@ -83,6 +93,7 @@ const ERROR_CODE_MAP: Record = {
PASSKEY_NOT_AVAILABLE: PasskeyErrorCodes.NOT_AVAILABLE,
PASSKEY_CHALLENGE_FAILED: PasskeyErrorCodes.CHALLENGE_FAILED,
PASSKEY_EXCHANGE_FAILED: PasskeyErrorCodes.EXCHANGE_FAILED,
+ PASSKEY_VERIFICATION_RETRYABLE: PasskeyErrorCodes.VERIFICATION_RETRYABLE,
InvalidParameter: PasskeyErrorCodes.INVALID_PARAMETER,
// --- Web platform (auth0-spa-js PasskeyApiClient / PasskeyClient) ---
@@ -92,6 +103,8 @@ const ERROR_CODE_MAP: Record = {
passkey_challenge_error: PasskeyErrorCodes.CHALLENGE_FAILED,
passkey_get_token_error: PasskeyErrorCodes.EXCHANGE_FAILED,
passkey_invalid_credential: PasskeyErrorCodes.INVALID_CREDENTIAL,
+ // An OTP code in `verification` was empty; spa-js sends nothing.
+ missing_verification_code: PasskeyErrorCodes.INVALID_PARAMETER,
// --- Web platform (auth0-spa-js token endpoint / OAuth2, surfaced via
// GenericError and its subclasses during the token-exchange step —
@@ -215,4 +228,68 @@ export class PasskeyError extends AuthError {
mfaRequirements: json.mfa_requirements,
};
}
+
+ /**
+ * Extracts what to retry with when this error is of type
+ * `PASSKEY_VERIFICATION_RETRYABLE`: a one-time code sent for passkey signup
+ * identifier verification was wrong, and the session is still usable.
+ *
+ * Collect new codes for `verificationRequired` only, then call
+ * `getTokenByPasskey` again with the **same** `authResponse`, the returned
+ * `authSession`, `verificationRequired`, and the new codes as
+ * `verification`. If `verificationRequired` is `undefined`, Auth0 did not say
+ * which identifiers failed: ask again for those of the original challenge.
+ *
+ * @returns The retry payload if this is a `VERIFICATION_RETRYABLE` error,
+ * otherwise `null`.
+ *
+ * @example
+ * ```typescript
+ * let authSession = challenge.authSession;
+ * let verificationRequired = challenge.verificationRequired;
+ * let verification = await promptForCodes(verificationRequired);
+ * for (let attempt = 1; ; attempt++) {
+ * try {
+ * return await auth0.getTokenByPasskey({
+ * authSession,
+ * authResponse: credential, // the same credential on every attempt
+ * verificationRequired,
+ * verification,
+ * });
+ * } catch (error) {
+ * const retry =
+ * error instanceof PasskeyError && error.getVerificationRetryPayload();
+ * // not retryable, or out of attempts: restart the signup
+ * if (!retry || attempt >= 3) throw error;
+ * authSession = retry.authSession ?? authSession;
+ * verificationRequired =
+ * retry.verificationRequired ?? challenge.verificationRequired;
+ * // fresh codes for the identifiers whose code was wrong, only
+ * verification = await promptForCodes(verificationRequired);
+ * }
+ * }
+ * ```
+ */
+ public getVerificationRetryPayload(): PasskeyVerificationRetryPayload | null {
+ if (this.type !== PasskeyErrorCodes.VERIFICATION_RETRYABLE) {
+ return null;
+ }
+
+ // Native: the bridge rejects with `userInfo`, and `NativeBridgeManager`
+ // keeps the whole native error on `json`. Web: `json` is the API error
+ // response of the failed token request (`auth_session`,
+ // `verification_required`) merged with the auth0-spa-js error's own
+ // camelCase fields, so either one is enough.
+ const json = this.json as Record | undefined;
+ const source = (json?.userInfo ?? json) as
+ Record | undefined;
+ const authSession = source?.authSession ?? source?.auth_session;
+ const verificationRequired =
+ source?.verificationRequired ?? source?.verification_required;
+
+ return {
+ authSession: typeof authSession === 'string' ? authSession : undefined,
+ verificationRequired: toVerificationMethods(verificationRequired),
+ };
+ }
}
diff --git a/src/core/models/__tests__/PasskeyError.spec.ts b/src/core/models/__tests__/PasskeyError.spec.ts
index 551cd91f..be374b8b 100644
--- a/src/core/models/__tests__/PasskeyError.spec.ts
+++ b/src/core/models/__tests__/PasskeyError.spec.ts
@@ -79,6 +79,16 @@ describe('PasskeyError', () => {
'PASSKEY_EXCHANGE_FAILED',
'native: token exchange failed',
],
+ [
+ 'PASSKEY_VERIFICATION_RETRYABLE',
+ 'PASSKEY_VERIFICATION_RETRYABLE',
+ 'native: a verification code was wrong and the signup can be retried',
+ ],
+ [
+ 'missing_verification_code',
+ 'PASSKEY_INVALID_PARAMETER',
+ 'spa-js: an OTP code in verification was empty; nothing was sent',
+ ],
[
'InvalidParameter',
'PASSKEY_INVALID_PARAMETER',
@@ -331,6 +341,121 @@ describe('PasskeyError', () => {
expect(payload?.errorDescription).toBe('Fallback message');
});
});
+
+ describe('getVerificationRetryPayload()', () => {
+ // On native the bridge rejects with `userInfo`, which `NativeBridgeManager`
+ // keeps on `json` (the whole native error object).
+ const retryable = (userInfo?: unknown) =>
+ new PasskeyError(
+ new AuthError('PASSKEY_VERIFICATION_RETRYABLE', 'Incorrect code', {
+ code: 'PASSKEY_VERIFICATION_RETRYABLE',
+ json: { code: 'PASSKEY_VERIFICATION_RETRYABLE', userInfo },
+ })
+ );
+
+ it('should return the session and identifiers to retry with', () => {
+ const error = retryable({
+ authSession: 'session-2',
+ verificationRequired: ['email', 'phone'],
+ });
+
+ expect(error.type).toBe(PasskeyErrorCodes.VERIFICATION_RETRYABLE);
+ expect(error.getVerificationRetryPayload()).toEqual({
+ authSession: 'session-2',
+ verificationRequired: ['email', 'phone'],
+ });
+ });
+
+ it('should leave out what the native error does not carry', () => {
+ // Auth0 does not always list the identifiers to ask again, and the bridge
+ // forwards only what it received: the caller falls back to the original
+ // challenge's session and identifiers.
+ expect(retryable({}).getVerificationRetryPayload()).toEqual({
+ authSession: undefined,
+ verificationRequired: undefined,
+ });
+ expect(retryable(undefined).getVerificationRetryPayload()).toEqual({
+ authSession: undefined,
+ verificationRequired: undefined,
+ });
+ });
+
+ it('should ignore values of the wrong shape or an unknown identifier', () => {
+ const error = retryable({
+ authSession: 42,
+ verificationRequired: ['email', 'username', 7],
+ });
+
+ expect(error.getVerificationRetryPayload()).toEqual({
+ authSession: undefined,
+ verificationRequired: ['email'],
+ });
+ });
+
+ it('should return undefined verificationRequired when no identifier is recognizable, so the caller falls back to the original list', () => {
+ const error = retryable({
+ authSession: 'session-2',
+ verificationRequired: ['username'],
+ });
+
+ expect(error.getVerificationRetryPayload()).toEqual({
+ authSession: 'session-2',
+ verificationRequired: undefined,
+ });
+ });
+
+ it('should return null for any other passkey error', () => {
+ const error = new PasskeyError(
+ new AuthError('PASSKEY_EXCHANGE_FAILED', 'Exchange failed', {
+ code: 'PASSKEY_EXCHANGE_FAILED',
+ json: { userInfo: { authSession: 'session-2' } },
+ })
+ );
+
+ expect(error.getVerificationRetryPayload()).toBeNull();
+ });
+
+ describe('on web', () => {
+ // `WebAuth0Client` puts the `cause` of the auth0-spa-js PasskeyGetTokenError
+ // (the API error response) on `json`, merged with the error's own camelCase
+ // retry fields.
+ const webRetryable = (json: unknown) =>
+ new PasskeyError(
+ new AuthError(
+ 'PASSKEY_VERIFICATION_RETRYABLE',
+ 'Invalid or expired session',
+ { code: 'PASSKEY_VERIFICATION_RETRYABLE', json }
+ )
+ );
+
+ it('should read the wire-format fields of the API error response', () => {
+ const error = webRetryable({
+ error: 'invalid_grant',
+ error_description: 'Invalid or expired session',
+ auth_session: 'session-2',
+ verification_required: ['phone'],
+ });
+
+ expect(error.type).toBe(PasskeyErrorCodes.VERIFICATION_RETRYABLE);
+ expect(error.getVerificationRetryPayload()).toEqual({
+ authSession: 'session-2',
+ verificationRequired: ['phone'],
+ });
+ });
+
+ it('should read the camelCase fields of the spa-js error itself', () => {
+ const error = webRetryable({
+ authSession: 'session-3',
+ verificationRequired: ['email', 'phone'],
+ });
+
+ expect(error.getVerificationRetryPayload()).toEqual({
+ authSession: 'session-3',
+ verificationRequired: ['email', 'phone'],
+ });
+ });
+ });
+ });
});
describe('PasskeyErrorCodes', () => {
@@ -348,6 +473,9 @@ describe('PasskeyErrorCodes', () => {
'PASSKEY_INVALID_PARAMETER'
);
expect(PasskeyErrorCodes.MFA_REQUIRED).toBe('PASSKEY_MFA_REQUIRED');
+ expect(PasskeyErrorCodes.VERIFICATION_RETRYABLE).toBe(
+ 'PASSKEY_VERIFICATION_RETRYABLE'
+ );
expect(PasskeyErrorCodes.UNKNOWN_ERROR).toBe('PASSKEY_UNKNOWN_ERROR');
});
});
diff --git a/src/core/utils/__tests__/passkeyVerification.spec.ts b/src/core/utils/__tests__/passkeyVerification.spec.ts
new file mode 100644
index 00000000..dd53bc71
--- /dev/null
+++ b/src/core/utils/__tests__/passkeyVerification.spec.ts
@@ -0,0 +1,91 @@
+import {
+ toVerificationCodes,
+ toVerificationMethods,
+} from '../passkeyVerification';
+import { AuthError } from '../../models/AuthError';
+
+describe('toVerificationMethods', () => {
+ it('keeps the identifiers the SDK knows about, in order', () => {
+ expect(toVerificationMethods(['phone', 'email'])).toEqual([
+ 'phone',
+ 'email',
+ ]);
+ });
+
+ it('drops unknown identifiers and values that are not strings', () => {
+ expect(toVerificationMethods(['email', 'username', 7, null])).toEqual([
+ 'email',
+ ]);
+ });
+
+ it('returns undefined when nothing recognizable is left, so a caller can fall back to the original list', () => {
+ expect(toVerificationMethods(['username'])).toBeUndefined();
+ expect(toVerificationMethods([])).toBeUndefined();
+ });
+
+ it('returns undefined for anything that is not an array', () => {
+ expect(toVerificationMethods(undefined)).toBeUndefined();
+ expect(toVerificationMethods('email')).toBeUndefined();
+ expect(toVerificationMethods({ 0: 'email' })).toBeUndefined();
+ });
+});
+
+describe('toVerificationCodes', () => {
+ it('keeps string codes for the identifiers the SDK knows about', () => {
+ expect(toVerificationCodes({ email: '123456', phone: '654321' })).toEqual({
+ email: '123456',
+ phone: '654321',
+ });
+ });
+
+ it('drops codes that are not strings', () => {
+ expect(toVerificationCodes({ email: '123456', phone: undefined })).toEqual({
+ email: '123456',
+ });
+ expect(toVerificationCodes({ email: 123456 })).toBeUndefined();
+ });
+
+ it('drops identifiers the SDK does not know about', () => {
+ expect(
+ toVerificationCodes({ email: '123456', username: '111111' })
+ ).toEqual({
+ email: '123456',
+ });
+ expect(toVerificationCodes({ username: '111111' })).toBeUndefined();
+ });
+
+ it('throws an InvalidParameter AuthError for an empty or whitespace-only code', () => {
+ for (const verification of [
+ { email: '' },
+ { phone: ' ' },
+ { email: '123456', phone: '\t\n' },
+ ]) {
+ let error: unknown;
+ try {
+ toVerificationCodes(verification);
+ } catch (e) {
+ error = e;
+ }
+ expect(error).toBeInstanceOf(AuthError);
+ expect((error as AuthError).code).toBe('InvalidParameter');
+ }
+ });
+
+ it('treats a code that Swift or Kotlin sees as blank as empty', () => {
+ // U+0085 is blank for Swift, U+001C-U+001F for Kotlin, but not for trim().
+ for (const code of ['\u0085', '\u001c', '\u001f', ' \u00a0\u3000']) {
+ expect(() => toVerificationCodes({ email: code })).toThrow(AuthError);
+ }
+ });
+
+ it('keeps a code with whitespace around it as is', () => {
+ expect(toVerificationCodes({ email: ' 123456 ' })).toEqual({
+ email: ' 123456 ',
+ });
+ });
+
+ it('returns undefined when there is nothing to send', () => {
+ expect(toVerificationCodes(undefined)).toBeUndefined();
+ expect(toVerificationCodes({})).toBeUndefined();
+ });
+});
diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts
index 8db3034e..2af188be 100644
--- a/src/core/utils/index.ts
+++ b/src/core/utils/index.ts
@@ -4,3 +4,4 @@ export * from './conversion';
export * from './fetchWithTimeout';
export * from './parseIdToken';
export * from './scope';
+export * from './passkeyVerification';
diff --git a/src/core/utils/passkeyVerification.ts b/src/core/utils/passkeyVerification.ts
new file mode 100644
index 00000000..b833366d
--- /dev/null
+++ b/src/core/utils/passkeyVerification.ts
@@ -0,0 +1,62 @@
+import { AuthError } from '../models/AuthError';
+import type { PasskeyVerificationMethod } from '../../types/parameters';
+
+const isKnownMethod = (value: unknown): value is PasskeyVerificationMethod =>
+ value === 'email' || value === 'phone';
+
+/**
+ * Keeps the identifiers this SDK knows about (`email`, `phone`) from a list that
+ * came from a platform library, in order.
+ *
+ * Returns `undefined` when nothing recognizable is left, so a caller can fall
+ * back to the identifiers of the original challenge. The web adapter (challenge
+ * and retry payload) and the retry payload of `PasskeyError` go through here;
+ * on native, the Swift/Kotlin bridge filters the identifiers of a challenge
+ * before they reach JS.
+ */
+export const toVerificationMethods = (
+ value: unknown
+): PasskeyVerificationMethod[] | undefined => {
+ if (!Array.isArray(value)) {
+ return undefined;
+ }
+ const known = value.filter(isKnownMethod);
+ return known.length > 0 ? known : undefined;
+};
+
+// Blank as JavaScript, Swift (`whitespacesAndNewlines`, which adds U+0085) and
+// Kotlin (`isBlank`, which adds U+001C-U+001F) see it, so that a blank code is
+// always rejected here, never by a native SDK with a different error.
+const isBlank = (code: string): boolean =>
+ /^[\s\u0085\u001c-\u001f]*$/.test(code);
+
+/**
+ * Keeps the string codes for the identifiers this SDK knows about from a
+ * `verification` map, and nothing else. Returns `undefined` when there is
+ * nothing to send.
+ *
+ * @throws {AuthError} With code `InvalidParameter` when a code is empty or only
+ * whitespace. The native bridges would send it to Auth0 (which fails the
+ * exchange and loses the session), so it is rejected here, before any
+ * platform call, to behave the same everywhere. Callers wrap it in a
+ * `PasskeyError`, whose type is then `PASSKEY_INVALID_PARAMETER`.
+ */
+export const toVerificationCodes = (
+ verification?: Record
+): Partial> | undefined => {
+ const codes: Partial> = {};
+ for (const method of ['email', 'phone'] as const) {
+ const code = verification?.[method];
+ if (typeof code === 'string') {
+ if (isBlank(code)) {
+ throw new AuthError(
+ 'InvalidParameter',
+ `The verification code for '${method}' must not be empty.`,
+ { code: 'InvalidParameter' }
+ );
+ }
+ codes[method] = code;
+ }
+ }
+ return Object.keys(codes).length > 0 ? codes : undefined;
+};
diff --git a/src/hooks/__tests__/Auth0Provider.spec.tsx b/src/hooks/__tests__/Auth0Provider.spec.tsx
index a5bf0b33..c82e2658 100644
--- a/src/hooks/__tests__/Auth0Provider.spec.tsx
+++ b/src/hooks/__tests__/Auth0Provider.spec.tsx
@@ -8,6 +8,7 @@ import {
} from '@testing-library/react';
import '@testing-library/jest-dom';
import { Auth0Provider, useAuth0 } from '..';
+import { AuthError, PasskeyError } from '../../core/models';
import Auth0 from '../../Auth0';
// Mock TurboModuleRegistry first
@@ -1510,6 +1511,140 @@ describe('Auth0Provider', () => {
});
});
+ describe('getTokenByPasskey identifier verification retry', () => {
+ const mockCredentials = {
+ idToken: 'a.b.c',
+ accessToken: 'access-token-123',
+ tokenType: 'Bearer',
+ expiresAt: Date.now() / 1000 + 3600,
+ };
+
+ // A wrong code as the native bridge reports it: the retry details sit in `userInfo`.
+ const retryableError = () =>
+ new PasskeyError(
+ new AuthError(
+ 'PASSKEY_VERIFICATION_RETRYABLE',
+ 'Invalid or expired session',
+ {
+ code: 'PASSKEY_VERIFICATION_RETRYABLE',
+ json: {
+ userInfo: {
+ authSession: 'session-2',
+ verificationRequired: ['email'],
+ },
+ },
+ }
+ )
+ );
+
+ // What the caller of getTokenByPasskey() actually receives, captured outside React.
+ let outcomes: { thrown?: unknown; returned?: unknown }[];
+
+ const Consumer = () => {
+ const { getTokenByPasskey, user, error, isLoading } = useAuth0();
+
+ const verify = async () => {
+ const outcome: { thrown?: unknown; returned?: unknown } = {};
+ outcomes.push(outcome);
+ try {
+ outcome.returned = await getTokenByPasskey({
+ authSession: 'session-1',
+ authResponse: '{}',
+ verification: { email: '000000' },
+ });
+ } catch (e) {
+ outcome.thrown = e;
+ }
+ };
+
+ if (isLoading) return Loading...;
+
+ return (
+
+
+ {user ? `Logged in as: ${user.name}` : 'Not logged in'}
+
+
+ {error ? `Error: ${error.message}` : 'No error'}
+
+
+
+ );
+ };
+
+ const renderConsumer = async () => {
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+ await waitFor(() =>
+ expect(screen.getByTestId('user-status')).toHaveTextContent(
+ 'Not logged in'
+ )
+ );
+ };
+
+ const clickVerify = async () => {
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('verify-button'));
+ });
+ };
+
+ beforeEach(() => {
+ outcomes = [];
+ });
+
+ it('rethrows the same retryable PasskeyError with its payload intact, and records it in state', async () => {
+ const error = retryableError();
+ mockClientInstance.getTokenByPasskey = jest
+ .fn()
+ .mockRejectedValueOnce(error);
+
+ await renderConsumer();
+ await clickVerify();
+
+ await waitFor(() =>
+ expect(screen.getByTestId('error-status')).toHaveTextContent(
+ 'Error: Invalid or expired session'
+ )
+ );
+ expect(outcomes[0].thrown).toBe(error);
+ expect(
+ (outcomes[0].thrown as PasskeyError).getVerificationRetryPayload()
+ ).toEqual({ authSession: 'session-2', verificationRequired: ['email'] });
+ expect(screen.getByTestId('user-status')).toHaveTextContent(
+ 'Not logged in'
+ );
+ });
+
+ it('clears the error and signs the user in when the retry succeeds', async () => {
+ mockClientInstance.getTokenByPasskey = jest
+ .fn()
+ .mockRejectedValueOnce(retryableError())
+ .mockResolvedValueOnce(mockCredentials);
+
+ await renderConsumer();
+ await clickVerify();
+ await waitFor(() =>
+ expect(screen.getByTestId('error-status')).toHaveTextContent('Error:')
+ );
+
+ await clickVerify();
+
+ await waitFor(() =>
+ expect(screen.getByTestId('user-status')).toHaveTextContent(
+ 'Logged in as: Test User'
+ )
+ );
+ expect(screen.getByTestId('error-status')).toHaveTextContent('No error');
+ expect(outcomes[1].returned).toEqual(mockCredentials);
+ expect(mockClientInstance.getTokenByPasskey).toHaveBeenCalledTimes(2);
+ });
+ });
+
// Web Platform Method Tests
describe('Web Platform Methods', () => {
it('should verify webAuth methods exist and are callable', () => {
diff --git a/src/platforms/native/adapters/NativeAuth0Client.ts b/src/platforms/native/adapters/NativeAuth0Client.ts
index e0947e39..a9a0c8a5 100644
--- a/src/platforms/native/adapters/NativeAuth0Client.ts
+++ b/src/platforms/native/adapters/NativeAuth0Client.ts
@@ -27,6 +27,8 @@ import { TokenType } from '../../../types/common';
import { AuthError, DPoPError, PasskeyError } from '../../../core/models';
import {
getConfigSignature,
+ toVerificationCodes,
+ toVerificationMethods,
validateActorTokenParameters,
validateTokenTypeUri,
} from '../../../core/utils';
@@ -241,6 +243,7 @@ export class NativeAuth0Client implements Auth0Client {
userMetadata,
realm,
organization,
+ deliveryMethod,
} = parameters;
try {
return await this.guardedBridge.passkeySignupChallenge(
@@ -254,7 +257,8 @@ export class NativeAuth0Client implements Auth0Client {
picture || undefined,
userMetadata || undefined,
realm || undefined,
- organization || undefined
+ organization || undefined,
+ deliveryMethod || undefined
);
} catch (e) {
if (e instanceof AuthError) {
@@ -284,8 +288,16 @@ export class NativeAuth0Client implements Auth0Client {
async getTokenByPasskey(
parameters: GetTokenByPasskeyParameters
): Promise {
- const { authSession, authResponse, realm, audience, scope, organization } =
- parameters;
+ const {
+ authSession,
+ authResponse,
+ realm,
+ audience,
+ scope,
+ organization,
+ verification,
+ verificationRequired,
+ } = parameters;
if (typeof authResponse !== 'string') {
throw new PasskeyError(
new AuthError(
@@ -302,7 +314,10 @@ export class NativeAuth0Client implements Auth0Client {
realm || undefined,
audience || undefined,
scope || undefined,
- organization || undefined
+ organization || undefined,
+ toVerificationCodes(verification),
+ // Auth0.swift and Auth0.Android check the codes against this list.
+ toVerificationMethods(verificationRequired)
);
} catch (e) {
if (e instanceof AuthError) {
diff --git a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts
index 762ff8f1..278aa55c 100644
--- a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts
+++ b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts
@@ -412,6 +412,7 @@ describe('NativeAuth0Client', () => {
userMetadata: { signup_source: 'mobile_app' },
realm: 'Username-Password-Authentication',
organization: 'org_123',
+ deliveryMethod: 'voice',
});
expect(
@@ -427,7 +428,8 @@ describe('NativeAuth0Client', () => {
'https://example.com/photo.png',
{ signup_source: 'mobile_app' },
'Username-Password-Authentication',
- 'org_123'
+ 'org_123',
+ 'voice'
);
});
@@ -456,10 +458,32 @@ describe('NativeAuth0Client', () => {
undefined,
undefined,
'Username-Password-Authentication',
+ undefined,
undefined
);
});
+ it('should return the identifiers to verify when the connection requires verification', async () => {
+ const withVerification = {
+ ...mockChallengeResponse,
+ verificationRequired: ['email', 'phone'],
+ };
+ (mockBridgeInstance as any).passkeySignupChallenge = jest
+ .fn()
+ .mockResolvedValue(withVerification);
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+
+ const result = await client.passkeySignupChallenge({
+ email: 'user@example.com',
+ phoneNumber: '+1234567890',
+ realm: 'Username-Password-Authentication',
+ deliveryMethod: 'text',
+ });
+
+ expect(result.verificationRequired).toEqual(['email', 'phone']);
+ });
+
it('should return challenge response from passkeySignupChallenge', async () => {
const client = new NativeAuth0Client(options);
await new Promise(process.nextTick);
@@ -597,7 +621,9 @@ describe('NativeAuth0Client', () => {
'Username-Password-Authentication',
'https://api.example.com',
'openid profile email',
- 'org_123'
+ 'org_123',
+ undefined,
+ undefined
);
});
@@ -618,10 +644,178 @@ describe('NativeAuth0Client', () => {
undefined,
undefined,
undefined,
+ undefined,
+ undefined,
undefined
);
});
+ it('should pass the verification codes and the identifiers they cover to the bridge', async () => {
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+
+ await client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: '{"id":"cred-id","type":"public-key","response":{}}',
+ verificationRequired: ['email', 'phone'],
+ verification: { email: '123456', phone: '654321' },
+ });
+
+ expect(
+ (mockBridgeInstance as any).getTokenByPasskey
+ ).toHaveBeenCalledWith(
+ 'auth-session-123',
+ '{"id":"cred-id","type":"public-key","response":{}}',
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ { email: '123456', phone: '654321' },
+ ['email', 'phone']
+ );
+ });
+
+ it('should retry with only the flagged identifiers and their fresh codes', async () => {
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+
+ await client.getTokenByPasskey({
+ authSession: 'session-2',
+ authResponse: '{"id":"cred-id","type":"public-key","response":{}}',
+ verificationRequired: ['phone'],
+ verification: { phone: '222222' },
+ });
+
+ const args = (mockBridgeInstance as any).getTokenByPasskey.mock.calls.at(
+ -1
+ );
+ expect(args[0]).toBe('session-2');
+ expect(args[6]).toEqual({ phone: '222222' });
+ expect(args[7]).toEqual(['phone']);
+ });
+
+ it('should not pass codes or identifiers the SDK does not know about', async () => {
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+
+ await client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: '{"id":"cred-id","type":"public-key","response":{}}',
+ verificationRequired: ['email', 'username'],
+ verification: { email: '123456', username: '111111' },
+ });
+
+ const args = (mockBridgeInstance as any).getTokenByPasskey.mock.calls.at(
+ -1
+ );
+ expect(args[6]).toEqual({ email: '123456' });
+ expect(args[7]).toEqual(['email']);
+ });
+
+ it('should pass the codes without verificationRequired, as before', async () => {
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+
+ await client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: '{"id":"cred-id","type":"public-key","response":{}}',
+ verification: { email: '123456' },
+ });
+
+ const args = (mockBridgeInstance as any).getTokenByPasskey.mock.calls.at(
+ -1
+ );
+ expect(args[6]).toEqual({ email: '123456' });
+ expect(args[7]).toBeUndefined();
+ });
+
+ it('should pass verificationRequired without codes, leaving the check to the native SDK', async () => {
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+
+ await client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: '{"id":"cred-id","type":"public-key","response":{}}',
+ verificationRequired: ['email'],
+ });
+
+ const args = (mockBridgeInstance as any).getTokenByPasskey.mock.calls.at(
+ -1
+ );
+ expect(args[6]).toBeUndefined();
+ expect(args[7]).toEqual(['email']);
+ });
+
+ it('should not pass an empty verificationRequired, nor one with only unknown identifiers', async () => {
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+
+ for (const verificationRequired of [[], ['username']]) {
+ await client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: '{"id":"cred-id","type":"public-key","response":{}}',
+ verificationRequired,
+ });
+
+ expect(
+ (mockBridgeInstance as any).getTokenByPasskey.mock.calls.at(-1)[7]
+ ).toBeUndefined();
+ }
+ });
+
+ it('should not pass codes that are not strings, nor an empty map', async () => {
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+ const call = (verification: any) =>
+ client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: '{"id":"cred-id","type":"public-key","response":{}}',
+ verification,
+ });
+ const lastVerificationArg = () =>
+ (mockBridgeInstance as any).getTokenByPasskey.mock.calls.at(-1)[6];
+
+ await call({ email: '123456', phone: undefined });
+ expect(lastVerificationArg()).toEqual({ email: '123456' });
+
+ await call({ email: undefined });
+ expect(lastVerificationArg()).toBeUndefined();
+
+ await call({});
+ expect(lastVerificationArg()).toBeUndefined();
+ });
+
+ it('should reject an empty or whitespace-only code with PASSKEY_INVALID_PARAMETER without calling the bridge', async () => {
+ const {
+ PasskeyError,
+ PasskeyErrorCodes,
+ } = require('../../../../core/models');
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+ const bridge = (mockBridgeInstance as any).getTokenByPasskey;
+ bridge.mockClear();
+
+ const verifications: Record[] = [
+ { email: '' },
+ { phone: ' ' },
+ ];
+ for (const verification of verifications) {
+ const error = await client
+ .getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: '{"id":"cred-id","type":"public-key","response":{}}',
+ verification,
+ })
+ .catch((e: unknown) => e);
+
+ expect(error).toBeInstanceOf(PasskeyError);
+ expect((error as PasskeyError).type).toBe(
+ PasskeyErrorCodes.INVALID_PARAMETER
+ );
+ }
+ expect(bridge).not.toHaveBeenCalled();
+ });
+
it('should return credentials from getTokenByPasskey', async () => {
const client = new NativeAuth0Client(options);
await new Promise(process.nextTick);
@@ -655,6 +849,45 @@ describe('NativeAuth0Client', () => {
).rejects.toBeInstanceOf(PasskeyError);
});
+ it('should throw a retryable PasskeyError carrying the retry payload when a verification code is wrong', async () => {
+ const {
+ AuthError,
+ PasskeyError,
+ PasskeyErrorCodes,
+ } = require('../../../../core/models');
+
+ mockBridgeInstance.getTokenByPasskey = jest.fn().mockRejectedValue(
+ new AuthError('PASSKEY_VERIFICATION_RETRYABLE', 'Incorrect code', {
+ code: 'PASSKEY_VERIFICATION_RETRYABLE',
+ json: {
+ userInfo: {
+ authSession: 'session-2',
+ verificationRequired: ['email'],
+ },
+ },
+ })
+ );
+
+ const client = new NativeAuth0Client(options);
+ await new Promise(process.nextTick);
+
+ const error = await client
+ .getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: '{"id":"cred-id","type":"public-key","response":{}}',
+ verificationRequired: ['email'],
+ verification: { email: '000000' },
+ })
+ .catch((e: unknown) => e);
+
+ expect(error).toBeInstanceOf(PasskeyError);
+ expect(error.type).toBe(PasskeyErrorCodes.VERIFICATION_RETRYABLE);
+ expect(error.getVerificationRetryPayload()).toEqual({
+ authSession: 'session-2',
+ verificationRequired: ['email'],
+ });
+ });
+
it('should reject with PasskeyError and not call the bridge when authResponse is not a string', async () => {
const {
PasskeyError,
diff --git a/src/platforms/native/bridge/NativeBridge.ts b/src/platforms/native/bridge/NativeBridge.ts
index a69ee2d6..29ddf1f0 100644
--- a/src/platforms/native/bridge/NativeBridge.ts
+++ b/src/platforms/native/bridge/NativeBridge.ts
@@ -308,6 +308,7 @@ export interface NativeBridge {
* @param userMetadata Additional user metadata as key-value pairs.
* @param realm The database connection name.
* @param organization Optional organization ID or name.
+ * @param deliveryMethod How to deliver the one-time code for phone number verification: `'text'` or `'voice'`.
* @returns A promise that resolves with the challenge response.
*/
passkeySignupChallenge(
@@ -321,7 +322,8 @@ export interface NativeBridge {
picture?: string,
userMetadata?: Record,
realm?: string,
- organization?: string
+ organization?: string,
+ deliveryMethod?: string
): Promise;
/**
@@ -347,6 +349,8 @@ export interface NativeBridge {
* @param audience Optional target API identifier.
* @param scope Optional space-separated scopes.
* @param organization Optional organization ID or name.
+ * @param verification Optional one-time codes keyed by the identifier they were sent to (`email`, `phone`).
+ * @param verificationRequired Optional identifiers that must have a code in `verification`; the native SDK checks this before sending.
* @returns A promise that resolves with Auth0 credentials.
*/
getTokenByPasskey(
@@ -355,7 +359,9 @@ export interface NativeBridge {
realm?: string,
audience?: string,
scope?: string,
- organization?: string
+ organization?: string,
+ verification?: Record,
+ verificationRequired?: string[]
): Promise;
/**
diff --git a/src/platforms/native/bridge/NativeBridgeManager.ts b/src/platforms/native/bridge/NativeBridgeManager.ts
index 3ee45894..13b7b1d8 100644
--- a/src/platforms/native/bridge/NativeBridgeManager.ts
+++ b/src/platforms/native/bridge/NativeBridgeManager.ts
@@ -352,9 +352,10 @@ export class NativeBridgeManager implements NativeBridge {
picture?: string,
userMetadata?: Record,
realm?: string,
- organization?: string
+ organization?: string,
+ deliveryMethod?: string
): Promise {
- return this.a0_call(
+ const challenge = await this.a0_call(
Auth0NativeModule.passkeySignupChallenge.bind(Auth0NativeModule),
email,
phoneNumber,
@@ -366,8 +367,12 @@ export class NativeBridgeManager implements NativeBridge {
picture,
userMetadata,
realm,
- organization
+ organization,
+ deliveryMethod
);
+ // The TurboModule spec can only type `verificationRequired` as `string[]`;
+ // the native modules only ever report the identifiers they recognize.
+ return challenge as PasskeyChallengeResponse;
}
async passkeyLoginChallenge(
@@ -387,7 +392,9 @@ export class NativeBridgeManager implements NativeBridge {
realm?: string,
audience?: string,
scope?: string,
- organization?: string
+ organization?: string,
+ verification?: Record,
+ verificationRequired?: string[]
): Promise {
const credential = await this.a0_call(
Auth0NativeModule.getTokenByPasskey.bind(Auth0NativeModule),
@@ -396,7 +403,9 @@ export class NativeBridgeManager implements NativeBridge {
realm,
audience,
scope,
- organization
+ organization,
+ verification,
+ verificationRequired
);
return new CredentialsModel(credential);
}
diff --git a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts
index f50dd311..fbc2999d 100644
--- a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts
+++ b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts
@@ -26,6 +26,8 @@ jest.mock('../../../../specs/NativeA0Auth0', () => ({
passwordlessChallengeWithEmail: jest.fn(),
passwordlessChallengeWithPhoneNumber: jest.fn(),
passwordlessLoginWithOTP: jest.fn(),
+ passkeySignupChallenge: jest.fn(),
+ getTokenByPasskey: jest.fn(),
}));
const MockedAuth0NativeModule = Auth0NativeModule as jest.Mocked<
typeof Auth0NativeModule
@@ -788,4 +790,112 @@ describe('NativeBridgeManager', () => {
).rejects.toThrow(AuthError);
});
});
+
+ describe('passkeySignupChallenge', () => {
+ it('forwards all parameters, including the delivery method, and returns the identifiers to verify', async () => {
+ const challenge = {
+ authSession: 'session_signup',
+ authParamsPublicKey: { challenge: 'Y2hhbGxlbmdl' },
+ verificationRequired: ['email', 'phone'],
+ };
+ MockedAuth0NativeModule.passkeySignupChallenge.mockResolvedValueOnce(
+ challenge as any
+ );
+
+ const result = await bridge.passkeySignupChallenge(
+ 'user@example.com',
+ '+15555550123',
+ 'johndoe',
+ 'John Doe',
+ 'John',
+ 'Doe',
+ 'johnny',
+ 'https://example.com/photo.png',
+ { signup_source: 'mobile_app' },
+ 'Username-Password-Authentication',
+ 'org_123',
+ 'voice'
+ );
+
+ expect(
+ MockedAuth0NativeModule.passkeySignupChallenge
+ ).toHaveBeenCalledWith(
+ 'user@example.com',
+ '+15555550123',
+ 'johndoe',
+ 'John Doe',
+ 'John',
+ 'Doe',
+ 'johnny',
+ 'https://example.com/photo.png',
+ { signup_source: 'mobile_app' },
+ 'Username-Password-Authentication',
+ 'org_123',
+ 'voice'
+ );
+ expect(result).toEqual(challenge);
+ });
+
+ it('wraps a native error in an AuthError', async () => {
+ MockedAuth0NativeModule.passkeySignupChallenge.mockRejectedValue({
+ code: 'PASSKEY_CHALLENGE_FAILED',
+ message: 'boom',
+ });
+
+ await expect(
+ bridge.passkeySignupChallenge('user@example.com')
+ ).rejects.toThrow(AuthError);
+ });
+ });
+
+ describe('getTokenByPasskey', () => {
+ it('forwards all parameters, including the verification codes and the identifiers they cover, and transforms the response into a Credentials model', async () => {
+ MockedAuth0NativeModule.getTokenByPasskey.mockResolvedValueOnce(
+ nativeSuccessCredentials as any
+ );
+
+ const credentials = await bridge.getTokenByPasskey(
+ 'session_signup',
+ '{"id":"cred-id"}',
+ 'Username-Password-Authentication',
+ 'https://api.example.com',
+ 'openid profile',
+ 'org_123',
+ { email: '123456', phone: '654321' },
+ ['email', 'phone']
+ );
+
+ expect(MockedAuth0NativeModule.getTokenByPasskey).toHaveBeenCalledWith(
+ 'session_signup',
+ '{"id":"cred-id"}',
+ 'Username-Password-Authentication',
+ 'https://api.example.com',
+ 'openid profile',
+ 'org_123',
+ { email: '123456', phone: '654321' },
+ ['email', 'phone']
+ );
+ expect(credentials).toBeInstanceOf(Credentials);
+ expect(credentials.accessToken).toBe(
+ nativeSuccessCredentials.accessToken
+ );
+ });
+
+ it('keeps the native error, including its userInfo, on the AuthError', async () => {
+ const nativeError = {
+ code: 'PASSKEY_VERIFICATION_RETRYABLE',
+ message: 'Incorrect code',
+ userInfo: { authSession: 'session_2', verificationRequired: ['email'] },
+ };
+ MockedAuth0NativeModule.getTokenByPasskey.mockRejectedValue(nativeError);
+
+ const error = await bridge
+ .getTokenByPasskey('session_signup', '{"id":"cred-id"}')
+ .catch((e: unknown) => e as AuthError);
+
+ expect(error).toBeInstanceOf(AuthError);
+ expect(error.code).toBe('PASSKEY_VERIFICATION_RETRYABLE');
+ expect(error.json).toBe(nativeError);
+ });
+ });
});
diff --git a/src/platforms/web/adapters/WebAuth0Client.ts b/src/platforms/web/adapters/WebAuth0Client.ts
index 470ea626..0bf3da8c 100644
--- a/src/platforms/web/adapters/WebAuth0Client.ts
+++ b/src/platforms/web/adapters/WebAuth0Client.ts
@@ -29,8 +29,15 @@ import { ssoExchangeNotSupported } from './WebAuthenticationProvider';
import { AuthenticationOrchestrator } from '../../../core/services';
import { HttpClient } from '../../../core/services/HttpClient';
import { TokenType } from '../../../types/common';
-import { AuthError, DPoPError, PasskeyError } from '../../../core/models';
import {
+ AuthError,
+ DPoPError,
+ PasskeyError,
+ PasskeyErrorCodes,
+} from '../../../core/models';
+import {
+ toVerificationCodes,
+ toVerificationMethods,
validateActorTokenParameters,
validateTokenTypeUri,
} from '../../../core/utils';
@@ -256,6 +263,7 @@ export class WebAuth0Client implements Auth0Client {
userMetadata,
realm,
organization,
+ deliveryMethod,
} = parameters;
// auth0-spa-js's PasskeySignupChallengeOptions type requires at
@@ -277,7 +285,11 @@ export class WebAuth0Client implements Auth0Client {
userMetadata,
realm,
organization,
+ deliveryMethod,
} as Parameters[0]);
+ const verificationRequired = toVerificationMethods(
+ challenge.verificationRequired
+ );
return {
authSession: challenge.authSession,
@@ -285,6 +297,8 @@ export class WebAuth0Client implements Auth0Client {
string,
any
>,
+ // Left out when no verification is needed, like on native.
+ ...(verificationRequired && { verificationRequired }),
};
} catch (e: any) {
if (e instanceof PasskeyError) throw e;
@@ -346,6 +360,7 @@ export class WebAuth0Client implements Auth0Client {
audience,
scope,
organization,
+ verification,
} = parameters;
// Apply default scope if not provided, for consistency with native
@@ -372,6 +387,9 @@ export class WebAuth0Client implements Auth0Client {
audience,
scope: finalScope,
organization,
+ // auth0-spa-js rejects an empty code itself, before sending anything.
+ // It takes no `verificationRequired`, so that parameter is ignored here.
+ verification: toVerificationCodes(verification),
});
const expiresAt = Math.floor(Date.now() / 1000) + response.expires_in;
@@ -392,13 +410,30 @@ export class WebAuth0Client implements Auth0Client {
// bare Error from ID token verification — none of these set `.code`,
// only `.error` (OAuth2-style), so check both before falling back to
// a generic code. See PasskeyErrorCodes for the resulting mapping.
- const code = e.code ?? e.error ?? 'passkey_exchange_failed';
+ //
+ // The exception is a wrong identifier-verification code while the session
+ // is still alive: spa-js flags that on a PasskeyGetTokenError
+ // (`isRetryable`). The retry details are on the API error response in its
+ // `cause` (`auth_session`, `verification_required`) and on the error itself
+ // (`authSession`, `verificationRequired`); keep both so either is enough.
+ const code = e.isRetryable
+ ? PasskeyErrorCodes.VERIFICATION_RETRYABLE
+ : (e.code ?? e.error ?? 'passkey_exchange_failed');
const authError = new AuthError(
code,
e.message ??
e.error_description ??
'Failed to exchange passkey credential for tokens',
- { code, json: e.cause ?? e }
+ {
+ code,
+ json: e.isRetryable
+ ? {
+ ...e.cause,
+ authSession: e.authSession,
+ verificationRequired: e.verificationRequired,
+ }
+ : (e.cause ?? e),
+ }
);
throw new PasskeyError(authError);
}
diff --git a/src/platforms/web/adapters/__tests__/WebAuth0Client.passkeyRetry.spec.ts b/src/platforms/web/adapters/__tests__/WebAuth0Client.passkeyRetry.spec.ts
new file mode 100644
index 00000000..17a67436
--- /dev/null
+++ b/src/platforms/web/adapters/__tests__/WebAuth0Client.passkeyRetry.spec.ts
@@ -0,0 +1,224 @@
+import { Auth0Client, PasskeyGetTokenError } from '@auth0/auth0-spa-js';
+import { WebAuth0Client } from '../WebAuth0Client';
+import { WebWebAuthProvider } from '../WebWebAuthProvider';
+import { WebCredentialsManager } from '../WebCredentialsManager';
+import { AuthenticationOrchestrator } from '../../../../core/services';
+import { HttpClient } from '../../../../core/services/HttpClient';
+import { PasskeyError, PasskeyErrorCodes } from '../../../../core/models';
+
+// Unlike WebAuth0Client.spec.ts, this spec does NOT mock core/models, and it throws
+// the real auth0-spa-js error classes. It exercises the adapter's error mapping and
+// the real PasskeyError together, so a change in the shape of a spa-js error shows
+// up here and not only in a user's app.
+jest.mock('@auth0/auth0-spa-js', () => ({
+ ...jest.requireActual('@auth0/auth0-spa-js'),
+ Auth0Client: jest.fn(),
+}));
+jest.mock('../WebWebAuthProvider');
+jest.mock('../WebCredentialsManager');
+jest.mock('../../../../core/services/AuthenticationOrchestrator');
+jest.mock('../../../../core/services/HttpClient');
+
+describe('WebAuth0Client passkey identifier verification (real PasskeyError)', () => {
+ let client: WebAuth0Client;
+ let spaPasskey: {
+ getSignupChallenge: jest.Mock;
+ getTokenWithPasskey: jest.Mock;
+ };
+
+ const credential = { id: 'credential-id' } as any;
+ const exchange = () =>
+ client
+ .getTokenByPasskey({
+ authSession: 'session-1',
+ authResponse: credential,
+ verification: { email: '000000' },
+ })
+ .catch((e: unknown) => e as PasskeyError);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ Object.defineProperty(window, 'location', {
+ value: { origin: 'https://app.example.com' },
+ writable: true,
+ configurable: true,
+ });
+
+ spaPasskey = {
+ getSignupChallenge: jest.fn(),
+ getTokenWithPasskey: jest.fn(),
+ };
+ (Auth0Client as unknown as jest.Mock).mockImplementation(() => ({
+ passkey: spaPasskey,
+ }));
+ (HttpClient as unknown as jest.Mock).mockImplementation(() => ({}));
+ (AuthenticationOrchestrator as unknown as jest.Mock).mockImplementation(
+ () => ({})
+ );
+ (WebWebAuthProvider as unknown as jest.Mock).mockImplementation(() => ({}));
+ (WebCredentialsManager as unknown as jest.Mock).mockImplementation(
+ () => ({})
+ );
+
+ client = new WebAuth0Client({
+ domain: 'test.auth0.com',
+ clientId: 'test-client-id',
+ });
+ });
+
+ it('maps a retryable spa-js PasskeyGetTokenError to a PasskeyError that carries the retry payload', async () => {
+ spaPasskey.getTokenWithPasskey.mockRejectedValue(
+ new PasskeyGetTokenError('Invalid or expired session', {
+ error: 'invalid_grant',
+ error_description: 'Invalid or expired session',
+ auth_session: 'session-2',
+ verification_required: ['email'],
+ })
+ );
+
+ const error = await exchange();
+
+ expect(error).toBeInstanceOf(PasskeyError);
+ expect(error.type).toBe(PasskeyErrorCodes.VERIFICATION_RETRYABLE);
+ expect(error.getVerificationRetryPayload()).toEqual({
+ authSession: 'session-2',
+ verificationRequired: ['email'],
+ });
+ });
+
+ it('reads the retry payload off the spa-js error itself when its cause does not carry it', async () => {
+ const spaError = new PasskeyGetTokenError('Invalid or expired session', {
+ error: 'invalid_grant',
+ error_description: 'Invalid or expired session',
+ auth_session: 'session-2',
+ verification_required: ['phone'],
+ });
+ // A later spa-js version may stop copying the retry fields into `cause`.
+ spaError.cause = {
+ error: 'invalid_grant',
+ error_description: 'Invalid or expired session',
+ };
+ spaPasskey.getTokenWithPasskey.mockRejectedValue(spaError);
+
+ const error = await exchange();
+
+ expect(error.type).toBe(PasskeyErrorCodes.VERIFICATION_RETRYABLE);
+ expect(error.getVerificationRetryPayload()).toEqual({
+ authSession: 'session-2',
+ verificationRequired: ['phone'],
+ });
+ });
+
+ it('retries a wrong one-time code with the flagged identifier only, on the same session and credential', async () => {
+ spaPasskey.getTokenWithPasskey
+ .mockRejectedValueOnce(
+ new PasskeyGetTokenError('Wrong phone number or verification code.', {
+ error: 'invalid_grant',
+ error_description: 'Wrong phone number or verification code.',
+ auth_session: 'session-1',
+ verification_required: ['phone'],
+ })
+ )
+ .mockResolvedValueOnce({
+ access_token: 'access-token',
+ id_token: 'id-token',
+ token_type: 'Bearer',
+ expires_in: 3600,
+ });
+
+ let verificationRequired: string[] | undefined = ['email', 'phone'];
+ const first = await client
+ .getTokenByPasskey({
+ authSession: 'session-1',
+ authResponse: credential,
+ verificationRequired,
+ verification: { email: '111111', phone: '000000' },
+ })
+ .catch((e: unknown) => e as PasskeyError);
+
+ const retry = first.getVerificationRetryPayload();
+ expect(retry).toEqual({
+ authSession: 'session-1',
+ verificationRequired: ['phone'],
+ });
+ verificationRequired = retry!.verificationRequired;
+
+ await client.getTokenByPasskey({
+ authSession: retry!.authSession!,
+ authResponse: credential,
+ verificationRequired,
+ verification: { phone: '222222' },
+ });
+
+ const [firstSent, retrySent] =
+ spaPasskey.getTokenWithPasskey.mock.calls.map(([options]) => options);
+ expect(firstSent.verification).toEqual({
+ email: '111111',
+ phone: '000000',
+ });
+ expect(retrySent).toEqual(
+ expect.objectContaining({
+ authSession: 'session-1',
+ credential,
+ verification: { phone: '222222' },
+ })
+ );
+ // auth0-spa-js takes no verificationRequired: the web adapter drops it.
+ expect(retrySent).not.toHaveProperty('verificationRequired');
+ });
+
+ it('keeps a PasskeyGetTokenError that is not retryable as PASSKEY_EXCHANGE_FAILED, with no payload', async () => {
+ spaPasskey.getTokenWithPasskey.mockRejectedValue(
+ new PasskeyGetTokenError('Access denied', {
+ error: 'access_denied',
+ error_description: 'Access denied',
+ })
+ );
+
+ const error = await exchange();
+
+ expect(error).toBeInstanceOf(PasskeyError);
+ expect(error.type).toBe(PasskeyErrorCodes.EXCHANGE_FAILED);
+ expect(error.getVerificationRetryPayload()).toBeNull();
+ });
+
+ it('rejects an empty or whitespace-only code with PASSKEY_INVALID_PARAMETER before calling spa-js', async () => {
+ const verifications: Record[] = [
+ { email: '' },
+ { phone: ' ' },
+ ];
+ for (const verification of verifications) {
+ const error = await client
+ .getTokenByPasskey({
+ authSession: 'session-1',
+ authResponse: credential,
+ verification,
+ })
+ .catch((e: unknown) => e as PasskeyError);
+
+ expect(error).toBeInstanceOf(PasskeyError);
+ expect(error.type).toBe(PasskeyErrorCodes.INVALID_PARAMETER);
+ expect(error.getVerificationRetryPayload()).toBeNull();
+ }
+ expect(spaPasskey.getTokenWithPasskey).not.toHaveBeenCalled();
+ });
+
+ it('returns the identifiers to verify from the signup challenge, without the ones it does not know', async () => {
+ spaPasskey.getSignupChallenge.mockResolvedValue({
+ authSession: 'session-1',
+ publicKey: { challenge: 'abc' },
+ verificationRequired: ['email', 'phone', 'username'],
+ });
+
+ const challenge = await client.passkeySignupChallenge({
+ email: 'user@example.com',
+ phoneNumber: '+15551234567',
+ deliveryMethod: 'voice',
+ });
+
+ expect(challenge.verificationRequired).toEqual(['email', 'phone']);
+ expect(spaPasskey.getSignupChallenge).toHaveBeenCalledWith(
+ expect.objectContaining({ deliveryMethod: 'voice' })
+ );
+ });
+});
diff --git a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts
index d1c5f819..751c599a 100644
--- a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts
+++ b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts
@@ -63,6 +63,7 @@ jest.mock('../../../../core/models', () => {
// Simplified ERROR_CODE_MAP matching the real PasskeyError
const codeMap: Record = {
InvalidParameter: 'PASSKEY_INVALID_PARAMETER',
+ PASSKEY_VERIFICATION_RETRYABLE: 'PASSKEY_VERIFICATION_RETRYABLE',
passkey_challenge_error: 'PASSKEY_CHALLENGE_FAILED',
passkey_get_token_error: 'PASSKEY_EXCHANGE_FAILED',
passkey_invalid_credential: 'PASSKEY_INVALID_CREDENTIAL',
@@ -89,6 +90,9 @@ jest.mock('../../../../core/models', () => {
AuthError: MockAuthError,
AuthenticationException: MockAuthenticationException,
PasskeyError: MockPasskeyError,
+ PasskeyErrorCodes: {
+ VERIFICATION_RETRYABLE: 'PASSKEY_VERIFICATION_RETRYABLE',
+ },
// Add other exports from models if needed
Credentials: jest.fn(),
Auth0User: jest.fn(),
@@ -608,6 +612,82 @@ describe('WebAuth0Client', () => {
});
});
+ it('should forward deliveryMethod to the SPA client', async () => {
+ mockSpaClient.passkey.getSignupChallenge.mockResolvedValue({
+ authSession: 'auth-session-123',
+ publicKey: mockPublicKey,
+ });
+
+ await client.passkeySignupChallenge({
+ phoneNumber: '+15551234567',
+ deliveryMethod: 'voice',
+ });
+
+ expect(mockSpaClient.passkey.getSignupChallenge).toHaveBeenCalledWith(
+ expect.objectContaining({
+ phoneNumber: '+15551234567',
+ deliveryMethod: 'voice',
+ })
+ );
+ });
+
+ it('should return the identifiers to verify when the connection requires verification', async () => {
+ mockSpaClient.passkey.getSignupChallenge.mockResolvedValue({
+ authSession: 'auth-session-123',
+ publicKey: mockPublicKey,
+ verificationRequired: ['email', 'phone'],
+ });
+
+ const result = await client.passkeySignupChallenge({
+ email: 'user@example.com',
+ phoneNumber: '+15551234567',
+ });
+
+ expect(result).toEqual({
+ authSession: 'auth-session-123',
+ authParamsPublicKey: mockPublicKey,
+ verificationRequired: ['email', 'phone'],
+ });
+ });
+
+ it('should only return the identifiers the SDK knows about', async () => {
+ mockSpaClient.passkey.getSignupChallenge.mockResolvedValue({
+ authSession: 'auth-session-123',
+ publicKey: mockPublicKey,
+ verificationRequired: ['email', 'username'],
+ });
+ const result = await client.passkeySignupChallenge({
+ email: 'user@example.com',
+ });
+ expect(result.verificationRequired).toEqual(['email']);
+
+ mockSpaClient.passkey.getSignupChallenge.mockResolvedValue({
+ authSession: 'auth-session-123',
+ publicKey: mockPublicKey,
+ verificationRequired: ['username'],
+ });
+ const unrecognized = await client.passkeySignupChallenge({
+ email: 'user@example.com',
+ });
+ expect(unrecognized).not.toHaveProperty('verificationRequired');
+ });
+
+ it('should leave verificationRequired out when no verification is needed', async () => {
+ for (const verificationRequired of [undefined, []]) {
+ mockSpaClient.passkey.getSignupChallenge.mockResolvedValue({
+ authSession: 'auth-session-123',
+ publicKey: mockPublicKey,
+ verificationRequired,
+ });
+
+ const result = await client.passkeySignupChallenge({
+ email: 'user@example.com',
+ });
+
+ expect(result).not.toHaveProperty('verificationRequired');
+ }
+ });
+
it('should throw PasskeyError when the challenge request fails', async () => {
mockSpaClient.passkey.getSignupChallenge.mockRejectedValue({
code: 'passkey_register_error',
@@ -1002,6 +1082,152 @@ describe('WebAuth0Client', () => {
expect(mockSpaClient.passkey.getTokenWithPasskey).not.toHaveBeenCalled();
});
+
+ describe('identifier verification', () => {
+ const tokenResponse = {
+ access_token: 'access-token',
+ id_token: 'id-token',
+ token_type: 'Bearer',
+ expires_in: 3600,
+ scope: 'openid profile email',
+ };
+ const credential = { id: 'credential-id' } as any;
+ const lastVerificationSent = () =>
+ mockSpaClient.passkey.getTokenWithPasskey.mock.calls.at(-1)[0]
+ .verification;
+
+ it('should forward the verification codes to the SPA client', async () => {
+ mockSpaClient.passkey.getTokenWithPasskey.mockResolvedValue(
+ tokenResponse
+ );
+
+ await client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: credential,
+ verification: { email: '123456', phone: '654321' },
+ });
+
+ expect(mockSpaClient.passkey.getTokenWithPasskey).toHaveBeenCalledWith(
+ expect.objectContaining({
+ verification: { email: '123456', phone: '654321' },
+ })
+ );
+ });
+
+ it('should ignore verificationRequired and not send it to the SPA client', async () => {
+ mockSpaClient.passkey.getTokenWithPasskey.mockResolvedValue(
+ tokenResponse
+ );
+
+ for (const parameters of [
+ // a listed identifier without a code is not rejected either
+ { verificationRequired: ['email', 'phone'], verification: {} },
+ { verificationRequired: ['email'] },
+ {},
+ ]) {
+ await expect(
+ client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: credential,
+ ...parameters,
+ })
+ ).resolves.toBeDefined();
+
+ const sent =
+ mockSpaClient.passkey.getTokenWithPasskey.mock.calls.at(-1)[0];
+ expect(sent).not.toHaveProperty('verificationRequired');
+ expect(sent.verification).toBeUndefined();
+ }
+ });
+
+ it('should not send codes for identifiers the SDK does not know about', async () => {
+ mockSpaClient.passkey.getTokenWithPasskey.mockResolvedValue(
+ tokenResponse
+ );
+
+ await client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: credential,
+ verification: { email: '123456', username: '111111' },
+ });
+
+ expect(lastVerificationSent()).toEqual({ email: '123456' });
+ });
+
+ it('should not send codes that are not strings, nor an empty map', async () => {
+ mockSpaClient.passkey.getTokenWithPasskey.mockResolvedValue(
+ tokenResponse
+ );
+ const exchange = (verification: any) =>
+ client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: credential,
+ verification,
+ });
+
+ await exchange({ email: '123456', phone: undefined });
+ expect(lastVerificationSent()).toEqual({ email: '123456' });
+
+ await exchange({});
+ expect(lastVerificationSent()).toBeUndefined();
+
+ await exchange(undefined);
+ expect(lastVerificationSent()).toBeUndefined();
+ });
+
+ it('should map a wrong code with a live session to PASSKEY_VERIFICATION_RETRYABLE', async () => {
+ const cause = {
+ error: 'invalid_grant',
+ error_description: 'Invalid or expired session',
+ auth_session: 'session-2',
+ verification_required: ['email'],
+ };
+ // What auth0-spa-js throws (PasskeyGetTokenError): the retry state is on
+ // the error itself, and the API error response is its `cause`.
+ mockSpaClient.passkey.getTokenWithPasskey.mockRejectedValue(
+ Object.assign(new Error('Invalid or expired session'), {
+ code: 'passkey_get_token_error',
+ isRetryable: true,
+ authSession: 'session-2',
+ verificationRequired: ['email'],
+ cause,
+ })
+ );
+
+ await expect(
+ client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: credential,
+ verification: { email: '000000' },
+ })
+ ).rejects.toMatchObject({
+ name: 'PasskeyError',
+ code: 'PASSKEY_VERIFICATION_RETRYABLE',
+ type: 'PASSKEY_VERIFICATION_RETRYABLE',
+ json: cause,
+ });
+ });
+
+ it('should keep PASSKEY_EXCHANGE_FAILED for a token error that is not retryable', async () => {
+ mockSpaClient.passkey.getTokenWithPasskey.mockRejectedValue(
+ Object.assign(new Error('Invalid or expired session'), {
+ code: 'passkey_get_token_error',
+ isRetryable: false,
+ })
+ );
+
+ await expect(
+ client.getTokenByPasskey({
+ authSession: 'auth-session-123',
+ authResponse: credential,
+ verification: { email: '000000' },
+ })
+ ).rejects.toMatchObject({
+ code: 'passkey_get_token_error',
+ type: 'PASSKEY_EXCHANGE_FAILED',
+ });
+ });
+ });
});
describe('ssoExchange', () => {
diff --git a/src/specs/NativeA0Auth0.ts b/src/specs/NativeA0Auth0.ts
index 69968380..e3462f20 100644
--- a/src/specs/NativeA0Auth0.ts
+++ b/src/specs/NativeA0Auth0.ts
@@ -237,8 +237,13 @@ export interface Spec extends TurboModule {
picture: string | undefined,
userMetadata: { [key: string]: string } | undefined,
realm: string | undefined,
- organization: string | undefined
- ): Promise<{ authSession: string; authParamsPublicKey: Object }>;
+ organization: string | undefined,
+ deliveryMethod: string | undefined
+ ): Promise<{
+ authSession: string;
+ authParamsPublicKey: Object;
+ verificationRequired?: string[];
+ }>;
/**
* Request a passkey login challenge from Auth0.
@@ -257,7 +262,9 @@ export interface Spec extends TurboModule {
realm: string | undefined,
audience: string | undefined,
scope: string | undefined,
- organization: string | undefined
+ organization: string | undefined,
+ verification: { [key: string]: string } | undefined,
+ verificationRequired: string[] | undefined
): Promise;
/**
diff --git a/src/types/parameters.ts b/src/types/parameters.ts
index 1db034a8..ab5451c0 100644
--- a/src/types/parameters.ts
+++ b/src/types/parameters.ts
@@ -520,6 +520,14 @@ export interface PasskeySignupChallengeParameters {
realm?: string;
/** Organization ID or name for authenticating in an organization context. */
organization?: string;
+ /**
+ * How to deliver the one-time code when the phone number must be verified, by
+ * SMS or voice call. When omitted, Auth0 chooses the delivery method.
+ *
+ * Only used when the connection requires identifier verification. See
+ * {@link PasskeyChallengeResponse.verificationRequired}.
+ */
+ deliveryMethod?: PasswordlessDeliveryMethod;
}
/**
@@ -546,6 +554,44 @@ export interface PasskeyChallengeResponse {
authSession: string;
/** The raw WebAuthn public key credential creation/request options as JSON. */
authParamsPublicKey: Record;
+ /**
+ * The identifiers the user must verify with a one-time code before the
+ * passkey signup can complete.
+ *
+ * Only present on a signup challenge, and only when the database connection
+ * requires identifier verification. Auth0 sends a code to each listed
+ * identifier. Collect the codes **before** creating the passkey credential,
+ * then pass them as `verification` to `getTokenByPasskey`. Identifiers this
+ * SDK does not recognize are left out.
+ */
+ verificationRequired?: PasskeyVerificationMethod[];
+}
+
+/**
+ * An identifier that Auth0 can require the user to verify with a one-time code
+ * while signing up with a passkey.
+ *
+ * @see {@link PasskeyChallengeResponse.verificationRequired}
+ */
+export type PasskeyVerificationMethod = 'email' | 'phone';
+
+/**
+ * What to retry a passkey signup exchange with after a one-time code was
+ * wrong. Returned by `PasskeyError.getVerificationRetryPayload()`.
+ */
+export interface PasskeyVerificationRetryPayload {
+ /**
+ * The auth session to retry with: the same, still valid session Auth0
+ * returned with the error. Always set on a retryable error; if it is ever
+ * absent, keep using the session from the original challenge.
+ */
+ authSession?: string;
+ /**
+ * The identifiers whose codes must be collected again. When `undefined`,
+ * Auth0 did not say which failed: collect the codes for the identifiers of
+ * the original challenge.
+ */
+ verificationRequired?: PasskeyVerificationMethod[];
}
/**
@@ -581,6 +627,57 @@ export interface GetTokenByPasskeyParameters {
scope?: string;
/** Organization ID or name for authenticating in an organization context. */
organization?: string;
+ /**
+ * The one-time codes the user entered, keyed by the identifier each code was
+ * sent to (for example `{ email: '123456', phone: '654321' }`).
+ *
+ * Only meaningful when the database connection requires identifier
+ * verification, that is, when the signup challenge returned
+ * {@link PasskeyChallengeResponse.verificationRequired}: provide a code for
+ * each of those identifiers. Omitting it is always valid.
+ *
+ * Only codes for identifiers this SDK knows about (`email`, `phone`) are
+ * sent; others are ignored. A code that is empty or only whitespace rejects
+ * with a `PasskeyError` of type `PASSKEY_INVALID_PARAMETER` on every
+ * platform, before anything is sent.
+ *
+ * If a code is wrong while the session is still alive, the call rejects with
+ * a `PasskeyError` of type `PASSKEY_VERIFICATION_RETRYABLE`. To retry, read
+ * `error.getVerificationRetryPayload()` and call `getTokenByPasskey` again
+ * with:
+ * - the **same** `authResponse`, so the user does not create a second passkey;
+ * - its `authSession` (the same, still valid session);
+ * - its `verificationRequired` (only the identifiers whose code was wrong; if
+ * it is `undefined`, those of the challenge) as `verificationRequired`;
+ * - fresh codes for those identifiers only as `verification`. The other
+ * identifiers are already verified in this session.
+ *
+ * Only used when completing a passkey signup.
+ */
+ verification?: Record;
+ /**
+ * The identifiers the codes in `verification` are for: on the first attempt,
+ * {@link PasskeyChallengeResponse.verificationRequired} from the signup
+ * challenge; on a retry, `verificationRequired` from
+ * `PasskeyError.getVerificationRetryPayload()` (see `verification`).
+ *
+ * Only meaningful when the database connection requires identifier
+ * verification. Omitting it is always valid: pass it only when the challenge
+ * or the retry payload has one.
+ *
+ * **iOS and Android:** passed to Auth0.swift / Auth0.Android, which check,
+ * before sending anything, that every listed identifier has a code in
+ * `verification`. If one has none, the call rejects with a `PasskeyError` of
+ * type `PASSKEY_EXCHANGE_FAILED`, and the session stays usable. When omitted,
+ * nothing is checked.
+ *
+ * **Web:** ignored (auth0-spa-js does not take it). A missing code is sent to
+ * Auth0 as is, and also rejects with `PASSKEY_EXCHANGE_FAILED`.
+ *
+ * Identifiers this SDK does not know about (other than `email`, `phone`) are
+ * ignored. Only used when completing a passkey signup.
+ */
+ verificationRequired?: string[];
}
// ========= Passkey Enrollment Parameters =========