diff --git a/.version b/.version
index 268fccb1..fbbf144b 100644
--- a/.version
+++ b/.version
@@ -1 +1 @@
-v5.5.0
+v5.5.1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63c7a65e..2b6b8529 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Change Log
+## [v5.5.1](https://github.com/auth0/react-native-auth0/tree/v5.5.1) (2026-04-23)
+
+[Full Changelog](https://github.com/auth0/react-native-auth0/compare/v5.5.0...v5.5.1)
+
+**Fixed**
+
+- fix: remove conflicting broad scheme from MainActivity to prevent Android disambiguation dialog [\#1514](https://github.com/auth0/react-native-auth0/pull/1514) ([subhankarmaiti](https://github.com/subhankarmaiti))
+- fix: filter universal link callbacks by Auth0 domain in iOS [\#1512](https://github.com/auth0/react-native-auth0/pull/1512) ([subhankarmaiti](https://github.com/subhankarmaiti))
+
## [v5.5.0](https://github.com/auth0/react-native-auth0/tree/v5.5.0) (2026-04-09)
[Full Changelog](https://github.com/auth0/react-native-auth0/compare/v5.4.1...v5.5.0)
diff --git a/EXAMPLES-WEB.md b/EXAMPLES-WEB.md
index 24f7b7ed..21ce8869 100644
--- a/EXAMPLES-WEB.md
+++ b/EXAMPLES-WEB.md
@@ -158,14 +158,120 @@ const App = () => {
};
```
-## Unsupported Web Features
+## 3. MFA Flexible Factors Grant (Web)
-For security reasons, the web platform **does not support** direct authentication grants. The following methods from the `auth` provider will throw a `NotImplemented` error:
+The MFA Flexible Factors Grant is fully supported on the web platform. It uses the `@auth0/auth0-spa-js` MFA API under the hood.
-- `auth.passwordRealm()`
-- `auth.loginWithOTP()`
-- `auth.loginWithSMS()`
-- `auth.loginWithEmail()`
-- `auth.refreshToken()`
+### Using MFA with Hooks
-All these flows should be configured in your [Auth0 Universal Login](https://auth0.com/docs/universal-login) page and initiated via the `authorize()` method.
+```tsx
+import React, { useState } from 'react';
+import { View, Button, TextInput, Text } from 'react-native';
+import { useAuth0, MfaError, MfaErrorCodes } from 'react-native-auth0';
+
+function MfaScreen({ mfaToken }: { mfaToken: string }) {
+ const { mfa } = useAuth0();
+ const [otp, setOtp] = useState('');
+
+ const listAuthenticators = async () => {
+ try {
+ const authenticators = await mfa.getAuthenticators({ mfaToken });
+ console.log('Authenticators:', authenticators);
+ } catch (error) {
+ if (error instanceof MfaError) {
+ console.error('MFA error:', error.type, error.message);
+ }
+ }
+ };
+
+ const enrollTotp = async () => {
+ try {
+ const challenge = await mfa.enroll({ mfaToken, type: 'otp' });
+ if (challenge.type === 'totp') {
+ console.log('Scan QR:', challenge.barcodeUri);
+ console.log('Secret:', challenge.secret);
+ }
+ } catch (error) {
+ if (error instanceof MfaError) {
+ console.error('Enrollment error:', error.type);
+ }
+ }
+ };
+
+ const verifyOtp = async () => {
+ try {
+ const credentials = await mfa.verify({ mfaToken, otp });
+ console.log('Authenticated!', credentials.accessToken);
+ } catch (error) {
+ if (error instanceof MfaError) {
+ switch (error.type) {
+ case MfaErrorCodes.INVALID_OTP:
+ console.log('Incorrect code');
+ break;
+ case MfaErrorCodes.TOO_MANY_ATTEMPTS:
+ console.log('Too many attempts');
+ break;
+ case MfaErrorCodes.EXPIRED_MFA_TOKEN:
+ console.log('Session expired');
+ break;
+ }
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ );
+}
+```
+
+### Using MFA with Auth0 Class
+
+```typescript
+import Auth0, { MfaError, MfaErrorCodes } from 'react-native-auth0';
+
+const auth0 = new Auth0({
+ domain: 'YOUR_AUTH0_DOMAIN',
+ clientId: 'YOUR_AUTH0_CLIENT_ID',
+});
+
+// List authenticators
+const authenticators = await auth0.mfa.getAuthenticators({
+ mfaToken: 'mfa_token',
+});
+
+// Enroll TOTP
+const challenge = await auth0.mfa.enroll({
+ mfaToken: 'mfa_token',
+ type: 'otp',
+});
+
+// Enroll SMS
+const smsChallenge = await auth0.mfa.enroll({
+ mfaToken: 'mfa_token',
+ phoneNumber: '+12025550135',
+});
+
+// Challenge an authenticator
+const challengeResult = await auth0.mfa.challenge({
+ mfaToken: 'mfa_token',
+ authenticatorId: 'sms|dev_123',
+});
+
+// Verify OTP
+const credentials = await auth0.mfa.verify({
+ mfaToken: 'mfa_token',
+ otp: '123456',
+});
+```
+
+## Web Platform Notes
+
+The web platform supports direct authentication grants including `auth.passwordRealm()`, `auth.createUser()`, `auth.resetPassword()`, and the MFA Flexible Factors Grant. These methods make direct HTTP calls to the Auth0 API.
+
+Token refresh is handled automatically by `credentialsManager.getCredentials()` on the web. The `auth.refreshToken()` method is not available.
diff --git a/EXAMPLES.md b/EXAMPLES.md
index deb3ee99..1b6acc98 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -58,6 +58,10 @@
- [Using SSO Exchange with Hooks](#using-sso-exchange-with-hooks)
- [Using SSO Exchange with Auth0 Class](#using-sso-exchange-with-auth0-class)
- [Sending the Session Transfer Token](#sending-the-session-transfer-token)
+- [MFA Flexible Factors Grant](#mfa-flexible-factors-grant)
+ - [Using MFA with Hooks](#using-mfa-with-hooks)
+ - [Using MFA with Auth0 Class](#using-mfa-with-auth0-class)
+ - [MFA Error Handling](#mfa-error-handling)
- [Bot Protection](#bot-protection)
- [Domain Switching](#domain-switching)
- [Android](#android)
@@ -1243,6 +1247,275 @@ function WebAppView() {
> **Note**: Cookie injection is platform-specific and may require additional configuration. The query parameter method is generally more straightforward and recommended for most use cases.
+## MFA Flexible Factors Grant
+
+The MFA Flexible Factors Grant provides programmatic control over Multi-Factor Authentication flows. Instead of relying solely on Universal Login, you can build custom MFA experiences — listing enrolled authenticators, enrolling new factors, triggering challenges, and verifying codes — all from your own UI.
+
+This feature works across all platforms (iOS, Android, and Web).
+
+### Using MFA with Hooks
+
+```tsx
+import React, { useState } from 'react';
+import { View, Button, TextInput, Text, Alert } from 'react-native';
+import { useAuth0, MfaError, MfaErrorCodes } from 'react-native-auth0';
+
+function MfaScreen({ mfaToken }: { mfaToken: string }) {
+ const { mfa } = useAuth0();
+ const [otp, setOtp] = useState('');
+
+ // List enrolled authenticators
+ const listAuthenticators = async () => {
+ try {
+ const authenticators = await mfa.getAuthenticators({ mfaToken });
+ console.log('Enrolled authenticators:', authenticators);
+ } catch (error) {
+ if (error instanceof MfaError) {
+ console.error('MFA error:', error.type, error.message);
+ }
+ }
+ };
+
+ // Enroll a new TOTP authenticator
+ const enrollTotp = async () => {
+ try {
+ const challenge = await mfa.enroll({ mfaToken, type: 'otp' });
+ if (challenge.type === 'totp') {
+ console.log('Scan this barcode:', challenge.barcodeUri);
+ console.log('Or enter this secret:', challenge.secret);
+ }
+ } catch (error) {
+ if (error instanceof MfaError) {
+ switch (error.type) {
+ case MfaErrorCodes.ENROLLMENT_FAILED:
+ Alert.alert('Error', 'Enrollment failed. Please try again.');
+ break;
+ case MfaErrorCodes.EXPIRED_MFA_TOKEN:
+ Alert.alert('Error', 'MFA session expired. Please start over.');
+ break;
+ }
+ }
+ }
+ };
+
+ // Enroll an SMS factor
+ const enrollSms = async () => {
+ try {
+ const challenge = await mfa.enroll({
+ mfaToken,
+ phoneNumber: '+12025550135',
+ });
+ console.log('OOB code:', challenge.oobCode);
+ } catch (error) {
+ if (
+ error instanceof MfaError &&
+ error.type === MfaErrorCodes.INVALID_PHONE_NUMBER
+ ) {
+ Alert.alert('Error', 'Invalid phone number.');
+ }
+ }
+ };
+
+ // Trigger a challenge for an existing authenticator
+ const triggerChallenge = async (authenticatorId: string) => {
+ try {
+ const result = await mfa.challenge({ mfaToken, authenticatorId });
+ console.log('Challenge type:', result.challengeType);
+ console.log('OOB code:', result.oobCode);
+ } catch (error) {
+ if (error instanceof MfaError) {
+ console.error('Challenge failed:', error.type);
+ }
+ }
+ };
+
+ // Verify an OTP code - this completes authentication
+ const verifyOtp = async () => {
+ try {
+ const credentials = await mfa.verify({ mfaToken, otp });
+ console.log('Authentication complete!', credentials.accessToken);
+ // User is now logged in - state is automatically updated
+ } catch (error) {
+ if (error instanceof MfaError) {
+ switch (error.type) {
+ case MfaErrorCodes.INVALID_OTP:
+ Alert.alert('Error', 'Incorrect code. Please try again.');
+ break;
+ case MfaErrorCodes.TOO_MANY_ATTEMPTS:
+ Alert.alert('Error', 'Too many attempts. Please wait.');
+ break;
+ case MfaErrorCodes.EXPIRED_MFA_TOKEN:
+ Alert.alert('Error', 'Session expired. Please start over.');
+ break;
+ }
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+```
+
+### Using MFA with Auth0 Class
+
+```typescript
+import Auth0, { MfaError, MfaErrorCodes } from 'react-native-auth0';
+
+const auth0 = new Auth0({
+ domain: 'YOUR_AUTH0_DOMAIN',
+ clientId: 'YOUR_AUTH0_CLIENT_ID',
+});
+
+// List enrolled authenticators
+const authenticators = await auth0.mfa.getAuthenticators({
+ mfaToken: 'mfa_token_from_login',
+ factorsAllowed: ['otp', 'oob'], // Optional: filter by factor type
+});
+
+// Enroll a TOTP authenticator
+const totpChallenge = await auth0.mfa.enroll({
+ mfaToken: 'mfa_token',
+ type: 'otp',
+});
+// totpChallenge.type === 'totp'
+// totpChallenge.barcodeUri - QR code URI
+// totpChallenge.secret - Manual entry secret
+
+// Enroll via SMS
+const smsChallenge = await auth0.mfa.enroll({
+ mfaToken: 'mfa_token',
+ phoneNumber: '+12025550135',
+});
+
+// Enroll via email
+const emailChallenge = await auth0.mfa.enroll({
+ mfaToken: 'mfa_token',
+ email: 'user@example.com',
+});
+
+// Enroll via voice call
+const voiceChallenge = await auth0.mfa.enroll({
+ mfaToken: 'mfa_token',
+ phoneNumber: '+12025550135',
+ voice: true,
+});
+
+// Trigger an OOB challenge
+const challenge = await auth0.mfa.challenge({
+ mfaToken: 'mfa_token',
+ authenticatorId: 'sms|dev_123',
+});
+
+// Verify with OTP
+const credentials = await auth0.mfa.verify({
+ mfaToken: 'mfa_token',
+ otp: '123456',
+});
+
+// Verify with OOB code
+const credentialsOob = await auth0.mfa.verify({
+ mfaToken: 'mfa_token',
+ oobCode: 'oob_code_from_challenge',
+ bindingCode: '654321', // Optional, for SMS/email OOB
+});
+
+// Verify with recovery code
+const credentialsRecovery = await auth0.mfa.verify({
+ mfaToken: 'mfa_token',
+ recoveryCode: 'ABCDEF123456',
+});
+```
+
+### MFA Error Handling
+
+All MFA operations throw `MfaError` with a normalized, platform-agnostic `type` property:
+
+```typescript
+import { MfaError, MfaErrorCodes } from 'react-native-auth0';
+
+try {
+ await auth0.mfa.verify({ mfaToken, otp: '123456' });
+} catch (error) {
+ if (error instanceof MfaError) {
+ switch (error.type) {
+ case MfaErrorCodes.INVALID_OTP:
+ // OTP code is incorrect
+ break;
+ case MfaErrorCodes.INVALID_OOB_CODE:
+ // OOB code is incorrect
+ break;
+ case MfaErrorCodes.INVALID_RECOVERY_CODE:
+ // Recovery code is incorrect
+ break;
+ case MfaErrorCodes.EXPIRED_MFA_TOKEN:
+ // MFA token has expired - restart the MFA flow
+ break;
+ case MfaErrorCodes.INVALID_MFA_TOKEN:
+ // MFA token is invalid
+ break;
+ case MfaErrorCodes.TOO_MANY_ATTEMPTS:
+ // Rate limited - wait before retrying
+ break;
+ case MfaErrorCodes.ENROLLMENT_FAILED:
+ // Enrollment operation failed
+ break;
+ case MfaErrorCodes.INVALID_PHONE_NUMBER:
+ // Phone number is invalid for enrollment
+ break;
+ case MfaErrorCodes.INVALID_EMAIL:
+ // Email is invalid for enrollment
+ break;
+ case MfaErrorCodes.CHALLENGE_FAILED:
+ // Challenge request failed
+ break;
+ case MfaErrorCodes.AUTHENTICATOR_NOT_FOUND:
+ // Authenticator not found or not enrolled
+ break;
+ case MfaErrorCodes.UNSUPPORTED_FACTOR:
+ // MFA factor type is not supported
+ break;
+ case MfaErrorCodes.ASSOCIATION_REQUIRED:
+ // User must enroll before using the authenticator
+ break;
+ default:
+ console.error('MFA error:', error.message);
+ }
+ }
+}
+```
+
+| Error Code | Description | Auth0 API Code | Native Bridge Code |
+| ------------------------- | ----------------------------------------------- | ------------------------------ | ---------------------- |
+| `INVALID_OTP` | OTP code is incorrect | `invalid_otp`, `invalid_grant` | |
+| `INVALID_OOB_CODE` | OOB code is incorrect | `invalid_oob_code` | |
+| `INVALID_BINDING_CODE` | Binding code is incorrect | `invalid_binding_code` | |
+| `INVALID_RECOVERY_CODE` | Recovery code is incorrect | `invalid_recovery_code` | |
+| `ENROLLMENT_FAILED` | MFA enrollment failed | `mfa_enrollment_failed` | `MFA_ENROLLMENT_ERROR` |
+| `INVALID_PHONE_NUMBER` | Phone number is invalid for enrollment | `invalid_phone_number` | |
+| `INVALID_EMAIL` | Email is invalid for enrollment | `invalid_email` | |
+| `EXPIRED_MFA_TOKEN` | MFA token has expired | `expired_token` | |
+| `INVALID_MFA_TOKEN` | MFA token is invalid | `mfa_token_invalid` | |
+| `TOO_MANY_ATTEMPTS` | Rate limited - too many verification attempts | `too_many_attempts` | |
+| `CHALLENGE_FAILED` | MFA challenge request failed | `mfa_challenge_failed` | `MFA_CHALLENGE_ERROR` |
+| `AUTHENTICATOR_NOT_FOUND` | Authenticator not found or not enrolled | | |
+| `UNSUPPORTED_FACTOR` | MFA factor type is not supported | `unsupported_challenge_type` | |
+| `ASSOCIATION_REQUIRED` | User must enroll before using the authenticator | `association_required` | |
+| `MFA_ERROR` | Generic MFA error | | `MFA_VERIFY_ERROR` |
+| `UNKNOWN_MFA_ERROR` | Unknown or uncategorized MFA error | | |
+
## Bot Protection
If you are using the [Bot Protection](https://auth0.com/docs/anomaly-detection/bot-protection) feature and performing database login/signup via the Authentication API, you need to handle the `requires_verification` error. It indicates that the request was flagged as suspicious and an additional verification step is necessary to log the user in. That verification step is web-based, so you need to use Universal Login to complete it.
diff --git a/README.md b/README.md
index a4aa5ca0..0f6ecdad 100644
--- a/README.md
+++ b/README.md
@@ -303,6 +303,39 @@ https://{YOUR_AUTH0_DOMAIN}/ios/{PRODUCT_BUNDLE_IDENTIFIER}/callback
> Replace `{PRODUCT_BUNDLE_IDENTIFIER}` and `{YOUR_AUTH0_DOMAIN}` with your actual product bundle identifier and Auth0 domain. Ensure that {PRODUCT_BUNDLE_IDENTIFIER} is all lowercase.
+#### Expo
+
+When using Expo, the callback URL format depends on whether you provide a `customScheme` in your `app.json` plugin configuration.
+
+##### With `customScheme`
+
+If you set a `customScheme` (e.g., `"auth0sample"`) in your `app.json`:
+
+```text
+{YOUR_CUSTOM_SCHEME}://{YOUR_AUTH0_DOMAIN}/ios/{BUNDLE_IDENTIFIER}/callback,
+{YOUR_CUSTOM_SCHEME}://{YOUR_AUTH0_DOMAIN}/android/{PACKAGE_NAME}/callback
+```
+
+**Example:** If your custom scheme is `auth0sample`, your Auth0 domain is `example.us.auth0.com`, your iOS bundle identifier is `com.example.myapp`, and your Android package name is `com.example.myapp`:
+
+```text
+auth0sample://example.us.auth0.com/ios/com.example.myapp/callback,
+auth0sample://example.us.auth0.com/android/com.example.myapp/callback
+```
+
+> **Note:** The URL scheme uses `customScheme`, but the path always contains the bundle identifier (iOS) or package name (Android), **not** the custom scheme.
+
+##### Without `customScheme`
+
+If you do not provide a `customScheme`, the SDK defaults to `{BUNDLE_IDENTIFIER}.auth0` / `{PACKAGE_NAME}.auth0`:
+
+```text
+{BUNDLE_IDENTIFIER}.auth0://{YOUR_AUTH0_DOMAIN}/ios/{BUNDLE_IDENTIFIER}/callback,
+{PACKAGE_NAME}.auth0://{YOUR_AUTH0_DOMAIN}/android/{PACKAGE_NAME}/callback
+```
+
+> All values must be **lowercase** with **no trailing slash**.
+
#### Configure an associated domain for iOS
> [!IMPORTANT]
@@ -712,6 +745,57 @@ try {
| `NO_NETWORK` | `NO_NETWORK` | | |
| `API_ERROR` | `API_ERROR` | | |
+### MFA errors
+
+All MFA operations (via `auth0.mfa` or the `mfa` property from `useAuth0()`) throw `MfaError` with a normalized `type` property:
+
+```js
+import { MfaError, MfaErrorCodes } from 'react-native-auth0';
+
+try {
+ const credentials = await auth0.mfa.verify({ mfaToken, otp: '123456' });
+} catch (error) {
+ if (error instanceof MfaError) {
+ switch (error.type) {
+ case MfaErrorCodes.INVALID_OTP:
+ console.log('Incorrect OTP code.');
+ break;
+ case MfaErrorCodes.EXPIRED_MFA_TOKEN:
+ console.log('MFA session expired. Please start over.');
+ break;
+ case MfaErrorCodes.TOO_MANY_ATTEMPTS:
+ console.log('Too many attempts. Please wait.');
+ break;
+ case MfaErrorCodes.ENROLLMENT_FAILED:
+ console.log('MFA enrollment failed.');
+ break;
+ default:
+ console.error('MFA error:', error.message);
+ }
+ }
+}
+```
+
+| Generic Error Code | Description | Auth0 API Code |
+| ------------------------- | -------------------------------------- | ------------------------------ |
+| `INVALID_OTP` | OTP code is incorrect | `invalid_otp`, `invalid_grant` |
+| `INVALID_OOB_CODE` | OOB code is incorrect | `invalid_oob_code` |
+| `INVALID_BINDING_CODE` | Binding code is incorrect | `invalid_binding_code` |
+| `INVALID_RECOVERY_CODE` | Recovery code is incorrect | `invalid_recovery_code` |
+| `ENROLLMENT_FAILED` | MFA enrollment failed | `mfa_enrollment_failed` |
+| `INVALID_PHONE_NUMBER` | Phone number is invalid for enrollment | `invalid_phone_number` |
+| `INVALID_EMAIL` | Email is invalid for enrollment | `invalid_email` |
+| `EXPIRED_MFA_TOKEN` | MFA token has expired | `expired_token` |
+| `INVALID_MFA_TOKEN` | MFA token is invalid | `mfa_token_invalid` |
+| `TOO_MANY_ATTEMPTS` | Rate limited | `too_many_attempts` |
+| `CHALLENGE_FAILED` | MFA challenge failed | `mfa_challenge_failed` |
+| `AUTHENTICATOR_NOT_FOUND` | Authenticator not found | |
+| `UNSUPPORTED_FACTOR` | Factor type not supported | `unsupported_challenge_type` |
+| `ASSOCIATION_REQUIRED` | User must enroll first | `association_required` |
+| `UNKNOWN_MFA_ERROR` | Unknown MFA error | |
+
+For detailed MFA usage examples, see [EXAMPLES.md](EXAMPLES.md#mfa-flexible-factors-grant) and [EXAMPLES-WEB.md](EXAMPLES-WEB.md#3-mfa-flexible-factors-grant-web).
+
### WebAuth errors
**Before (Platform-Specific Codes)**
@@ -826,6 +910,11 @@ This library provides a unified API across Native (iOS/Android) and Web platform
| `auth.createUser()` | ✅ | ✅ | Calls the `/dbconnections/signup` endpoint. Works on both platforms. |
| `auth.resetPassword()` | ✅ | ✅ | Calls the `/dbconnections/change_password` endpoint. Works on both platforms. |
| `users(token).patchUser()` | ✅ | ✅ | Calls the Management API. Works on any platform with a valid token, but use with caution in the browser. |
+| **MFA Flexible Factors Grant** | | | --- |
+| `mfa.getAuthenticators()` | ✅ | ✅ | Lists enrolled MFA authenticators for the user. |
+| `mfa.enroll()` | ✅ | ✅ | Enrolls a new MFA factor (OTP, SMS, email, voice, push). |
+| `mfa.challenge()` | ✅ | ✅ | Triggers an MFA challenge for an enrolled authenticator. |
+| `mfa.verify()` | ✅ | ✅ | Verifies an MFA code (OTP, OOB, recovery) and returns credentials. |
## Troubleshooting
diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt
index ade908c0..4e4de9e3 100644
--- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt
+++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt
@@ -16,11 +16,14 @@ import com.auth0.android.dpop.DPoPException
import com.auth0.android.provider.WebAuthProvider
import com.auth0.android.result.Credentials
import com.facebook.react.bridge.ActivityEventListener
+import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.UiThreadUtil
+import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import java.net.MalformedURLException
import java.net.URL
@@ -88,6 +91,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
private var useDPoP: Boolean = true
private var auth0: Auth0? = null
+ private var mfaClient: MfaClient? = null
private lateinit var secureCredentialsManager: SecureCredentialsManager
private var webAuthPromise: Promise? = null
@@ -177,6 +181,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
this.useDPoP = useDPoP ?: true
auth0 = Auth0.getInstance(clientId, domain)
+ mfaClient = MfaClient(auth0!!, this.useDPoP, reactContext)
val authAPI = AuthenticationAPIClient(auth0!!)
if (this.useDPoP) {
@@ -507,6 +512,30 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
})
}
+ @ReactMethod
+ override fun getMfaAuthenticators(mfaToken: String, factorsAllowed: ReadableArray?, promise: Promise) {
+ mfaClient?.getAuthenticators(mfaToken, factorsAllowed, promise)
+ ?: promise.reject("NOT_INITIALIZED", "Auth0 not initialized")
+ }
+
+ @ReactMethod
+ override fun mfaEnroll(mfaToken: String, type: String, value: String?, promise: Promise) {
+ mfaClient?.enroll(mfaToken, type, value, promise)
+ ?: promise.reject("NOT_INITIALIZED", "Auth0 not initialized")
+ }
+
+ @ReactMethod
+ override fun mfaChallenge(mfaToken: String, authenticatorId: String, promise: Promise) {
+ mfaClient?.challenge(mfaToken, authenticatorId, promise)
+ ?: promise.reject("NOT_INITIALIZED", "Auth0 not initialized")
+ }
+
+ @ReactMethod
+ override fun mfaVerify(mfaToken: String, type: String, code: String, bindingCode: String?, promise: Promise) {
+ mfaClient?.verify(mfaToken, type, code, bindingCode, promise)
+ ?: promise.reject("NOT_INITIALIZED", "Auth0 not initialized")
+ }
+
override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
// No-op
}
diff --git a/android/src/main/java/com/auth0/react/MfaClient.kt b/android/src/main/java/com/auth0/react/MfaClient.kt
new file mode 100644
index 00000000..465964f9
--- /dev/null
+++ b/android/src/main/java/com/auth0/react/MfaClient.kt
@@ -0,0 +1,209 @@
+package com.auth0.react
+
+import com.auth0.android.Auth0
+import com.auth0.android.authentication.AuthenticationAPIClient
+import com.auth0.android.authentication.mfa.MfaEnrollmentType
+import com.auth0.android.authentication.mfa.MfaException
+import com.auth0.android.authentication.mfa.MfaVerificationType
+import com.auth0.android.result.Authenticator
+import com.auth0.android.result.Credentials
+import com.auth0.android.result.EnrollmentChallenge
+import com.auth0.android.result.OobEnrollmentChallenge
+import com.auth0.android.result.RecoveryCodeEnrollmentChallenge
+import com.auth0.android.result.TotpEnrollmentChallenge
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReadableArray
+import com.facebook.react.bridge.WritableNativeArray
+import com.facebook.react.bridge.WritableNativeMap
+
+enum class MfaFactorType(val value: String) {
+ PHONE("phone"),
+ EMAIL("email"),
+ OTP("otp"),
+ PUSH("push"),
+ VOICE("voice");
+
+ companion object {
+ fun fromString(type: String): MfaFactorType? =
+ entries.find { it.value == type }
+ }
+}
+
+enum class MfaVerifyType(val value: String) {
+ OTP("otp"),
+ OOB("oob"),
+ RECOVERY_CODE("recoveryCode");
+
+ companion object {
+ fun fromString(type: String): MfaVerifyType? =
+ entries.find { it.value == type }
+ }
+}
+
+class MfaClient(
+ private val auth0: Auth0,
+ private val useDPoP: Boolean,
+ private val reactContext: ReactApplicationContext
+) {
+
+ private val client: AuthenticationAPIClient by lazy {
+ AuthenticationAPIClient(auth0).apply {
+ if (useDPoP) {
+ useDPoP(reactContext)
+ }
+ }
+ }
+
+ fun getAuthenticators(mfaToken: String, factorsAllowed: ReadableArray?, promise: Promise) {
+ val mfaClient = client.mfaClient(mfaToken)
+
+ val factors = mutableListOf()
+ factorsAllowed?.let {
+ for (i in 0 until it.size()) {
+ it.getString(i)?.let { factor -> factors.add(factor) }
+ }
+ }
+
+ mfaClient.getAuthenticators(factors).start(
+ object : com.auth0.android.callback.Callback, MfaException.MfaListAuthenticatorsException> {
+ override fun onSuccess(result: List) {
+ val array = WritableNativeArray()
+ for (authenticator in result) {
+ val map = WritableNativeMap().apply {
+ putString("id", authenticator.id)
+ putString("authenticatorType", authenticator.authenticatorType)
+ putBoolean("active", authenticator.active)
+ authenticator.name?.let { putString("name", it) }
+ authenticator.oobChannel?.let { putString("oobChannel", it) }
+ }
+ array.pushMap(map)
+ }
+ promise.resolve(array)
+ }
+
+ override fun onFailure(error: MfaException.MfaListAuthenticatorsException) {
+ promise.reject(error.getCode(), error.getDescription(), error)
+ }
+ }
+ )
+ }
+
+ fun enroll(mfaToken: String, type: String, value: String?, promise: Promise) {
+ val mfaClient = client.mfaClient(mfaToken)
+
+ val factorType = MfaFactorType.fromString(type)
+ if (factorType == null) {
+ promise.reject("MFA_ENROLLMENT_ERROR", "Unsupported enrollment type: $type")
+ return
+ }
+
+ val enrollmentType = when (factorType) {
+ MfaFactorType.PHONE -> {
+ if (value == null) {
+ promise.reject("MFA_ENROLLMENT_ERROR", "Phone number is required for phone enrollment")
+ return
+ }
+ MfaEnrollmentType.Phone(value)
+ }
+ MfaFactorType.EMAIL -> {
+ if (value == null) {
+ promise.reject("MFA_ENROLLMENT_ERROR", "Email is required for email enrollment")
+ return
+ }
+ MfaEnrollmentType.Email(value)
+ }
+ MfaFactorType.OTP -> MfaEnrollmentType.Otp
+ MfaFactorType.PUSH -> MfaEnrollmentType.Push
+ MfaFactorType.VOICE -> {
+ if (value == null) {
+ promise.reject("MFA_ENROLLMENT_ERROR", "Phone number is required for voice enrollment")
+ return
+ }
+ MfaEnrollmentType.Phone(value)
+ }
+ }
+
+ mfaClient.enroll(enrollmentType).start(
+ object : com.auth0.android.callback.Callback {
+ override fun onSuccess(result: EnrollmentChallenge) {
+ val map = WritableNativeMap()
+ when (result) {
+ is TotpEnrollmentChallenge -> {
+ map.putString("type", "totp")
+ map.putString("barcodeUri", result.barcodeUri)
+ map.putString("secret", result.manualInputCode)
+ }
+ is OobEnrollmentChallenge -> {
+ map.putString("type", "oob")
+ map.putString("oobCode", result.oobCode)
+ result.bindingMethod?.let { map.putString("bindingMethod", it) }
+ }
+ is RecoveryCodeEnrollmentChallenge -> {
+ map.putString("type", "recovery-code")
+ map.putString("recoveryCode", result.recoveryCode)
+ }
+ else -> {
+ map.putString("type", "unknown")
+ }
+ }
+ promise.resolve(map)
+ }
+
+ override fun onFailure(error: MfaException.MfaEnrollmentException) {
+ promise.reject(error.getCode(), error.getDescription(), error)
+ }
+ }
+ )
+ }
+
+ fun challenge(mfaToken: String, authenticatorId: String, promise: Promise) {
+ val mfaClient = client.mfaClient(mfaToken)
+
+ mfaClient.challenge(authenticatorId).start(
+ object : com.auth0.android.callback.Callback {
+ override fun onSuccess(result: com.auth0.android.result.Challenge) {
+ val map = WritableNativeMap().apply {
+ putString("challengeType", result.challengeType)
+ result.oobCode?.let { putString("oobCode", it) }
+ result.bindingMethod?.let { putString("bindingMethod", it) }
+ }
+ promise.resolve(map)
+ }
+
+ override fun onFailure(error: MfaException.MfaChallengeException) {
+ promise.reject(error.getCode(), error.getDescription(), error)
+ }
+ }
+ )
+ }
+
+ fun verify(mfaToken: String, type: String, code: String, bindingCode: String?, promise: Promise) {
+ val verifyType = MfaVerifyType.fromString(type)
+ if (verifyType == null) {
+ promise.reject("MFA_VERIFY_ERROR", "Unsupported verification type: $type")
+ return
+ }
+
+ val mfaClient = client.mfaClient(mfaToken)
+
+ val verificationType = when (verifyType) {
+ MfaVerifyType.OTP -> MfaVerificationType.Otp(code)
+ MfaVerifyType.OOB -> MfaVerificationType.Oob(oobCode = code, bindingCode = bindingCode)
+ MfaVerifyType.RECOVERY_CODE -> MfaVerificationType.RecoveryCode(code)
+ }
+
+ mfaClient.verify(verificationType).start(
+ object : com.auth0.android.callback.Callback {
+ override fun onSuccess(result: Credentials) {
+ val map = CredentialsParser.toMap(result)
+ promise.resolve(map)
+ }
+
+ override fun onFailure(error: MfaException.MfaVerifyException) {
+ promise.reject(error.getCode(), error.getDescription(), error)
+ }
+ }
+ )
+ }
+}
diff --git a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt
index f0c26d10..7e0d1699 100644
--- a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt
+++ b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt
@@ -120,4 +120,39 @@ abstract class A0Auth0Spec(context: ReactApplicationContext) : ReactContextBaseJ
organization: String?,
promise: Promise
)
+
+ @ReactMethod
+ @DoNotStrip
+ abstract fun getMfaAuthenticators(
+ mfaToken: String,
+ factorsAllowed: com.facebook.react.bridge.ReadableArray?,
+ promise: Promise
+ )
+
+ @ReactMethod
+ @DoNotStrip
+ abstract fun mfaEnroll(
+ mfaToken: String,
+ type: String,
+ value: String?,
+ promise: Promise
+ )
+
+ @ReactMethod
+ @DoNotStrip
+ abstract fun mfaChallenge(
+ mfaToken: String,
+ authenticatorId: String,
+ promise: Promise
+ )
+
+ @ReactMethod
+ @DoNotStrip
+ abstract fun mfaVerify(
+ mfaToken: String,
+ type: String,
+ code: String,
+ bindingCode: String?,
+ promise: Promise
+ )
}
\ No newline at end of file
diff --git a/example/src/App.web.tsx b/example/src/App.web.tsx
index 13830002..e4dcec20 100644
--- a/example/src/App.web.tsx
+++ b/example/src/App.web.tsx
@@ -6,8 +6,23 @@ import {
Text,
StyleSheet,
ActivityIndicator,
+ TouchableOpacity,
+ Image,
+ Linking,
} from 'react-native';
-import Auth0, { Auth0Provider, useAuth0, User } from 'react-native-auth0';
+import Auth0, {
+ Auth0Provider,
+ useAuth0,
+ User,
+ MfaError,
+ MfaErrorCodes,
+ MfaFactorType,
+} from 'react-native-auth0';
+import type {
+ MfaAuthenticator,
+ MfaEnrollmentChallenge,
+ MfaChallengeResult,
+} from 'react-native-auth0';
import config from './auth0-configuration';
import Button from './components/Button';
@@ -15,6 +30,16 @@ import Header from './components/Header';
import Result from './components/Result';
import LabeledInput from './components/LabeledInput';
+type MfaStep =
+ | 'idle'
+ | 'list'
+ | 'enroll-select'
+ | 'enroll-details'
+ | 'verify'
+ | 'complete';
+
+type EnrollType = MfaFactorType;
+
// ========================================================================
// --- 1. HOOKS-BASED IMPLEMENTATION (Recommended) ---
// ========================================================================
@@ -29,7 +54,8 @@ const HooksAuthContent = (): React.JSX.Element => {
getCredentials,
createUser,
resetPassword,
- auth,
+ loginWithPasswordRealm,
+ mfa,
users,
} = useAuth0();
@@ -38,6 +64,37 @@ const HooksAuthContent = (): React.JSX.Element => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
+ // MFA wizard state
+ const [mfaToken, setMfaToken] = useState('');
+ const [mfaStep, setMfaStep] = useState('idle');
+ const [mfaLoading, setMfaLoading] = useState(false);
+ const [authenticators, setAuthenticators] = useState([]);
+ const [selectedAuthenticator, setSelectedAuthenticator] =
+ useState(null);
+ const [enrollType, setEnrollType] = useState(null);
+ const [enrollPhoneNumber, setEnrollPhoneNumber] = useState('');
+ const [enrollEmail, setEnrollEmail] = useState('');
+ const [enrollmentChallenge, setEnrollmentChallenge] =
+ useState(null);
+ const [challengeResult, setChallengeResult] =
+ useState(null);
+ const [verifyCode, setVerifyCode] = useState('');
+ const [verifyBindingCode, setVerifyBindingCode] = useState('');
+
+ const resetMfaWizard = () => {
+ setMfaStep('idle');
+ setAuthenticators([]);
+ setSelectedAuthenticator(null);
+ setEnrollType(null);
+ setEnrollPhoneNumber('');
+ setEnrollEmail('');
+ setEnrollmentChallenge(null);
+ setChallengeResult(null);
+ setVerifyCode('');
+ setVerifyBindingCode('');
+ setMfaLoading(false);
+ };
+
const runDemo = async (action: () => Promise) => {
setResult(null);
setApiError(null);
@@ -45,10 +102,128 @@ const HooksAuthContent = (): React.JSX.Element => {
const response = await action();
setResult(response ?? { success: true });
} catch (e) {
+ if (e instanceof MfaError) {
+ setApiError(e);
+ return;
+ }
setApiError(e as Error);
}
};
+ const handleMfaError = (e: unknown, fallbackMsg: string) => {
+ if (e instanceof MfaError) {
+ if (
+ e.type === MfaErrorCodes.EXPIRED_MFA_TOKEN ||
+ e.type === MfaErrorCodes.INVALID_MFA_TOKEN
+ ) {
+ setMfaToken('');
+ resetMfaWizard();
+ }
+ setApiError(e);
+ } else {
+ setApiError(e as Error);
+ }
+ };
+
+ const onMfaStart = async () => {
+ setMfaLoading(true);
+ setApiError(null);
+ try {
+ const list = await mfa.getAuthenticators({ mfaToken });
+ setAuthenticators(list);
+ setMfaStep('list');
+ } catch (e) {
+ handleMfaError(e, 'Failed to list authenticators.');
+ } finally {
+ setMfaLoading(false);
+ }
+ };
+
+ const onMfaSelectAuthenticator = async (auth: MfaAuthenticator) => {
+ setSelectedAuthenticator(auth);
+ setMfaLoading(true);
+ try {
+ const res = await mfa.challenge({ mfaToken, authenticatorId: auth.id });
+ setChallengeResult(res);
+ setMfaStep('verify');
+ } catch (e) {
+ handleMfaError(e, 'Challenge failed.');
+ setMfaStep('list');
+ } finally {
+ setMfaLoading(false);
+ }
+ };
+
+ const onMfaSelectEnrollType = (type: EnrollType) => {
+ setEnrollType(type);
+ if (type === MfaFactorType.OTP || type === MfaFactorType.PUSH) {
+ onMfaEnroll(type);
+ } else {
+ setMfaStep('enroll-details');
+ }
+ };
+
+ const onMfaEnroll = async (type?: EnrollType) => {
+ const factor = type || enrollType;
+ if (!factor) return;
+ setMfaLoading(true);
+ try {
+ let challenge: MfaEnrollmentChallenge;
+ if (factor === MfaFactorType.SMS) {
+ challenge = await mfa.enroll({
+ mfaToken,
+ factorType: MfaFactorType.SMS,
+ phoneNumber: enrollPhoneNumber,
+ });
+ } else if (factor === MfaFactorType.EMAIL) {
+ challenge = await mfa.enroll({
+ mfaToken,
+ factorType: MfaFactorType.EMAIL,
+ email: enrollEmail,
+ });
+ } else {
+ challenge = await mfa.enroll({ mfaToken, factorType: factor });
+ }
+ setEnrollmentChallenge(challenge);
+ setMfaStep('verify');
+ } catch (e) {
+ handleMfaError(e, 'Enrollment failed.');
+ } finally {
+ setMfaLoading(false);
+ }
+ };
+
+ const onMfaVerify = async () => {
+ setMfaLoading(true);
+ try {
+ let credentials;
+ const oobCode =
+ challengeResult?.oobCode ||
+ (enrollmentChallenge?.type === 'oob'
+ ? enrollmentChallenge.oobCode
+ : undefined);
+
+ if (oobCode) {
+ credentials = await mfa.verify({
+ mfaToken,
+ oobCode,
+ bindingCode: verifyBindingCode || undefined,
+ });
+ } else {
+ credentials = await mfa.verify({ mfaToken, otp: verifyCode });
+ }
+ setResult({
+ success: true,
+ accessToken: credentials.accessToken.substring(0, 20) + '...',
+ });
+ setMfaStep('complete');
+ } catch (e) {
+ handleMfaError(e, 'Verification failed.');
+ } finally {
+ setMfaLoading(false);
+ }
+ };
+
if (isLoading) {
return (
@@ -91,9 +266,9 @@ const HooksAuthContent = (): React.JSX.Element => {
title="Log In"
/>
-
+
{
onChangeText={setPassword}
secureTextEntry
/>
+
-
-
- runDemo(() =>
- auth.passwordRealm({ username: '', password: '', realm: '' })
- )
- }
- title="Test auth.passwordRealm()"
- />
+
+ {mfaStep === 'idle' && (
+ <>
+
+ Get an mfa_token from a password login with MFA enabled.
+
+
+
+ >
+ )}
+ {mfaStep === 'list' && (
+ <>
+
+ Step 1: Select Authenticator
+
+ {authenticators.length > 0 ? (
+ authenticators.map((auth) => (
+ onMfaSelectAuthenticator(auth)}
+ >
+
+ {auth.authenticatorType}
+ {auth.oobChannel ? ` (${auth.oobChannel})` : ''}
+
+ {auth.id}
+
+ ))
+ ) : (
+ No authenticators enrolled.
+ )}
+ setMfaStep('enroll-select')}
+ title="Enroll New Authenticator"
+ />
+
+ >
+ )}
+ {mfaStep === 'enroll-select' && (
+ <>
+
+ Step 2: Choose Factor Type
+
+ onMfaSelectEnrollType(MfaFactorType.OTP)}
+ title="TOTP (Authenticator App)"
+ disabled={mfaLoading}
+ />
+ onMfaSelectEnrollType(MfaFactorType.SMS)}
+ title="SMS"
+ disabled={mfaLoading}
+ />
+ onMfaSelectEnrollType(MfaFactorType.EMAIL)}
+ title="Email"
+ disabled={mfaLoading}
+ />
+ onMfaSelectEnrollType(MfaFactorType.PUSH)}
+ title="Push Notification"
+ disabled={mfaLoading}
+ />
+ setMfaStep('list')} title="Back" />
+ >
+ )}
+ {mfaStep === 'enroll-details' && (
+ <>
+ Step 2: Enter Details
+ {enrollType === MfaFactorType.SMS && (
+ <>
+
+ onMfaEnroll()}
+ title="Enroll SMS"
+ disabled={!enrollPhoneNumber || mfaLoading}
+ />
+ >
+ )}
+ {enrollType === MfaFactorType.EMAIL && (
+ <>
+
+ onMfaEnroll()}
+ title="Enroll Email"
+ disabled={!enrollEmail || mfaLoading}
+ />
+ >
+ )}
+ setMfaStep('enroll-select')}
+ title="Back"
+ />
+ >
+ )}
+ {mfaStep === 'verify' && (
+ <>
+ Step 3: Verify
+ {enrollmentChallenge?.type === 'totp' && (
+
+ {enrollmentChallenge.barcodeUri && (
+ <>
+
+
+
+
+ Linking.openURL(enrollmentChallenge.barcodeUri!)
+ }
+ title="Open in Authenticator App"
+ />
+ >
+ )}
+ Secret: {enrollmentChallenge.secret}
+
+ )}
+ {challengeResult?.challengeType === 'oob' ||
+ enrollmentChallenge?.type === 'oob' ? (
+ <>
+
+ A code has been sent. Enter the binding code below.
+
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+ setMfaStep('list')} title="Back" />
+ >
+ )}
+ {mfaStep === 'complete' && (
+ <>
+
+ Authentication successful!
+
+ {result && (
+
+ )}
+
+ >
+ )}
>
)}
@@ -158,11 +535,22 @@ const HooksApp = () => (
interface ClassAppState {
auth0: Auth0;
user: User | null;
- result: any; // Can hold credentials or other results
+ result: any;
apiError: Error | null;
isLoading: boolean;
email: string;
password: string;
+ mfaToken: string;
+ mfaStep: MfaStep;
+ mfaLoading: boolean;
+ authenticators: MfaAuthenticator[];
+ enrollType: EnrollType | null;
+ enrollPhoneNumber: string;
+ enrollEmail: string;
+ enrollmentChallenge: MfaEnrollmentChallenge | null;
+ challengeResult: MfaChallengeResult | null;
+ verifyCode: string;
+ verifyBindingCode: string;
}
class ClassApp extends React.Component<{}, ClassAppState> {
@@ -174,6 +562,17 @@ class ClassApp extends React.Component<{}, ClassAppState> {
isLoading: true,
email: '',
password: '',
+ mfaToken: '',
+ mfaStep: 'idle',
+ mfaLoading: false,
+ authenticators: [],
+ enrollType: null,
+ enrollPhoneNumber: '',
+ enrollEmail: '',
+ enrollmentChallenge: null,
+ challengeResult: null,
+ verifyCode: '',
+ verifyBindingCode: '',
};
componentDidMount() {
@@ -239,8 +638,146 @@ class ClassApp extends React.Component<{}, ClassAppState> {
}
};
+ resetMfaWizard = () => {
+ this.setState({
+ mfaStep: 'idle',
+ authenticators: [],
+ enrollType: null,
+ enrollPhoneNumber: '',
+ enrollEmail: '',
+ enrollmentChallenge: null,
+ challengeResult: null,
+ verifyCode: '',
+ verifyBindingCode: '',
+ mfaLoading: false,
+ });
+ };
+
+ onMfaStart = async () => {
+ this.setState({ mfaLoading: true, apiError: null });
+ try {
+ const list = await this.state.auth0
+ .mfa()
+ .getAuthenticators({ mfaToken: this.state.mfaToken });
+ this.setState({ authenticators: list, mfaStep: 'list' });
+ } catch (e) {
+ this.setState({ apiError: e as Error });
+ } finally {
+ this.setState({ mfaLoading: false });
+ }
+ };
+
+ onMfaChallenge = async (auth: MfaAuthenticator) => {
+ this.setState({ mfaLoading: true });
+ try {
+ const res = await this.state.auth0
+ .mfa()
+ .challenge({ mfaToken: this.state.mfaToken, authenticatorId: auth.id });
+ this.setState({ challengeResult: res, mfaStep: 'verify' });
+ } catch (e) {
+ this.setState({ apiError: e as Error, mfaStep: 'list' });
+ } finally {
+ this.setState({ mfaLoading: false });
+ }
+ };
+
+ onMfaEnroll = async (type?: EnrollType) => {
+ const factor = type || this.state.enrollType;
+ if (!factor) return;
+ this.setState({ mfaLoading: true });
+ try {
+ let challenge: MfaEnrollmentChallenge;
+ const {
+ mfaToken,
+ enrollPhoneNumber: phone,
+ enrollEmail: em,
+ } = this.state;
+ if (factor === MfaFactorType.SMS) {
+ challenge = await this.state.auth0
+ .mfa()
+ .enroll({
+ mfaToken,
+ factorType: MfaFactorType.SMS,
+ phoneNumber: phone,
+ });
+ } else if (factor === MfaFactorType.EMAIL) {
+ challenge = await this.state.auth0
+ .mfa()
+ .enroll({ mfaToken, factorType: MfaFactorType.EMAIL, email: em });
+ } else {
+ challenge = await this.state.auth0
+ .mfa()
+ .enroll({ mfaToken, factorType: factor });
+ }
+ this.setState({ enrollmentChallenge: challenge, mfaStep: 'verify' });
+ } catch (e) {
+ this.setState({ apiError: e as Error });
+ } finally {
+ this.setState({ mfaLoading: false });
+ }
+ };
+
+ onMfaVerify = async () => {
+ this.setState({ mfaLoading: true });
+ try {
+ const {
+ mfaToken,
+ challengeResult,
+ enrollmentChallenge,
+ verifyCode,
+ verifyBindingCode,
+ } = this.state;
+ let credentials;
+ const oobCode =
+ challengeResult?.oobCode ||
+ (enrollmentChallenge?.type === 'oob'
+ ? enrollmentChallenge.oobCode
+ : undefined);
+ if (oobCode) {
+ credentials = await this.state.auth0.mfa().verify({
+ mfaToken,
+ oobCode,
+ bindingCode: verifyBindingCode || undefined,
+ });
+ } else {
+ credentials = await this.state.auth0
+ .mfa()
+ .verify({ mfaToken, otp: verifyCode });
+ }
+ this.setState({
+ result: {
+ success: true,
+ accessToken: credentials.accessToken.substring(0, 20) + '...',
+ },
+ mfaStep: 'complete',
+ });
+ } catch (e) {
+ this.setState({ apiError: e as Error });
+ } finally {
+ this.setState({ mfaLoading: false });
+ }
+ };
+
render() {
- const { user, result, apiError, isLoading, email, password } = this.state;
+ const {
+ user,
+ result,
+ apiError,
+ isLoading,
+ email,
+ password,
+ mfaToken,
+ mfaStep,
+ mfaLoading,
+ authenticators,
+ enrollType,
+ enrollPhoneNumber,
+ enrollEmail,
+ enrollmentChallenge,
+ challengeResult,
+ verifyCode,
+ verifyBindingCode,
+ } = this.state;
if (isLoading) {
return (
@@ -283,9 +820,9 @@ class ClassApp extends React.Component<{}, ClassAppState> {
-
+
this.setState({ email: val })}
autoCapitalize="none"
@@ -297,6 +834,29 @@ class ClassApp extends React.Component<{}, ClassAppState> {
onChangeText={(val) => this.setState({ password: val })}
secureTextEntry
/>
+
+ this.runDemo(async () => {
+ try {
+ return await this.state.auth0.auth.passwordRealm({
+ username: email,
+ password,
+ realm: 'Username-Password-Authentication',
+ });
+ } catch (e: any) {
+ if (e?.json?.mfa_token) {
+ this.setState({ mfaToken: e.json.mfa_token });
+ }
+ throw e;
+ }
+ })
+ }
+ title="Log In with Password"
+ />
+
+ If MFA is enabled, a failed login will return an mfa_token that
+ auto-populates below.
+
this.runDemo(() =>
@@ -321,19 +881,226 @@ class ClassApp extends React.Component<{}, ClassAppState> {
title="Reset Password"
/>
-
-
- this.runDemo(() =>
- this.state.auth0.auth.passwordRealm({
- username: '',
- password: '',
- realm: '',
- })
- )
- }
- title="Test auth.passwordRealm()"
- />
+
+ {mfaStep === 'idle' && (
+ <>
+
+ Get an mfa_token from a password login with MFA enabled.
+
+
+ this.setState({ mfaToken: val })
+ }
+ placeholder="Paste mfa_token here"
+ />
+
+ >
+ )}
+ {mfaStep === 'list' && (
+ <>
+
+ Step 1: Select Authenticator
+
+ {authenticators.length > 0 ? (
+ authenticators.map((auth) => (
+ this.onMfaChallenge(auth)}
+ >
+
+ {auth.authenticatorType}
+ {auth.oobChannel ? ` (${auth.oobChannel})` : ''}
+
+ {auth.id}
+
+ ))
+ ) : (
+ No authenticators enrolled.
+ )}
+ this.setState({ mfaStep: 'enroll-select' })}
+ title="Enroll New Authenticator"
+ />
+
+ >
+ )}
+ {mfaStep === 'enroll-select' && (
+ <>
+
+ Step 2: Choose Factor Type
+
+ {
+ this.setState({ enrollType: MfaFactorType.OTP });
+ this.onMfaEnroll(MfaFactorType.OTP);
+ }}
+ title="TOTP (Authenticator App)"
+ disabled={mfaLoading}
+ />
+
+ this.setState({
+ enrollType: MfaFactorType.SMS,
+ mfaStep: 'enroll-details',
+ })
+ }
+ title="SMS"
+ disabled={mfaLoading}
+ />
+
+ this.setState({
+ enrollType: MfaFactorType.EMAIL,
+ mfaStep: 'enroll-details',
+ })
+ }
+ title="Email"
+ disabled={mfaLoading}
+ />
+ {
+ this.setState({ enrollType: MfaFactorType.PUSH });
+ this.onMfaEnroll(MfaFactorType.PUSH);
+ }}
+ title="Push Notification"
+ disabled={mfaLoading}
+ />
+ this.setState({ mfaStep: 'list' })}
+ title="Back"
+ />
+ >
+ )}
+ {mfaStep === 'enroll-details' && (
+ <>
+ Step 2: Enter Details
+ {enrollType === MfaFactorType.SMS && (
+ <>
+
+ this.setState({ enrollPhoneNumber: val })
+ }
+ placeholder="+12025550135"
+ />
+ this.onMfaEnroll()}
+ title="Enroll SMS"
+ disabled={!enrollPhoneNumber || mfaLoading}
+ />
+ >
+ )}
+ {enrollType === MfaFactorType.EMAIL && (
+ <>
+
+ this.setState({ enrollEmail: val })
+ }
+ placeholder="user@example.com"
+ />
+ this.onMfaEnroll()}
+ title="Enroll Email"
+ disabled={!enrollEmail || mfaLoading}
+ />
+ >
+ )}
+ this.setState({ mfaStep: 'enroll-select' })}
+ title="Back"
+ />
+ >
+ )}
+ {mfaStep === 'verify' && (
+ <>
+ Step 3: Verify
+ {enrollmentChallenge?.type === 'totp' && (
+
+ {enrollmentChallenge.barcodeUri && (
+ <>
+
+
+
+
+ Linking.openURL(enrollmentChallenge.barcodeUri!)
+ }
+ title="Open in Authenticator App"
+ />
+ >
+ )}
+ Secret: {enrollmentChallenge.secret}
+
+ )}
+ {challengeResult?.challengeType === 'oob' ||
+ enrollmentChallenge?.type === 'oob' ? (
+ <>
+
+ A code has been sent. Enter the binding code below.
+
+
+ this.setState({ verifyBindingCode: val })
+ }
+ placeholder="Code from SMS/email"
+ />
+
+ >
+ ) : (
+ <>
+
+ this.setState({ verifyCode: val })
+ }
+ placeholder="6-digit code"
+ />
+
+ >
+ )}
+ this.setState({ mfaStep: 'list' })}
+ title="Back"
+ />
+ >
+ )}
+ {mfaStep === 'complete' && (
+ <>
+
+ Authentication successful!
+
+ {result && (
+
+ )}
+
+ >
+ )}
>
)}
@@ -402,6 +1169,7 @@ const styles = StyleSheet.create({
},
sectionTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 12 },
buttonGroup: { gap: 10 },
+ hint: { fontSize: 12, color: '#888', fontStyle: 'italic', marginBottom: 8 },
toggleContainer: {
padding: 16,
alignItems: 'center',
@@ -412,4 +1180,32 @@ const styles = StyleSheet.create({
toggleButton: { backgroundColor: '#6c757d' },
});
+const webStyles = StyleSheet.create({
+ authItem: {
+ borderWidth: 1,
+ borderColor: '#CCC',
+ borderRadius: 6,
+ padding: 12,
+ backgroundColor: '#F9F9F9',
+ marginBottom: 8,
+ },
+ authItemTitle: { fontSize: 14, fontWeight: '600' },
+ authItemSub: { fontSize: 11, color: '#666', marginTop: 2 },
+ infoBox: {
+ backgroundColor: '#F0F4FF',
+ borderRadius: 6,
+ padding: 10,
+ marginBottom: 8,
+ },
+ successText: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#2E7D32',
+ textAlign: 'center',
+ marginBottom: 12,
+ },
+ qrContainer: { alignItems: 'center', marginVertical: 12 },
+ qrImage: { width: 200, height: 200 },
+});
+
export default App;
diff --git a/example/src/screens/class-based/ClassApiTests.tsx b/example/src/screens/class-based/ClassApiTests.tsx
index 5b2c095c..05aa3cea 100644
--- a/example/src/screens/class-based/ClassApiTests.tsx
+++ b/example/src/screens/class-based/ClassApiTests.tsx
@@ -24,6 +24,7 @@ const ClassApiTestsScreen = ({ route }: Props) => {
const [password, setPassword] = useState('P@ssword123'); // dummy password
const [mfaToken, setMfaToken] = useState('');
const [otp, setOtp] = useState('');
+ const [authenticatorId, setAuthenticatorId] = useState('');
const [refreshToken, setRefreshToken] = useState('');
const runTest = async (testFn: () => Promise, title: string) => {
@@ -126,19 +127,68 @@ const ClassApiTestsScreen = ({ route }: Props) => {
/>
-
+
+
+ Uses auth0.mfa() class-based API for flexible MFA operations.
+
+
+ runTest(
+ () => auth0.mfa().getAuthenticators({ mfaToken }),
+ 'List Authenticators'
+ )
+ }
+ title="mfa().getAuthenticators()"
+ disabled={!mfaToken}
+ />
+
+ runTest(
+ () => auth0.mfa().enroll({ mfaToken, type: 'otp' }),
+ 'Enroll TOTP'
+ )
+ }
+ title="mfa().enroll(otp)"
+ disabled={!mfaToken}
+ />
+
+
+ runTest(
+ () => auth0.mfa().challenge({ mfaToken, authenticatorId }),
+ 'Challenge'
+ )
+ }
+ title="mfa().challenge()"
+ disabled={!mfaToken || !authenticatorId}
+ />
+
+ runTest(() => auth0.mfa().verify({ mfaToken, otp }), 'Verify OTP')
+ }
+ title="mfa().verify(otp)"
+ disabled={!mfaToken || !otp}
+ />
+
+
+
runTest(
@@ -176,9 +226,6 @@ const ClassApiTestsScreen = ({ route }: Props) => {
disabled={!refreshToken}
/>
-
- {/* Other methods can be added here following the same pattern */}
- {/* e.g., exchange, exchangeNativeSocial, other passwordless/MFA flows */}
);
@@ -221,6 +268,7 @@ const styles = StyleSheet.create({
buttonGroup: {
gap: 10,
},
+ hint: { fontSize: 12, color: '#888', fontStyle: 'italic' },
});
export default ClassApiTestsScreen;
diff --git a/example/src/screens/class-based/ClassLogin.tsx b/example/src/screens/class-based/ClassLogin.tsx
index 8a9bd8e8..381c8ae4 100644
--- a/example/src/screens/class-based/ClassLogin.tsx
+++ b/example/src/screens/class-based/ClassLogin.tsx
@@ -1,12 +1,33 @@
-// example/src/screens/class-based/ClassLogin.tsx
-
import React, { useState } from 'react';
-import { SafeAreaView, View, StyleSheet } from 'react-native';
+import {
+ SafeAreaView,
+ ScrollView,
+ View,
+ Text,
+ StyleSheet,
+ Alert,
+ TouchableOpacity,
+ Image,
+ Linking,
+} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { StackNavigationProp } from '@react-navigation/stack';
-import auth0 from '../../api/auth0'; // Import our singleton instance
+import {
+ MfaError,
+ MfaErrorCodes,
+ MfaFactorType,
+ WebAuthError,
+ WebAuthErrorCodes,
+} from 'react-native-auth0';
+import type {
+ MfaAuthenticator,
+ MfaEnrollmentChallenge,
+ MfaChallengeResult,
+} from 'react-native-auth0';
+import auth0 from '../../api/auth0';
import Button from '../../components/Button';
import Header from '../../components/Header';
+import LabeledInput from '../../components/LabeledInput';
import Result from '../../components/Result';
import type { ClassDemoStackParamList } from '../../navigation/ClassDemoNavigator';
import config from '../../auth0-configuration';
@@ -16,11 +37,63 @@ type NavigationProp = StackNavigationProp<
'ClassLogin'
>;
+type MfaStep =
+ | 'idle'
+ | 'list'
+ | 'enroll-select'
+ | 'enroll-details'
+ | 'challenge'
+ | 'verify'
+ | 'complete';
+
+type EnrollType = MfaFactorType;
+
const ClassLoginScreen = () => {
const [error, setError] = useState(null);
+ const [result, setResult] = useState
@@ -132,7 +563,6 @@ const HomeScreen = () => {
keyboardType="email-address"
/>
-
{showOtpInput && (
<>
{
>
)}
+
+
);
@@ -165,7 +599,7 @@ const Section = ({
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#FFFFFF' },
- content: { padding: 16, gap: 20 },
+ content: { padding: 16, gap: 20, paddingBottom: 50 },
title: {
fontSize: 24,
fontWeight: 'bold',
@@ -180,6 +614,43 @@ const styles = StyleSheet.create({
gap: 10,
},
sectionTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 8 },
+ stepTitle: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: '#333',
+ marginBottom: 4,
+ },
+ hint: { fontSize: 12, color: '#888', fontStyle: 'italic' },
+ authItem: {
+ borderWidth: 1,
+ borderColor: '#CCC',
+ borderRadius: 6,
+ padding: 12,
+ backgroundColor: '#F9F9F9',
+ },
+ authItemTitle: { fontSize: 14, fontWeight: '600' },
+ authItemSubtitle: { fontSize: 11, color: '#666', marginTop: 2 },
+ divider: {
+ height: 1,
+ backgroundColor: '#E0E0E0',
+ marginVertical: 8,
+ },
+ infoBox: {
+ backgroundColor: '#F0F4FF',
+ borderRadius: 6,
+ padding: 10,
+ gap: 4,
+ },
+ infoLabel: { fontSize: 12, fontWeight: '600', color: '#444' },
+ infoValue: { fontSize: 12, color: '#333', fontFamily: 'monospace' },
+ qrContainer: { alignItems: 'center', marginVertical: 12 },
+ qrImage: { width: 200, height: 200 },
+ successText: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#2E7D32',
+ textAlign: 'center',
+ },
});
export default HomeScreen;
diff --git a/ios/A0Auth0.mm b/ios/A0Auth0.mm
index 94223abd..6ba8a2d5 100644
--- a/ios/A0Auth0.mm
+++ b/ios/A0Auth0.mm
@@ -178,8 +178,36 @@ - (dispatch_queue_t)methodQueue
[self.nativeBridge customTokenExchangeWithSubjectToken:subjectToken subjectTokenType:subjectTokenType audience:audience scope:scope organization:organization resolve:resolve reject:reject];
}
+RCT_EXPORT_METHOD(getMfaAuthenticators:(NSString *)mfaToken
+ factorsAllowed:(NSArray * _Nullable)factorsAllowed
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject) {
+ [self.nativeBridge getMfaAuthenticatorsWithMfaToken:mfaToken factorsAllowed:factorsAllowed resolve:resolve reject:reject];
+}
+RCT_EXPORT_METHOD(mfaEnroll:(NSString *)mfaToken
+ type:(NSString *)type
+ value:(NSString * _Nullable)value
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject) {
+ [self.nativeBridge mfaEnrollWithMfaToken:mfaToken type:type value:value resolve:resolve reject:reject];
+}
+RCT_EXPORT_METHOD(mfaChallenge:(NSString *)mfaToken
+ authenticatorId:(NSString *)authenticatorId
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject) {
+ [self.nativeBridge mfaChallengeWithMfaToken:mfaToken authenticatorId:authenticatorId resolve:resolve reject:reject];
+}
+
+RCT_EXPORT_METHOD(mfaVerify:(NSString *)mfaToken
+ type:(NSString *)type
+ code:(NSString *)code
+ bindingCode:(NSString * _Nullable)bindingCode
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject) {
+ [self.nativeBridge mfaVerifyWithMfaToken:mfaToken type:type code:code bindingCode:bindingCode resolve:resolve reject:reject];
+}
diff --git a/ios/A0MfaClient.swift b/ios/A0MfaClient.swift
new file mode 100644
index 00000000..ccf08293
--- /dev/null
+++ b/ios/A0MfaClient.swift
@@ -0,0 +1,200 @@
+//
+// A0MfaClient.swift
+// A0Auth0
+//
+// Copyright © 2025 Facebook. All rights reserved.
+//
+
+import Auth0
+import Foundation
+
+/// A dedicated bridge class for MFA (Multi-Factor Authentication) operations.
+/// Encapsulates all MFA-related native SDK interactions, keeping them separate
+/// from the main NativeBridge for better organization and maintainability.
+enum MfaFactorType: String {
+ case phone
+ case email
+ case otp
+ case push
+ case voice
+}
+
+enum MfaVerificationType: String {
+ case otp
+ case oob
+ case recoveryCode
+}
+
+class A0MfaClient {
+
+ private let clientId: String
+ private let domain: String
+ private let useDPoP: Bool
+
+ private lazy var authentication: Authentication = {
+ var auth = Auth0.authentication(clientId: clientId, domain: domain)
+ if useDPoP {
+ auth = auth.useDPoP()
+ }
+ return auth
+ }()
+
+ init(clientId: String, domain: String, useDPoP: Bool) {
+ self.clientId = clientId
+ self.domain = domain
+ self.useDPoP = useDPoP
+ }
+
+ func getAuthenticators(mfaToken: String, factorsAllowed: [String]?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
+ let mfaClient = authentication.mfa(mfaToken: mfaToken)
+ let allowedFactors = factorsAllowed ?? []
+
+ mfaClient.getAuthenticators(factorsAllowed: allowedFactors).start { result in
+ switch result {
+ case .success(let authenticators):
+ let list = authenticators.map { authenticator -> [String: Any] in
+ var dict: [String: Any] = [
+ "id": authenticator.id,
+ "authenticatorType": authenticator.authenticatorType,
+ "active": authenticator.active
+ ]
+ if let name = authenticator.name { dict["name"] = name }
+ if let oobChannel = authenticator.oobChannel { dict["oobChannel"] = oobChannel }
+ return dict
+ }
+ resolve(list)
+ case .failure(let error):
+ reject(error.code, error.localizedDescription, error)
+ }
+ }
+ }
+
+ func enroll(mfaToken: String, type: String, value: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
+ guard let factorType = MfaFactorType(rawValue: type) else {
+ reject("MFA_ENROLLMENT_ERROR", "Unsupported enrollment type: \(type)", nil)
+ return
+ }
+
+ let mfaClient = authentication.mfa(mfaToken: mfaToken)
+
+ switch factorType {
+ case .phone, .voice:
+ guard let phoneNumber = value else {
+ reject("MFA_ENROLLMENT_ERROR", "Phone number is required for phone enrollment", nil)
+ return
+ }
+ mfaClient.enroll(mfaToken: mfaToken, phoneNumber: phoneNumber).start { result in
+ switch result {
+ case .success(let challenge):
+ var dict: [String: Any] = ["type": "oob"]
+ dict["oobCode"] = challenge.oobCode
+ if let bindingMethod = challenge.bindingMethod { dict["bindingMethod"] = bindingMethod }
+ if let recoveryCodes = challenge.recoveryCodes { dict["recoveryCodes"] = recoveryCodes }
+ resolve(dict)
+ case .failure(let error):
+ reject(error.code, error.localizedDescription, error)
+ }
+ }
+ case .email:
+ guard let email = value else {
+ reject("MFA_ENROLLMENT_ERROR", "Email is required for email enrollment", nil)
+ return
+ }
+ mfaClient.enroll(mfaToken: mfaToken, email: email).start { result in
+ switch result {
+ case .success(let challenge):
+ var dict: [String: Any] = ["type": "oob"]
+ dict["oobCode"] = challenge.oobCode
+ if let bindingMethod = challenge.bindingMethod { dict["bindingMethod"] = bindingMethod }
+ if let recoveryCodes = challenge.recoveryCodes { dict["recoveryCodes"] = recoveryCodes }
+ resolve(dict)
+ case .failure(let error):
+ reject(error.code, error.localizedDescription, error)
+ }
+ }
+ case .otp:
+ mfaClient.enroll(mfaToken: mfaToken).start { result in
+ switch result {
+ case .success(let challenge):
+ var dict: [String: Any] = ["type": "totp"]
+ dict["barcodeUri"] = challenge.barcode
+ dict["secret"] = challenge.secret
+ if let recoveryCodes = challenge.recoveryCodes { dict["recoveryCodes"] = recoveryCodes }
+ resolve(dict)
+ case .failure(let error):
+ reject(error.code, error.localizedDescription, error)
+ }
+ }
+ case .push:
+ mfaClient.enroll(mfaToken: mfaToken).start { result in
+ switch result {
+ case .success(let challenge):
+ var dict: [String: Any] = ["type": "oob"]
+ dict["oobCode"] = challenge.oobCode
+ if let bindingMethod = challenge.bindingMethod { dict["bindingMethod"] = bindingMethod }
+ if let recoveryCodes = challenge.recoveryCodes { dict["recoveryCodes"] = recoveryCodes }
+ resolve(dict)
+ case .failure(let error):
+ reject(error.code, error.localizedDescription, error)
+ }
+ }
+ }
+ }
+
+ func challenge(mfaToken: String, authenticatorId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
+ let mfaClient = authentication.mfa(mfaToken: mfaToken)
+
+ mfaClient.challenge(with: authenticatorId, mfaToken: mfaToken).start { result in
+ switch result {
+ case .success(let challenge):
+ var dict: [String: Any] = [
+ "challengeType": challenge.challengeType
+ ]
+ if let oobCode = challenge.oobCode { dict["oobCode"] = oobCode }
+ if let bindingMethod = challenge.bindingMethod { dict["bindingMethod"] = bindingMethod }
+ resolve(dict)
+ case .failure(let error):
+ reject(error.code, error.localizedDescription, error)
+ }
+ }
+ }
+
+ func verify(mfaToken: String, type: String, code: String, bindingCode: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
+ guard let verificationType = MfaVerificationType(rawValue: type) else {
+ reject("MFA_VERIFY_ERROR", "Unsupported verification type: \(type)", nil)
+ return
+ }
+
+ let mfaClient = authentication.mfa(mfaToken: mfaToken)
+
+ switch verificationType {
+ case .otp:
+ mfaClient.verify(otp: code, mfaToken: mfaToken).start { result in
+ switch result {
+ case .success(let credentials):
+ resolve(credentials.asDictionary())
+ case .failure(let error):
+ reject(error.code, error.localizedDescription, error)
+ }
+ }
+ case .oob:
+ mfaClient.verify(oobCode: code, bindingCode: bindingCode, mfaToken: mfaToken).start { result in
+ switch result {
+ case .success(let credentials):
+ resolve(credentials.asDictionary())
+ case .failure(let error):
+ reject(error.code, error.localizedDescription, error)
+ }
+ }
+ case .recoveryCode:
+ mfaClient.verify(recoveryCode: code, mfaToken: mfaToken).start { result in
+ switch result {
+ case .success(let credentials):
+ resolve(credentials.asDictionary())
+ case .failure(let error):
+ reject(error.code, error.localizedDescription, error)
+ }
+ }
+ }
+ }
+}
diff --git a/ios/NativeBridge.swift b/ios/NativeBridge.swift
index e8a3d270..1ea5a69b 100644
--- a/ios/NativeBridge.swift
+++ b/ios/NativeBridge.swift
@@ -44,6 +44,9 @@ public class NativeBridge: NSObject {
var domain: String
var useDPoP: Bool
var maxRetries: Int
+ private(set) lazy var mfaClient: A0MfaClient = {
+ A0MfaClient(clientId: self.clientId, domain: self.domain, useDPoP: self.useDPoP)
+ }()
@objc public init(clientId: String, domain: String, localAuthenticationOptions: [String: Any]?, useDPoP: Bool, maxRetries: Int, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
var auth0 = Auth0
@@ -404,10 +407,28 @@ public class NativeBridge: NSObject {
}
}
+ // MARK: - MFA Flexible Factors Grant
+
+ @objc public func getMfaAuthenticators(mfaToken: String, factorsAllowed: [String]?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
+ mfaClient.getAuthenticators(mfaToken: mfaToken, factorsAllowed: factorsAllowed, resolve: resolve, reject: reject)
+ }
+
+ @objc public func mfaEnroll(mfaToken: String, type: String, value: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
+ mfaClient.enroll(mfaToken: mfaToken, type: type, value: value, resolve: resolve, reject: reject)
+ }
+
+ @objc public func mfaChallenge(mfaToken: String, authenticatorId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
+ mfaClient.challenge(mfaToken: mfaToken, authenticatorId: authenticatorId, resolve: resolve, reject: reject)
+ }
+
+ @objc public func mfaVerify(mfaToken: String, type: String, code: String, bindingCode: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
+ mfaClient.verify(mfaToken: mfaToken, type: type, code: code, bindingCode: bindingCode, resolve: resolve, reject: reject)
+ }
+
@objc public func getClientId() -> String {
return clientId
}
-
+
@objc public func getDomain() -> String {
return domain
}
diff --git a/package.json b/package.json
index c83a2473..192c5929 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "react-native-auth0",
"title": "React Native Auth0",
- "version": "5.5.0",
+ "version": "5.5.1",
"description": "React Native toolkit for Auth0 API",
"main": "lib/commonjs/index.js",
"module": "lib/module/index.js",
diff --git a/src/Auth0.ts b/src/Auth0.ts
index d01ec99b..1ba11f30 100644
--- a/src/Auth0.ts
+++ b/src/Auth0.ts
@@ -1,4 +1,5 @@
import type { IAuth0Client } from './core/interfaces/IAuth0Client';
+import type { IMfaClient } from './core/interfaces/IMfaClient';
import type { TokenType } from './types/common';
import { Auth0ClientFactory } from './factory/Auth0ClientFactory';
import type {
@@ -121,6 +122,25 @@ class Auth0 {
): Promise {
return this.client.customTokenExchange(parameters);
}
+
+ /**
+ * Provides access to MFA operations using the Flexible Factors Grant.
+ *
+ * The MFA client provides methods to list authenticators, enroll new MFA
+ * factors, challenge existing factors, and verify MFA codes.
+ *
+ * @example
+ * ```typescript
+ * // List enrolled authenticators
+ * const authenticators = await auth0.mfa.getAuthenticators({ mfaToken });
+ *
+ * // Verify with OTP
+ * const credentials = await auth0.mfa.verify({ mfaToken, otp: '123456' });
+ * ```
+ */
+ get mfa(): IMfaClient {
+ return this.client.mfa;
+ }
}
export default Auth0;
diff --git a/src/core/interfaces/IAuth0Client.ts b/src/core/interfaces/IAuth0Client.ts
index f54cb58b..0081e54b 100644
--- a/src/core/interfaces/IAuth0Client.ts
+++ b/src/core/interfaces/IAuth0Client.ts
@@ -2,6 +2,7 @@ import type { IWebAuthProvider } from './IWebAuthProvider';
import type { ICredentialsManager } from './ICredentialsManager';
import type { IAuthenticationProvider } from './IAuthenticationProvider';
import type { IUsersClient } from './IUsersClient';
+import type { IMfaClient } from './IMfaClient';
import type {
DPoPHeadersParams,
TokenType,
@@ -74,4 +75,24 @@ export interface IAuth0Client {
customTokenExchange(
parameters: CustomTokenExchangeParameters
): Promise;
+
+ /**
+ * Provides access to MFA operations using the Flexible Factors Grant.
+ *
+ * The MFA client provides methods to list authenticators, enroll new MFA
+ * factors, challenge existing factors, and verify MFA codes.
+ *
+ * @example
+ * ```typescript
+ * // List enrolled authenticators
+ * const authenticators = await auth0.mfa.getAuthenticators({ mfaToken });
+ *
+ * // Challenge an authenticator
+ * const challenge = await auth0.mfa.challenge({ mfaToken, authenticatorId });
+ *
+ * // Verify with OTP
+ * const credentials = await auth0.mfa.verify({ mfaToken, otp: '123456' });
+ * ```
+ */
+ readonly mfa: IMfaClient;
}
diff --git a/src/core/interfaces/IMfaClient.ts b/src/core/interfaces/IMfaClient.ts
new file mode 100644
index 00000000..93d00326
--- /dev/null
+++ b/src/core/interfaces/IMfaClient.ts
@@ -0,0 +1,55 @@
+import type {
+ Credentials,
+ MfaAuthenticator,
+ MfaEnrollmentChallenge,
+ MfaChallengeResult,
+ MfaGetAuthenticatorsParameters,
+ MfaEnrollParameters,
+ MfaChallengeWithAuthenticatorParameters,
+ MfaVerifyParameters,
+} from '../../types';
+
+/**
+ * Defines the contract for MFA operations using the Flexible Factors Grant.
+ *
+ * An MFA client is scoped to a single MFA flow, initialized with an mfaToken
+ * from an MFA_REQUIRED error. It provides methods to list authenticators,
+ * enroll new factors, challenge existing factors, and verify MFA codes.
+ */
+export interface IMfaClient {
+ /**
+ * Lists the user's enrolled MFA authenticators.
+ *
+ * @param parameters Parameters including the MFA token and optional factor filter.
+ * @returns A promise that resolves with the list of enrolled authenticators.
+ */
+ getAuthenticators(
+ parameters: MfaGetAuthenticatorsParameters
+ ): Promise;
+
+ /**
+ * Enrolls a new MFA factor for the user.
+ *
+ * @param parameters Parameters specifying the factor type and any required values.
+ * @returns A promise that resolves with the enrollment challenge details.
+ */
+ enroll(parameters: MfaEnrollParameters): Promise;
+
+ /**
+ * Requests an MFA challenge for a specific enrolled authenticator.
+ *
+ * @param parameters Parameters including the MFA token and authenticator ID.
+ * @returns A promise that resolves with the challenge details.
+ */
+ challenge(
+ parameters: MfaChallengeWithAuthenticatorParameters
+ ): Promise;
+
+ /**
+ * Verifies an MFA code and returns credentials on success.
+ *
+ * @param parameters Parameters for verification (OTP, OOB, or recovery code).
+ * @returns A promise that resolves with the user's credentials.
+ */
+ verify(parameters: MfaVerifyParameters): Promise;
+}
diff --git a/src/core/interfaces/index.ts b/src/core/interfaces/index.ts
index 4cdbbd99..2d48e10a 100644
--- a/src/core/interfaces/index.ts
+++ b/src/core/interfaces/index.ts
@@ -4,3 +4,4 @@ export * from './IAuthenticationProvider';
export * from './ICredentialsManager';
export * from './IWebAuthProvider';
export * from './IUsersClient';
+export * from './IMfaClient';
diff --git a/src/core/models/MfaError.ts b/src/core/models/MfaError.ts
new file mode 100644
index 00000000..8fb99f74
--- /dev/null
+++ b/src/core/models/MfaError.ts
@@ -0,0 +1,153 @@
+import { AuthError } from './AuthError';
+
+/**
+ * Platform-agnostic error code constants for MFA (Multi-Factor Authentication) operations.
+ *
+ * Use these constants for type-safe error handling when working with MFA operations
+ * like getAuthenticators, enroll, challenge, and verify.
+ * Each constant corresponds to a specific error type in the {@link MfaError.type} property.
+ *
+ * @example
+ * ```typescript
+ * import { MfaError, MfaErrorCodes } from 'react-native-auth0';
+ *
+ * try {
+ * const credentials = await auth0.mfa.verify({ mfaToken, otp: '123456' });
+ * } catch (e) {
+ * if (e instanceof MfaError) {
+ * switch (e.type) {
+ * case MfaErrorCodes.INVALID_OTP:
+ * // OTP code is incorrect
+ * break;
+ * case MfaErrorCodes.EXPIRED_MFA_TOKEN:
+ * // MFA token has expired - restart MFA flow
+ * break;
+ * case MfaErrorCodes.TOO_MANY_ATTEMPTS:
+ * // Rate limited - wait before retrying
+ * break;
+ * }
+ * }
+ * }
+ * ```
+ *
+ * @see {@link MfaError}
+ */
+export const MfaErrorCodes = {
+ /** OTP code provided is invalid */
+ INVALID_OTP: 'INVALID_OTP',
+ /** OOB code provided is invalid */
+ INVALID_OOB_CODE: 'INVALID_OOB_CODE',
+ /** Binding code provided is invalid */
+ INVALID_BINDING_CODE: 'INVALID_BINDING_CODE',
+ /** Recovery code provided is invalid */
+ INVALID_RECOVERY_CODE: 'INVALID_RECOVERY_CODE',
+ /** MFA enrollment failed */
+ ENROLLMENT_FAILED: 'ENROLLMENT_FAILED',
+ /** Phone number provided is invalid for enrollment */
+ INVALID_PHONE_NUMBER: 'INVALID_PHONE_NUMBER',
+ /** Email provided is invalid for enrollment */
+ INVALID_EMAIL: 'INVALID_EMAIL',
+ /** MFA token has expired - restart MFA flow */
+ EXPIRED_MFA_TOKEN: 'EXPIRED_MFA_TOKEN',
+ /** MFA token is invalid */
+ INVALID_MFA_TOKEN: 'INVALID_MFA_TOKEN',
+ /** Too many verification attempts - rate limited */
+ TOO_MANY_ATTEMPTS: 'TOO_MANY_ATTEMPTS',
+ /** MFA challenge request failed */
+ CHALLENGE_FAILED: 'CHALLENGE_FAILED',
+ /** Authenticator not found or not enrolled */
+ AUTHENTICATOR_NOT_FOUND: 'AUTHENTICATOR_NOT_FOUND',
+ /** MFA factor type is not supported */
+ UNSUPPORTED_FACTOR: 'UNSUPPORTED_FACTOR',
+ /** User must enroll before using the authenticator */
+ ASSOCIATION_REQUIRED: 'ASSOCIATION_REQUIRED',
+ /** Generic MFA error */
+ MFA_ERROR: 'MFA_ERROR',
+ /** Unknown or uncategorized MFA error */
+ UNKNOWN_MFA_ERROR: 'UNKNOWN_MFA_ERROR',
+} as const;
+
+const ERROR_CODE_MAP: Record = {
+ // --- Auth0 API error codes (returned by both native SDKs and web) ---
+ invalid_otp: MfaErrorCodes.INVALID_OTP,
+ invalid_oob_code: MfaErrorCodes.INVALID_OOB_CODE,
+ invalid_binding_code: MfaErrorCodes.INVALID_BINDING_CODE,
+ invalid_recovery_code: MfaErrorCodes.INVALID_RECOVERY_CODE,
+ invalid_grant: MfaErrorCodes.INVALID_OTP,
+ mfa_token_invalid: MfaErrorCodes.INVALID_MFA_TOKEN,
+ expired_token: MfaErrorCodes.EXPIRED_MFA_TOKEN,
+ too_many_attempts: MfaErrorCodes.TOO_MANY_ATTEMPTS,
+ unsupported_challenge_type: MfaErrorCodes.UNSUPPORTED_FACTOR,
+ association_required: MfaErrorCodes.ASSOCIATION_REQUIRED,
+ invalid_phone_number: MfaErrorCodes.INVALID_PHONE_NUMBER,
+ invalid_email: MfaErrorCodes.INVALID_EMAIL,
+
+ // --- Native bridge local validation errors ---
+ MFA_ENROLLMENT_ERROR: MfaErrorCodes.ENROLLMENT_FAILED,
+ MFA_VERIFY_ERROR: MfaErrorCodes.MFA_ERROR,
+ MFA_CHALLENGE_ERROR: MfaErrorCodes.CHALLENGE_FAILED,
+
+ // --- Web (spa-js) error codes ---
+ mfa_enrollment_failed: MfaErrorCodes.ENROLLMENT_FAILED,
+ mfa_list_authenticators_failed: MfaErrorCodes.MFA_ERROR,
+ mfa_challenge_failed: MfaErrorCodes.CHALLENGE_FAILED,
+ mfa_verify_failed: MfaErrorCodes.MFA_ERROR,
+
+ // --- Generic fallbacks ---
+ UNKNOWN: MfaErrorCodes.UNKNOWN_MFA_ERROR,
+ OTHER: MfaErrorCodes.UNKNOWN_MFA_ERROR,
+};
+
+/**
+ * Represents an error that occurred during MFA (Multi-Factor Authentication) operations.
+ *
+ * This class wraps authentication errors related to MFA functionality, including:
+ * - Listing enrolled authenticators
+ * - Enrolling new MFA factors (OTP, SMS, email, push)
+ * - Requesting MFA challenges
+ * - Verifying MFA codes (OTP, OOB, recovery codes)
+ *
+ * The `type` property provides a normalized, platform-agnostic error code that
+ * applications can use for consistent error handling across iOS, Android, and Web.
+ *
+ * @example
+ * ```typescript
+ * import { MfaError, MfaErrorCodes } from 'react-native-auth0';
+ *
+ * try {
+ * const credentials = await auth0.mfa.verify({ mfaToken, otp: '123456' });
+ * } catch (error) {
+ * if (error instanceof MfaError) {
+ * switch (error.type) {
+ * case MfaErrorCodes.INVALID_OTP:
+ * // Show "incorrect code" message
+ * break;
+ * case MfaErrorCodes.EXPIRED_MFA_TOKEN:
+ * // Restart MFA flow
+ * break;
+ * case MfaErrorCodes.TOO_MANY_ATTEMPTS:
+ * // Show rate limit message
+ * break;
+ * }
+ * }
+ * }
+ * ```
+ */
+export class MfaError extends AuthError {
+ /**
+ * A normalized error type that is consistent across platforms.
+ * This can be used for reliable error handling in application code.
+ */
+ public readonly type: string;
+
+ constructor(originalError: AuthError) {
+ super(originalError.name, originalError.message, {
+ status: originalError.status,
+ code: originalError.code,
+ json: originalError.json,
+ });
+
+ this.type =
+ ERROR_CODE_MAP[originalError.code] || MfaErrorCodes.UNKNOWN_MFA_ERROR;
+ }
+}
diff --git a/src/core/models/__tests__/ErrorCodes.spec.ts b/src/core/models/__tests__/ErrorCodes.spec.ts
index 71171e68..f6e2e4ce 100644
--- a/src/core/models/__tests__/ErrorCodes.spec.ts
+++ b/src/core/models/__tests__/ErrorCodes.spec.ts
@@ -2,6 +2,7 @@ import {
WebAuthErrorCodes,
CredentialsManagerErrorCodes,
DPoPErrorCodes,
+ MfaErrorCodes,
} from '../';
describe('Error Code Constants', () => {
@@ -237,6 +238,27 @@ describe('Error Code Constants', () => {
expect(webAuthOverlaps).toEqual([]);
expect(credentialsOverlaps).toEqual([]);
});
+
+ it('should not have overlapping error codes between MFA and other error types', () => {
+ const mfaArray = Object.values(MfaErrorCodes);
+ const webAuthArray = Object.values(WebAuthErrorCodes);
+ const credentialsArray = Object.values(CredentialsManagerErrorCodes);
+ const dpopArray = Object.values(DPoPErrorCodes);
+
+ const webAuthOverlaps = mfaArray.filter((code) =>
+ webAuthArray.includes(code as any)
+ );
+ const credentialsOverlaps = mfaArray.filter((code) =>
+ credentialsArray.includes(code as any)
+ );
+ const dpopOverlaps = mfaArray.filter((code) =>
+ dpopArray.includes(code as any)
+ );
+
+ expect(webAuthOverlaps).toEqual([]);
+ expect(credentialsOverlaps).toEqual([]);
+ expect(dpopOverlaps).toEqual([]);
+ });
});
describe('TypeScript Type Safety', () => {
diff --git a/src/core/models/__tests__/MfaError.spec.ts b/src/core/models/__tests__/MfaError.spec.ts
new file mode 100644
index 00000000..97f10e13
--- /dev/null
+++ b/src/core/models/__tests__/MfaError.spec.ts
@@ -0,0 +1,127 @@
+import { AuthError, MfaError, MfaErrorCodes } from '../';
+
+describe('MfaError', () => {
+ it('should be an instance of AuthError', () => {
+ const original = new AuthError('invalid_otp', 'Invalid OTP code', {
+ code: 'invalid_otp',
+ status: 403,
+ });
+ const error = new MfaError(original);
+ expect(error).toBeInstanceOf(AuthError);
+ expect(error).toBeInstanceOf(MfaError);
+ });
+
+ it('should preserve the original error properties', () => {
+ const original = new AuthError('invalid_otp', 'Invalid OTP code', {
+ code: 'invalid_otp',
+ status: 403,
+ json: { error: 'invalid_otp' },
+ });
+ const error = new MfaError(original);
+
+ expect(error.name).toBe('invalid_otp');
+ expect(error.message).toBe('Invalid OTP code');
+ expect(error.code).toBe('invalid_otp');
+ expect(error.status).toBe(403);
+ expect(error.json).toEqual({ error: 'invalid_otp' });
+ });
+
+ describe('error code mapping', () => {
+ const testCases: [string, string, string][] = [
+ ['invalid_otp', 'INVALID_OTP', 'Auth0 API OTP error'],
+ ['invalid_oob_code', 'INVALID_OOB_CODE', 'Auth0 API OOB error'],
+ [
+ 'invalid_binding_code',
+ 'INVALID_BINDING_CODE',
+ 'Auth0 API binding code error',
+ ],
+ [
+ 'invalid_recovery_code',
+ 'INVALID_RECOVERY_CODE',
+ 'Auth0 API recovery code error',
+ ],
+ ['invalid_grant', 'INVALID_OTP', 'Auth0 API generic grant error for MFA'],
+ ['mfa_token_invalid', 'INVALID_MFA_TOKEN', 'invalid MFA token'],
+ ['expired_token', 'EXPIRED_MFA_TOKEN', 'expired MFA token'],
+ ['too_many_attempts', 'TOO_MANY_ATTEMPTS', 'rate limit'],
+ [
+ 'unsupported_challenge_type',
+ 'UNSUPPORTED_FACTOR',
+ 'unsupported factor',
+ ],
+ ['association_required', 'ASSOCIATION_REQUIRED', 'association required'],
+ ['invalid_phone_number', 'INVALID_PHONE_NUMBER', 'invalid phone'],
+ ['invalid_email', 'INVALID_EMAIL', 'invalid email'],
+ ['MFA_ENROLLMENT_ERROR', 'ENROLLMENT_FAILED', 'native enrollment error'],
+ ['MFA_VERIFY_ERROR', 'MFA_ERROR', 'native verify error'],
+ ['MFA_CHALLENGE_ERROR', 'CHALLENGE_FAILED', 'native challenge error'],
+ ['mfa_enrollment_failed', 'ENROLLMENT_FAILED', 'web enrollment error'],
+ ['mfa_list_authenticators_failed', 'MFA_ERROR', 'web list error'],
+ ['mfa_challenge_failed', 'CHALLENGE_FAILED', 'web challenge error'],
+ ['mfa_verify_failed', 'MFA_ERROR', 'web verify error'],
+ ];
+
+ it.each(testCases)(
+ 'should map code "%s" to type "%s" (%s)',
+ (code, expectedType) => {
+ const original = new AuthError('error', 'message', { code });
+ const error = new MfaError(original);
+ expect(error.type).toBe(expectedType);
+ }
+ );
+
+ it('should fall back to UNKNOWN_MFA_ERROR for unmapped codes', () => {
+ const original = new AuthError('some_error', 'Something', {
+ code: 'completely_unknown_code',
+ });
+ const error = new MfaError(original);
+ expect(error.type).toBe(MfaErrorCodes.UNKNOWN_MFA_ERROR);
+ });
+ });
+});
+
+describe('MfaErrorCodes', () => {
+ it('should export all expected error code constants', () => {
+ expect(MfaErrorCodes.INVALID_OTP).toBe('INVALID_OTP');
+ expect(MfaErrorCodes.INVALID_OOB_CODE).toBe('INVALID_OOB_CODE');
+ expect(MfaErrorCodes.INVALID_BINDING_CODE).toBe('INVALID_BINDING_CODE');
+ expect(MfaErrorCodes.INVALID_RECOVERY_CODE).toBe('INVALID_RECOVERY_CODE');
+ expect(MfaErrorCodes.ENROLLMENT_FAILED).toBe('ENROLLMENT_FAILED');
+ expect(MfaErrorCodes.INVALID_PHONE_NUMBER).toBe('INVALID_PHONE_NUMBER');
+ expect(MfaErrorCodes.INVALID_EMAIL).toBe('INVALID_EMAIL');
+ expect(MfaErrorCodes.EXPIRED_MFA_TOKEN).toBe('EXPIRED_MFA_TOKEN');
+ expect(MfaErrorCodes.INVALID_MFA_TOKEN).toBe('INVALID_MFA_TOKEN');
+ expect(MfaErrorCodes.TOO_MANY_ATTEMPTS).toBe('TOO_MANY_ATTEMPTS');
+ expect(MfaErrorCodes.CHALLENGE_FAILED).toBe('CHALLENGE_FAILED');
+ expect(MfaErrorCodes.AUTHENTICATOR_NOT_FOUND).toBe(
+ 'AUTHENTICATOR_NOT_FOUND'
+ );
+ expect(MfaErrorCodes.UNSUPPORTED_FACTOR).toBe('UNSUPPORTED_FACTOR');
+ expect(MfaErrorCodes.ASSOCIATION_REQUIRED).toBe('ASSOCIATION_REQUIRED');
+ expect(MfaErrorCodes.MFA_ERROR).toBe('MFA_ERROR');
+ expect(MfaErrorCodes.UNKNOWN_MFA_ERROR).toBe('UNKNOWN_MFA_ERROR');
+ });
+
+ it('should have exactly 16 error codes', () => {
+ const keys = Object.keys(MfaErrorCodes);
+ expect(keys).toHaveLength(16);
+ });
+
+ it('should be usable in switch statements', () => {
+ const testErrorType = 'INVALID_OTP';
+ let result = '';
+
+ switch (testErrorType) {
+ case MfaErrorCodes.INVALID_OTP:
+ result = 'invalid_otp';
+ break;
+ case MfaErrorCodes.EXPIRED_MFA_TOKEN:
+ result = 'expired';
+ break;
+ default:
+ result = 'unknown';
+ }
+
+ expect(result).toBe('invalid_otp');
+ });
+});
diff --git a/src/core/models/index.ts b/src/core/models/index.ts
index 736f3b52..95fefa5f 100644
--- a/src/core/models/index.ts
+++ b/src/core/models/index.ts
@@ -9,3 +9,4 @@ export {
} from './CredentialsManagerError';
export { WebAuthError, WebAuthErrorCodes } from './WebAuthError';
export { DPoPError, DPoPErrorCodes } from './DPoPError';
+export { MfaError, MfaErrorCodes } from './MfaError';
diff --git a/src/core/utils/telemetry.ts b/src/core/utils/telemetry.ts
index 90560924..1e75886d 100644
--- a/src/core/utils/telemetry.ts
+++ b/src/core/utils/telemetry.ts
@@ -1,6 +1,6 @@
export const telemetry = {
name: 'react-native-auth0',
- version: '5.5.0',
+ version: '5.5.1',
};
export type Telemetry = {
diff --git a/src/hooks/Auth0Context.ts b/src/hooks/Auth0Context.ts
index 0be76a0e..8884aeef 100644
--- a/src/hooks/Auth0Context.ts
+++ b/src/hooks/Auth0Context.ts
@@ -24,6 +24,7 @@ import type {
DPoPHeadersParams,
SessionTransferCredentials,
} from '../types';
+import type { IMfaClient } from '../core/interfaces/IMfaClient';
import type { ApiCredentials } from '../core/models';
import type {
NativeAuthorizeOptions,
@@ -348,6 +349,27 @@ export interface Auth0ContextInterface extends AuthState {
headers?: Record
) => Promise;
+ /**
+ * Provides access to MFA operations using the Flexible Factors Grant.
+ *
+ * The MFA client provides methods to list authenticators, enroll new MFA
+ * factors, challenge existing factors, and verify MFA codes.
+ *
+ * On successful verification via `mfa.verify()`, the user state is updated automatically.
+ *
+ * @example
+ * ```typescript
+ * const { mfa } = useAuth0();
+ *
+ * // List enrolled authenticators
+ * const authenticators = await mfa.getAuthenticators({ mfaToken });
+ *
+ * // Verify with OTP
+ * const credentials = await mfa.verify({ mfaToken, otp: '123456' });
+ * ```
+ */
+ mfa: IMfaClient;
+
/**
* Exchanges a refresh token for session transfer credentials via the Authentication API.
*
@@ -404,6 +426,12 @@ const initialContext: Auth0ContextInterface = {
getDPoPHeaders: stub,
getSSOCredentials: stub,
ssoExchange: stub,
+ mfa: {
+ getAuthenticators: stub,
+ enroll: stub,
+ challenge: stub,
+ verify: stub,
+ },
};
export const Auth0Context =
diff --git a/src/hooks/Auth0Provider.tsx b/src/hooks/Auth0Provider.tsx
index 34d2cf3a..3c2bb306 100644
--- a/src/hooks/Auth0Provider.tsx
+++ b/src/hooks/Auth0Provider.tsx
@@ -29,6 +29,7 @@ import type {
MfaChallengeResponse,
DPoPHeadersParams,
} from '../types';
+import type { IMfaClient } from '../core/interfaces/IMfaClient';
import type {
NativeAuthorizeOptions,
NativeClearSessionOptions,
@@ -406,6 +407,52 @@ export const Auth0Provider = ({
[client]
);
+ const mfa = useMemo(() => {
+ const mfaClient = client.mfa;
+ return {
+ getAuthenticators: async (parameters) => {
+ try {
+ return await mfaClient.getAuthenticators(parameters);
+ } catch (e) {
+ const error = e as AuthError;
+ dispatch({ type: 'ERROR', error });
+ throw error;
+ }
+ },
+ enroll: async (parameters) => {
+ try {
+ return await mfaClient.enroll(parameters);
+ } catch (e) {
+ const error = e as AuthError;
+ dispatch({ type: 'ERROR', error });
+ throw error;
+ }
+ },
+ challenge: async (parameters) => {
+ try {
+ return await mfaClient.challenge(parameters);
+ } catch (e) {
+ const error = e as AuthError;
+ dispatch({ type: 'ERROR', error });
+ throw error;
+ }
+ },
+ verify: async (parameters) => {
+ try {
+ const credentials = await mfaClient.verify(parameters);
+ const user = Auth0User.fromIdToken(credentials.idToken);
+ await client.credentialsManager.saveCredentials(credentials);
+ dispatch({ type: 'LOGIN_COMPLETE', user });
+ return credentials;
+ } catch (e) {
+ const error = e as AuthError;
+ dispatch({ type: 'ERROR', error });
+ throw error;
+ }
+ },
+ };
+ }, [client]);
+
const contextValue = useMemo(
() => ({
...state,
@@ -436,6 +483,7 @@ export const Auth0Provider = ({
revokeRefreshToken,
getDPoPHeaders,
ssoExchange,
+ mfa,
}),
[
state,
@@ -466,6 +514,7 @@ export const Auth0Provider = ({
revokeRefreshToken,
getDPoPHeaders,
ssoExchange,
+ mfa,
]
);
diff --git a/src/index.ts b/src/index.ts
index 28339a14..afd17c10 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,9 +6,11 @@ export {
WebAuthErrorCodes,
DPoPError,
DPoPErrorCodes,
+ MfaError,
+ MfaErrorCodes,
} from './core/models';
export { TimeoutError } from './core/utils/fetchWithTimeout';
-export { TokenType } from './types/common';
+export { TokenType, MfaFactorType } from './types/common';
export { Auth0Provider } from './hooks/Auth0Provider';
export { useAuth0 } from './hooks/useAuth0';
export * from './types';
@@ -18,6 +20,7 @@ export {
LocalAuthenticationStrategy,
} from './types/platform-specific';
export type { LocalAuthenticationOptions } from './types/platform-specific';
+export type { IMfaClient } from './core/interfaces/IMfaClient';
// Re-export Auth0 as default
export { default } from './Auth0';
diff --git a/src/platforms/native/adapters/NativeAuth0Client.ts b/src/platforms/native/adapters/NativeAuth0Client.ts
index cb0946cd..bccf1934 100644
--- a/src/platforms/native/adapters/NativeAuth0Client.ts
+++ b/src/platforms/native/adapters/NativeAuth0Client.ts
@@ -2,6 +2,7 @@ import type {
IAuth0Client,
IAuthenticationProvider,
IUsersClient,
+ IMfaClient,
} from '../../../core/interfaces';
import type { NativeAuth0Options } from '../../../types/platform-specific';
import type {
@@ -11,6 +12,7 @@ import type {
} from '../../../types';
import { NativeWebAuthProvider } from './NativeWebAuthProvider';
import { NativeCredentialsManager } from './NativeCredentialsManager';
+import { NativeMfaClient } from './NativeMfaClient';
import { type INativeBridge, NativeBridgeManager } from '../bridge';
import {
AuthenticationOrchestrator,
@@ -24,6 +26,7 @@ export class NativeAuth0Client implements IAuth0Client {
readonly webAuth: NativeWebAuthProvider;
readonly credentialsManager: NativeCredentialsManager;
readonly auth: IAuthenticationProvider;
+ readonly mfa: IMfaClient;
private ready: Promise;
private readonly httpClient: HttpClient;
private readonly tokenType: TokenType;
@@ -76,6 +79,7 @@ export class NativeAuth0Client implements IAuth0Client {
this.webAuth = new NativeWebAuthProvider(guardedBridge, options.domain);
this.credentialsManager = new NativeCredentialsManager(guardedBridge);
+ this.mfa = new NativeMfaClient(guardedBridge);
}
private async initialize(
diff --git a/src/platforms/native/adapters/NativeMfaClient.ts b/src/platforms/native/adapters/NativeMfaClient.ts
new file mode 100644
index 00000000..c12eb84e
--- /dev/null
+++ b/src/platforms/native/adapters/NativeMfaClient.ts
@@ -0,0 +1,108 @@
+import type { IMfaClient } from '../../../core/interfaces';
+import type { INativeBridge } from '../bridge';
+import type {
+ Credentials,
+ MfaAuthenticator,
+ MfaEnrollmentChallenge,
+ MfaChallengeResult,
+ MfaGetAuthenticatorsParameters,
+ MfaEnrollParameters,
+ MfaEnrollSmsParameters,
+ MfaEnrollVoiceParameters,
+ MfaEnrollEmailParameters,
+ MfaChallengeWithAuthenticatorParameters,
+ MfaVerifyParameters,
+ MfaVerifyOtpParameters,
+ MfaVerifyOobParameters,
+ MfaVerifyRecoveryCodeParameters,
+} from '../../../types';
+import { AuthError, MfaError } from '../../../core/models';
+
+export class NativeMfaClient implements IMfaClient {
+ private readonly bridge: INativeBridge;
+
+ constructor(bridge: INativeBridge) {
+ this.bridge = bridge;
+ }
+
+ async getAuthenticators(
+ parameters: MfaGetAuthenticatorsParameters
+ ): Promise {
+ try {
+ return await this.bridge.getMfaAuthenticators(
+ parameters.mfaToken,
+ parameters.factorsAllowed
+ );
+ } catch (e) {
+ throw e instanceof AuthError ? new MfaError(e) : e;
+ }
+ }
+
+ async enroll(
+ parameters: MfaEnrollParameters
+ ): Promise {
+ const { mfaToken, factorType } = parameters;
+ let type: string = factorType;
+ let value: string | undefined;
+
+ if (factorType === 'sms' || factorType === 'voice') {
+ type = factorType === 'sms' ? 'phone' : 'voice';
+ value = (parameters as MfaEnrollSmsParameters | MfaEnrollVoiceParameters)
+ .phoneNumber;
+ } else if (factorType === 'email') {
+ value = (parameters as MfaEnrollEmailParameters).email;
+ }
+
+ try {
+ return await this.bridge.mfaEnroll(mfaToken, type, value);
+ } catch (e) {
+ throw e instanceof AuthError ? new MfaError(e) : e;
+ }
+ }
+
+ async challenge(
+ parameters: MfaChallengeWithAuthenticatorParameters
+ ): Promise {
+ try {
+ return await this.bridge.mfaChallenge(
+ parameters.mfaToken,
+ parameters.authenticatorId
+ );
+ } catch (e) {
+ throw e instanceof AuthError ? new MfaError(e) : e;
+ }
+ }
+
+ async verify(parameters: MfaVerifyParameters): Promise {
+ const { mfaToken } = parameters;
+
+ try {
+ if ('otp' in parameters) {
+ return await this.bridge.mfaVerify(
+ mfaToken,
+ 'otp',
+ (parameters as MfaVerifyOtpParameters).otp
+ );
+ }
+
+ if ('oobCode' in parameters) {
+ const oobParams = parameters as MfaVerifyOobParameters;
+ return await this.bridge.mfaVerify(
+ mfaToken,
+ 'oob',
+ oobParams.oobCode,
+ oobParams.bindingCode
+ );
+ }
+
+ const recoveryParams = parameters as MfaVerifyRecoveryCodeParameters;
+ return await this.bridge.mfaVerify(
+ mfaToken,
+ 'recoveryCode',
+ recoveryParams.recoveryCode
+ );
+ } catch (e) {
+ throw e instanceof AuthError ? new MfaError(e) : e;
+ }
+ }
+}
diff --git a/src/platforms/native/adapters/__tests__/NativeMfaClient.spec.ts b/src/platforms/native/adapters/__tests__/NativeMfaClient.spec.ts
new file mode 100644
index 00000000..f7627a66
--- /dev/null
+++ b/src/platforms/native/adapters/__tests__/NativeMfaClient.spec.ts
@@ -0,0 +1,375 @@
+import { NativeMfaClient } from '../NativeMfaClient';
+import type { INativeBridge } from '../../bridge';
+import { AuthError, MfaError, MfaErrorCodes } from '../../../../core/models';
+
+const mockBridge: jest.Mocked = {
+ getMfaAuthenticators: jest.fn(),
+ mfaEnroll: jest.fn(),
+ mfaChallenge: jest.fn(),
+ mfaVerify: jest.fn(),
+ initialize: jest.fn(),
+ hasValidInstance: jest.fn(),
+ getBundleIdentifier: jest.fn(),
+ authorize: jest.fn(),
+ clearSession: jest.fn(),
+ cancelWebAuth: jest.fn(),
+ saveCredentials: jest.fn(),
+ getCredentials: jest.fn(),
+ hasValidCredentials: jest.fn(),
+ clearCredentials: jest.fn(),
+ resumeWebAuth: jest.fn(),
+ getDPoPHeaders: jest.fn(),
+ clearDPoPKey: jest.fn(),
+ getSSOCredentials: jest.fn(),
+ getApiCredentials: jest.fn(),
+ clearApiCredentials: jest.fn(),
+ customTokenExchange: jest.fn(),
+};
+
+describe('NativeMfaClient', () => {
+ let client: NativeMfaClient;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ client = new NativeMfaClient(mockBridge);
+ });
+
+ describe('getAuthenticators', () => {
+ it('should call bridge with mfaToken and factorsAllowed', async () => {
+ const authenticators = [
+ {
+ id: 'sms|dev_123',
+ authenticatorType: 'oob',
+ active: true,
+ oobChannel: 'sms',
+ },
+ ];
+ mockBridge.getMfaAuthenticators.mockResolvedValueOnce(authenticators);
+
+ const result = await client.getAuthenticators({
+ mfaToken: 'mfa_token_123',
+ factorsAllowed: ['otp', 'oob'],
+ });
+
+ expect(mockBridge.getMfaAuthenticators).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ ['otp', 'oob']
+ );
+ expect(result).toEqual(authenticators);
+ });
+
+ it('should call bridge with undefined factorsAllowed when not provided', async () => {
+ mockBridge.getMfaAuthenticators.mockResolvedValueOnce([]);
+
+ await client.getAuthenticators({ mfaToken: 'mfa_token_123' });
+
+ expect(mockBridge.getMfaAuthenticators).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ undefined
+ );
+ });
+
+ it('should wrap AuthError in MfaError', async () => {
+ const authError = new AuthError('invalid_grant', 'Token expired', {
+ code: 'expired_token',
+ });
+ mockBridge.getMfaAuthenticators.mockRejectedValueOnce(authError);
+
+ await expect(
+ client.getAuthenticators({ mfaToken: 'mfa_token_123' })
+ ).rejects.toThrow(MfaError);
+
+ try {
+ await client.getAuthenticators({ mfaToken: 'mfa_token_123' });
+ } catch {
+ // Re-mock since first call consumed it
+ }
+ // Use a fresh mock to verify type
+ mockBridge.getMfaAuthenticators.mockRejectedValueOnce(authError);
+ try {
+ await client.getAuthenticators({ mfaToken: 'mfa_token_123' });
+ } catch (e) {
+ expect(e).toBeInstanceOf(MfaError);
+ expect((e as MfaError).type).toBe(MfaErrorCodes.EXPIRED_MFA_TOKEN);
+ }
+ });
+
+ it('should rethrow non-AuthError errors as-is', async () => {
+ const error = new Error('Network error');
+ mockBridge.getMfaAuthenticators.mockRejectedValueOnce(error);
+
+ await expect(
+ client.getAuthenticators({ mfaToken: 'mfa_token_123' })
+ ).rejects.toThrow(error);
+ });
+ });
+
+ describe('enroll', () => {
+ it('should enroll sms factor with phoneNumber', async () => {
+ const challenge = { type: 'oob' as const, oobCode: 'oob_123' };
+ mockBridge.mfaEnroll.mockResolvedValueOnce(challenge);
+
+ const result = await client.enroll({
+ mfaToken: 'mfa_token_123',
+ factorType: 'sms',
+ phoneNumber: '+12025550135',
+ });
+
+ expect(mockBridge.mfaEnroll).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ 'phone',
+ '+12025550135'
+ );
+ expect(result).toEqual(challenge);
+ });
+
+ it('should enroll voice factor with phoneNumber', async () => {
+ const challenge = { type: 'oob' as const, oobCode: 'oob_voice' };
+ mockBridge.mfaEnroll.mockResolvedValueOnce(challenge);
+
+ const result = await client.enroll({
+ mfaToken: 'mfa_token_123',
+ factorType: 'voice',
+ phoneNumber: '+12025550135',
+ });
+
+ expect(mockBridge.mfaEnroll).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ 'voice',
+ '+12025550135'
+ );
+ expect(result).toEqual(challenge);
+ });
+
+ it('should enroll email factor with email', async () => {
+ const challenge = { type: 'oob' as const, oobCode: 'oob_456' };
+ mockBridge.mfaEnroll.mockResolvedValueOnce(challenge);
+
+ const result = await client.enroll({
+ mfaToken: 'mfa_token_123',
+ factorType: 'email',
+ email: 'user@example.com',
+ });
+
+ expect(mockBridge.mfaEnroll).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ 'email',
+ 'user@example.com'
+ );
+ expect(result).toEqual(challenge);
+ });
+
+ it('should enroll otp factor', async () => {
+ const challenge = {
+ type: 'totp' as const,
+ barcodeUri: 'otpauth://totp/...',
+ secret: 'JBSWY3DPEHPK3PXP',
+ };
+ mockBridge.mfaEnroll.mockResolvedValueOnce(challenge);
+
+ const result = await client.enroll({
+ mfaToken: 'mfa_token_123',
+ factorType: 'otp',
+ });
+
+ expect(mockBridge.mfaEnroll).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ 'otp',
+ undefined
+ );
+ expect(result).toEqual(challenge);
+ });
+
+ it('should enroll push factor', async () => {
+ const challenge = { type: 'oob' as const, oobCode: 'oob_789' };
+ mockBridge.mfaEnroll.mockResolvedValueOnce(challenge);
+
+ const result = await client.enroll({
+ mfaToken: 'mfa_token_123',
+ factorType: 'push',
+ });
+
+ expect(mockBridge.mfaEnroll).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ 'push',
+ undefined
+ );
+ expect(result).toEqual(challenge);
+ });
+
+ it('should wrap AuthError in MfaError', async () => {
+ const authError = new AuthError('enrollment_error', 'Enrollment failed', {
+ code: 'MFA_ENROLLMENT_ERROR',
+ });
+ mockBridge.mfaEnroll.mockRejectedValueOnce(authError);
+
+ try {
+ await client.enroll({ mfaToken: 'mfa_token_123', factorType: 'otp' });
+ fail('Should have thrown');
+ } catch (e) {
+ expect(e).toBeInstanceOf(MfaError);
+ expect((e as MfaError).type).toBe(MfaErrorCodes.ENROLLMENT_FAILED);
+ }
+ });
+
+ it('should rethrow non-AuthError errors as-is', async () => {
+ const error = new Error('Enrollment failed');
+ mockBridge.mfaEnroll.mockRejectedValueOnce(error);
+
+ await expect(
+ client.enroll({ mfaToken: 'mfa_token_123', factorType: 'otp' })
+ ).rejects.toThrow(error);
+ });
+ });
+
+ describe('challenge', () => {
+ it('should call bridge with mfaToken and authenticatorId', async () => {
+ const challengeResult = {
+ challengeType: 'oob',
+ oobCode: 'oob_challenge_123',
+ };
+ mockBridge.mfaChallenge.mockResolvedValueOnce(challengeResult);
+
+ const result = await client.challenge({
+ mfaToken: 'mfa_token_123',
+ authenticatorId: 'sms|dev_123',
+ });
+
+ expect(mockBridge.mfaChallenge).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ 'sms|dev_123'
+ );
+ expect(result).toEqual(challengeResult);
+ });
+
+ it('should wrap AuthError in MfaError', async () => {
+ const authError = new AuthError('challenge_error', 'Challenge failed', {
+ code: 'mfa_challenge_failed',
+ });
+ mockBridge.mfaChallenge.mockRejectedValueOnce(authError);
+
+ try {
+ await client.challenge({
+ mfaToken: 'mfa_token_123',
+ authenticatorId: 'sms|dev_123',
+ });
+ fail('Should have thrown');
+ } catch (e) {
+ expect(e).toBeInstanceOf(MfaError);
+ expect((e as MfaError).type).toBe(MfaErrorCodes.CHALLENGE_FAILED);
+ }
+ });
+
+ it('should rethrow non-AuthError errors as-is', async () => {
+ const error = new Error('Challenge failed');
+ mockBridge.mfaChallenge.mockRejectedValueOnce(error);
+
+ await expect(
+ client.challenge({
+ mfaToken: 'mfa_token_123',
+ authenticatorId: 'sms|dev_123',
+ })
+ ).rejects.toThrow(error);
+ });
+ });
+
+ describe('verify', () => {
+ const mockCredentials = {
+ idToken: 'id_token',
+ accessToken: 'access_token',
+ tokenType: 'Bearer',
+ expiresAt: 1234567890,
+ };
+
+ it('should verify OTP code', async () => {
+ mockBridge.mfaVerify.mockResolvedValueOnce(mockCredentials);
+
+ const result = await client.verify({
+ mfaToken: 'mfa_token_123',
+ otp: '123456',
+ });
+
+ expect(mockBridge.mfaVerify).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ 'otp',
+ '123456'
+ );
+ expect(result).toEqual(mockCredentials);
+ });
+
+ it('should verify OOB code without binding code', async () => {
+ mockBridge.mfaVerify.mockResolvedValueOnce(mockCredentials);
+
+ const result = await client.verify({
+ mfaToken: 'mfa_token_123',
+ oobCode: 'oob_code_123',
+ });
+
+ expect(mockBridge.mfaVerify).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ 'oob',
+ 'oob_code_123',
+ undefined
+ );
+ expect(result).toEqual(mockCredentials);
+ });
+
+ it('should verify OOB code with binding code', async () => {
+ mockBridge.mfaVerify.mockResolvedValueOnce(mockCredentials);
+
+ const result = await client.verify({
+ mfaToken: 'mfa_token_123',
+ oobCode: 'oob_code_123',
+ bindingCode: '654321',
+ });
+
+ expect(mockBridge.mfaVerify).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ 'oob',
+ 'oob_code_123',
+ '654321'
+ );
+ expect(result).toEqual(mockCredentials);
+ });
+
+ it('should verify recovery code', async () => {
+ mockBridge.mfaVerify.mockResolvedValueOnce(mockCredentials);
+
+ const result = await client.verify({
+ mfaToken: 'mfa_token_123',
+ recoveryCode: 'RECOVERY_CODE_123',
+ });
+
+ expect(mockBridge.mfaVerify).toHaveBeenCalledWith(
+ 'mfa_token_123',
+ 'recoveryCode',
+ 'RECOVERY_CODE_123'
+ );
+ expect(result).toEqual(mockCredentials);
+ });
+
+ it('should wrap AuthError in MfaError for invalid_otp', async () => {
+ const authError = new AuthError('invalid_otp', 'Invalid OTP', {
+ code: 'invalid_otp',
+ });
+ mockBridge.mfaVerify.mockRejectedValueOnce(authError);
+
+ try {
+ await client.verify({ mfaToken: 'mfa_token_123', otp: '000000' });
+ fail('Should have thrown');
+ } catch (e) {
+ expect(e).toBeInstanceOf(MfaError);
+ expect((e as MfaError).type).toBe(MfaErrorCodes.INVALID_OTP);
+ }
+ });
+
+ it('should rethrow non-AuthError errors as-is', async () => {
+ const error = new Error('Verification failed');
+ mockBridge.mfaVerify.mockRejectedValueOnce(error);
+
+ await expect(
+ client.verify({ mfaToken: 'mfa_token_123', otp: '123456' })
+ ).rejects.toThrow(error);
+ });
+ });
+});
diff --git a/src/platforms/native/adapters/index.ts b/src/platforms/native/adapters/index.ts
index 3df27790..98b429c0 100644
--- a/src/platforms/native/adapters/index.ts
+++ b/src/platforms/native/adapters/index.ts
@@ -1,3 +1,4 @@
export * from './NativeAuth0Client';
export * from './NativeWebAuthProvider';
export * from './NativeCredentialsManager';
+export * from './NativeMfaClient';
diff --git a/src/platforms/native/bridge/INativeBridge.ts b/src/platforms/native/bridge/INativeBridge.ts
index 184ebc3d..c4458e0a 100644
--- a/src/platforms/native/bridge/INativeBridge.ts
+++ b/src/platforms/native/bridge/INativeBridge.ts
@@ -5,6 +5,9 @@ import type {
ClearSessionParameters,
DPoPHeadersParams,
SessionTransferCredentials,
+ MfaAuthenticator,
+ MfaEnrollmentChallenge,
+ MfaChallengeResult,
} from '../../../types';
import type {
LocalAuthenticationOptions,
@@ -205,4 +208,58 @@ export interface INativeBridge {
scope?: string,
organization?: string
): Promise;
+
+ /**
+ * Lists enrolled MFA authenticators.
+ *
+ * @param mfaToken The MFA token from an MFA_REQUIRED error.
+ * @param factorsAllowed Optional list of factor types to filter by.
+ * @returns A promise that resolves with the list of enrolled authenticators.
+ */
+ getMfaAuthenticators(
+ mfaToken: string,
+ factorsAllowed?: string[]
+ ): Promise;
+
+ /**
+ * Enrolls a new MFA factor.
+ *
+ * @param mfaToken The MFA token from an MFA_REQUIRED error.
+ * @param type The factor type: 'phone', 'email', 'otp', or 'push'.
+ * @param value The phone number or email (required for 'phone' and 'email').
+ * @returns A promise that resolves with the enrollment challenge details.
+ */
+ mfaEnroll(
+ mfaToken: string,
+ type: string,
+ value?: string
+ ): Promise;
+
+ /**
+ * Requests an MFA challenge for an enrolled authenticator.
+ *
+ * @param mfaToken The MFA token from an MFA_REQUIRED error.
+ * @param authenticatorId The ID of the enrolled authenticator.
+ * @returns A promise that resolves with the challenge details.
+ */
+ mfaChallenge(
+ mfaToken: string,
+ authenticatorId: string
+ ): Promise;
+
+ /**
+ * Verifies an MFA code and returns credentials on success.
+ *
+ * @param mfaToken The MFA token.
+ * @param type The verification type: 'otp', 'oob', or 'recoveryCode'.
+ * @param code The OTP code, OOB code, or recovery code.
+ * @param bindingCode Optional binding code for OOB verification.
+ * @returns A promise that resolves with credentials on successful verification.
+ */
+ mfaVerify(
+ mfaToken: string,
+ type: string,
+ code: string,
+ bindingCode?: string
+ ): Promise;
}
diff --git a/src/platforms/native/bridge/NativeBridgeManager.ts b/src/platforms/native/bridge/NativeBridgeManager.ts
index a5e3a6b7..aa52abb2 100644
--- a/src/platforms/native/bridge/NativeBridgeManager.ts
+++ b/src/platforms/native/bridge/NativeBridgeManager.ts
@@ -7,6 +7,9 @@ import type {
NativeClearSessionOptions,
DPoPHeadersParams,
SessionTransferCredentials,
+ MfaAuthenticator,
+ MfaEnrollmentChallenge,
+ MfaChallengeResult,
} from '../../../types';
import {
SafariViewControllerPresentationStyle,
@@ -251,4 +254,55 @@ export class NativeBridgeManager implements INativeBridge {
);
}
}
+
+ async getMfaAuthenticators(
+ mfaToken: string,
+ factorsAllowed?: string[]
+ ): Promise {
+ return this.a0_call(
+ Auth0NativeModule.getMfaAuthenticators.bind(Auth0NativeModule),
+ mfaToken,
+ factorsAllowed
+ ) as Promise;
+ }
+
+ async mfaEnroll(
+ mfaToken: string,
+ type: string,
+ value?: string
+ ): Promise {
+ return this.a0_call(
+ Auth0NativeModule.mfaEnroll.bind(Auth0NativeModule),
+ mfaToken,
+ type,
+ value
+ ) as Promise;
+ }
+
+ async mfaChallenge(
+ mfaToken: string,
+ authenticatorId: string
+ ): Promise {
+ return this.a0_call(
+ Auth0NativeModule.mfaChallenge.bind(Auth0NativeModule),
+ mfaToken,
+ authenticatorId
+ ) as Promise;
+ }
+
+ async mfaVerify(
+ mfaToken: string,
+ type: string,
+ code: string,
+ bindingCode?: string
+ ): Promise {
+ const credential = await this.a0_call(
+ Auth0NativeModule.mfaVerify.bind(Auth0NativeModule),
+ mfaToken,
+ type,
+ code,
+ bindingCode
+ );
+ return new CredentialsModel(credential);
+ }
}
diff --git a/src/platforms/web/adapters/WebAuth0Client.ts b/src/platforms/web/adapters/WebAuth0Client.ts
index ec53736b..1aaf3223 100644
--- a/src/platforms/web/adapters/WebAuth0Client.ts
+++ b/src/platforms/web/adapters/WebAuth0Client.ts
@@ -7,6 +7,7 @@ import type {
IAuth0Client,
IAuthenticationProvider,
IUsersClient,
+ IMfaClient,
} from '../../../core/interfaces';
import type { WebAuth0Options } from '../../../types/platform-specific';
import type {
@@ -16,6 +17,7 @@ import type {
} from '../../../types';
import { WebWebAuthProvider } from './WebWebAuthProvider';
import { WebCredentialsManager } from './WebCredentialsManager';
+import { WebMfaClient } from './WebMfaClient';
import { ssoExchangeNotSupported } from './WebAuthenticationProvider';
import {
AuthenticationOrchestrator,
@@ -29,6 +31,7 @@ export class WebAuth0Client implements IAuth0Client {
readonly webAuth: WebWebAuthProvider;
readonly credentialsManager: WebCredentialsManager;
readonly auth: IAuthenticationProvider;
+ readonly mfa: IMfaClient;
private readonly httpClient: HttpClient;
private readonly tokenType: TokenType;
@@ -119,6 +122,7 @@ export class WebAuth0Client implements IAuth0Client {
this.webAuth = new WebWebAuthProvider(this.client);
this.credentialsManager = new WebCredentialsManager(this.client);
+ this.mfa = new WebMfaClient(this.client.mfa, this.tokenType);
}
users(token: string, tokenType?: TokenType): IUsersClient {
diff --git a/src/platforms/web/adapters/WebMfaClient.ts b/src/platforms/web/adapters/WebMfaClient.ts
new file mode 100644
index 00000000..0bd7cf78
--- /dev/null
+++ b/src/platforms/web/adapters/WebMfaClient.ts
@@ -0,0 +1,190 @@
+import type { MfaApiClient } from '@auth0/auth0-spa-js';
+import type { IMfaClient } from '../../../core/interfaces';
+import type {
+ Credentials,
+ MfaAuthenticator,
+ MfaEnrollmentChallenge,
+ MfaChallengeResult,
+ MfaGetAuthenticatorsParameters,
+ MfaEnrollParameters,
+ MfaEnrollSmsParameters,
+ MfaEnrollVoiceParameters,
+ MfaEnrollEmailParameters,
+ MfaChallengeWithAuthenticatorParameters,
+ MfaVerifyParameters,
+} from '../../../types';
+import { AuthError, MfaError } from '../../../core/models';
+
+export class WebMfaClient implements IMfaClient {
+ private readonly spaMfa: MfaApiClient;
+ private readonly tokenType: string;
+
+ private static readonly ALL_CHALLENGE_TYPES = [
+ { type: 'otp' },
+ { type: 'oob' },
+ { type: 'recovery-code' },
+ ];
+
+ constructor(spaMfa: MfaApiClient, tokenType: string) {
+ this.spaMfa = spaMfa;
+ this.tokenType = tokenType;
+ }
+
+ private ensureMfaContext(mfaToken: string): void {
+ this.spaMfa.setMFAAuthDetails(mfaToken, undefined, undefined, {
+ challenge: WebMfaClient.ALL_CHALLENGE_TYPES,
+ });
+ }
+
+ async getAuthenticators(
+ parameters: MfaGetAuthenticatorsParameters
+ ): Promise {
+ try {
+ this.ensureMfaContext(parameters.mfaToken);
+ const authenticators = await this.spaMfa.getAuthenticators(
+ parameters.mfaToken
+ );
+ return authenticators.map((a) => ({
+ id: a.id,
+ authenticatorType: a.authenticatorType,
+ active: a.active,
+ name: a.name,
+ }));
+ } catch (e: any) {
+ const authError = new AuthError(
+ e.error ?? 'mfa_list_authenticators_failed',
+ e.error_description ?? e.message,
+ {
+ status: e.status,
+ code: e.error ?? 'mfa_list_authenticators_failed',
+ json: e,
+ }
+ );
+ throw new MfaError(authError);
+ }
+ }
+
+ async enroll(
+ parameters: MfaEnrollParameters
+ ): Promise {
+ try {
+ this.ensureMfaContext(parameters.mfaToken);
+ const { factorType } = parameters;
+ let spaParams: any = {
+ mfaToken: parameters.mfaToken,
+ factorType,
+ };
+
+ if (factorType === 'sms' || factorType === 'voice') {
+ spaParams.phoneNumber = (
+ parameters as MfaEnrollSmsParameters | MfaEnrollVoiceParameters
+ ).phoneNumber;
+ } else if (factorType === 'email') {
+ spaParams.email = (parameters as MfaEnrollEmailParameters).email;
+ }
+
+ const response = await this.spaMfa.enroll(spaParams);
+
+ if (response.authenticatorType === 'otp') {
+ return {
+ type: 'totp',
+ barcodeUri: response.barcodeUri,
+ secret: response.secret,
+ recoveryCodes: response.recoveryCodes,
+ };
+ }
+
+ return {
+ type: 'oob',
+ oobCode: response.oobCode ?? '',
+ bindingMethod: response.bindingMethod,
+ recoveryCodes: response.recoveryCodes,
+ };
+ } catch (e: any) {
+ if (e instanceof MfaError) throw e;
+ const authError = new AuthError(
+ e.error ?? 'mfa_enrollment_failed',
+ e.error_description ?? e.message,
+ {
+ status: e.status,
+ code: e.error ?? 'mfa_enrollment_failed',
+ json: e,
+ }
+ );
+ throw new MfaError(authError);
+ }
+ }
+
+ async challenge(
+ parameters: MfaChallengeWithAuthenticatorParameters
+ ): Promise {
+ try {
+ this.ensureMfaContext(parameters.mfaToken);
+ const response = await this.spaMfa.challenge({
+ mfaToken: parameters.mfaToken,
+ challengeType: 'oob',
+ authenticatorId: parameters.authenticatorId,
+ });
+
+ return {
+ challengeType: response.challengeType,
+ oobCode: response.oobCode,
+ bindingMethod: response.bindingMethod,
+ };
+ } catch (e: any) {
+ if (e instanceof MfaError) throw e;
+ const authError = new AuthError(
+ e.error ?? 'mfa_challenge_failed',
+ e.error_description ?? e.message,
+ {
+ status: e.status,
+ code: e.error ?? 'mfa_challenge_failed',
+ json: e,
+ }
+ );
+ throw new MfaError(authError);
+ }
+ }
+
+ async verify(parameters: MfaVerifyParameters): Promise {
+ try {
+ this.ensureMfaContext(parameters.mfaToken);
+ const spaParams: any = { mfaToken: parameters.mfaToken };
+
+ if ('otp' in parameters) {
+ spaParams.otp = parameters.otp;
+ } else if ('oobCode' in parameters) {
+ spaParams.oobCode = parameters.oobCode;
+ if ('bindingCode' in parameters) {
+ spaParams.bindingCode = parameters.bindingCode;
+ }
+ } else if ('recoveryCode' in parameters) {
+ spaParams.recoveryCode = parameters.recoveryCode;
+ }
+
+ const response = await this.spaMfa.verify(spaParams);
+
+ const expiresAt = Math.floor(Date.now() / 1000) + response.expires_in;
+ return {
+ accessToken: response.access_token,
+ idToken: response.id_token,
+ tokenType: response.token_type ?? this.tokenType,
+ expiresAt,
+ scope: response.scope,
+ refreshToken: response.refresh_token,
+ };
+ } catch (e: any) {
+ if (e instanceof MfaError) throw e;
+ const authError = new AuthError(
+ e.error ?? 'mfa_verify_failed',
+ e.error_description ?? e.message,
+ {
+ status: e.status,
+ code: e.error ?? 'mfa_verify_failed',
+ json: e,
+ }
+ );
+ throw new MfaError(authError);
+ }
+ }
+}
diff --git a/src/platforms/web/adapters/index.ts b/src/platforms/web/adapters/index.ts
index 1c697be4..57cbace9 100644
--- a/src/platforms/web/adapters/index.ts
+++ b/src/platforms/web/adapters/index.ts
@@ -1,3 +1,4 @@
export * from './WebAuth0Client';
export * from './WebWebAuthProvider';
export * from './WebCredentialsManager';
+export * from './WebMfaClient';
diff --git a/src/specs/NativeA0Auth0.ts b/src/specs/NativeA0Auth0.ts
index 58779f48..0778652f 100644
--- a/src/specs/NativeA0Auth0.ts
+++ b/src/specs/NativeA0Auth0.ts
@@ -157,6 +157,45 @@ export interface Spec extends TurboModule {
scope: string | undefined,
organization: string | undefined
): Promise;
+
+ /**
+ * Get enrolled MFA authenticators.
+ */
+ getMfaAuthenticators(
+ mfaToken: string,
+ factorsAllowed: string[] | undefined
+ ): Promise;
+
+ /**
+ * Enroll a new MFA factor.
+ * @param mfaToken The MFA token from an MFA_REQUIRED error.
+ * @param type The type of factor to enroll: 'phone', 'email', 'otp', or 'push'.
+ * @param value The phone number or email address (required for 'phone' and 'email' types).
+ */
+ mfaEnroll(
+ mfaToken: string,
+ type: string,
+ value: string | undefined
+ ): Promise;
+
+ /**
+ * Request an MFA challenge for an enrolled authenticator.
+ */
+ mfaChallenge(mfaToken: string, authenticatorId: string): Promise;
+
+ /**
+ * Verify an MFA code and obtain credentials.
+ * @param mfaToken The MFA token.
+ * @param type The verification type: 'otp', 'oob', or 'recoveryCode'.
+ * @param code The OTP code, OOB code, or recovery code.
+ * @param bindingCode The binding code for OOB verification (optional).
+ */
+ mfaVerify(
+ mfaToken: string,
+ type: string,
+ code: string,
+ bindingCode: string | undefined
+ ): Promise;
}
export default TurboModuleRegistry.getEnforcing('A0Auth0');
diff --git a/src/types/common.ts b/src/types/common.ts
index 2d9ec970..3b33abe2 100644
--- a/src/types/common.ts
+++ b/src/types/common.ts
@@ -189,6 +189,102 @@ export type MfaChallengeResponse =
| MfaChallengeOobResponse
| MfaChallengeOobWithBindingResponse;
+// ========= MFA Flexible Factors Grant Types =========
+
+/** Represents an enrolled MFA authenticator. */
+export type MfaAuthenticator = {
+ id: string;
+ authenticatorType: string;
+ name?: string;
+ active: boolean;
+ oobChannel?: string;
+};
+
+/** Represents the response from an MFA challenge request via the MFA API. */
+export type MfaChallengeResult = {
+ challengeType: string;
+ oobCode?: string;
+ bindingMethod?: string;
+};
+
+/** Base enrollment challenge response. */
+export type MfaOobEnrollmentChallenge = {
+ type: 'oob';
+ oobCode: string;
+ bindingMethod?: string;
+ recoveryCodes?: string[];
+};
+
+/** Enrollment challenge for TOTP (authenticator app). */
+export type MfaTotpEnrollmentChallenge = {
+ type: 'totp';
+ barcodeUri: string;
+ secret: string;
+ recoveryCodes?: string[];
+};
+
+/** Union type for all possible enrollment challenge responses. */
+export type MfaEnrollmentChallenge =
+ | MfaOobEnrollmentChallenge
+ | MfaTotpEnrollmentChallenge;
+
+/** Structured payload extracted from an MFA_REQUIRED error. */
+export type MfaRequiredErrorPayload = {
+ mfaToken: string;
+ error: string;
+ errorDescription?: string;
+ mfaRequirements?: MfaRequirements;
+};
+
+/** Describes which MFA factors are available for enrollment and challenge. */
+export type MfaRequirements = {
+ enroll?: MfaFactor[];
+ challenge?: MfaFactor[];
+};
+
+/** Describes a single MFA factor type. */
+export type MfaFactor = {
+ type: string;
+};
+
+// ========= MFA Factor Type Constants =========
+
+/**
+ * Supported MFA factor types.
+ * Use these constants when enrolling factors, filtering authenticators, or identifying challenge types.
+ *
+ * @example
+ * ```ts
+ * import { MfaFactorType } from 'react-native-auth0';
+ *
+ * // Enroll TOTP authenticator
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.OTP });
+ *
+ * // Enroll SMS
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.SMS, phoneNumber: '+1234567890' });
+ *
+ * // Enroll Email
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.EMAIL, email: 'user@example.com' });
+ *
+ * // Filter authenticators
+ * const smsAuths = authenticators.filter(a => a.oobChannel === MfaFactorType.SMS);
+ * ```
+ */
+export const MfaFactorType = {
+ /** Time-based One-Time Password (authenticator app like Google Authenticator, Authy) */
+ OTP: 'otp',
+ /** SMS-based verification */
+ SMS: 'sms',
+ /** Voice call verification */
+ VOICE: 'voice',
+ /** Email-based verification */
+ EMAIL: 'email',
+ /** Push notification (Auth0 Guardian) */
+ PUSH: 'push',
+} as const;
+
+export type MfaFactorType = (typeof MfaFactorType)[keyof typeof MfaFactorType];
+
// ========= DPoP Types =========
/**
diff --git a/src/types/parameters.ts b/src/types/parameters.ts
index d52ca394..2dd250df 100644
--- a/src/types/parameters.ts
+++ b/src/types/parameters.ts
@@ -280,6 +280,148 @@ export interface MfaChallengeParameters extends RequestOptions {
authenticatorId?: string;
}
+// ========= MFA Flexible Factors Grant Parameters =========
+
+/** Parameters for listing enrolled MFA authenticators. */
+export interface MfaGetAuthenticatorsParameters {
+ mfaToken: string;
+ factorsAllowed?: string[];
+}
+
+/**
+ * Enroll an OTP (TOTP) authenticator.
+ *
+ * @example
+ * ```ts
+ * import { MfaFactorType } from 'react-native-auth0';
+ * const result = await mfa.enroll({ mfaToken, factorType: MfaFactorType.OTP });
+ * // result.secret, result.barcodeUri
+ * ```
+ */
+export interface MfaEnrollOtpParameters {
+ mfaToken: string;
+ factorType: 'otp';
+}
+
+/**
+ * Enroll an SMS MFA factor.
+ * Requires a phone number in E.164 format.
+ *
+ * @example
+ * ```ts
+ * import { MfaFactorType } from 'react-native-auth0';
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.SMS, phoneNumber: '+12025550135' });
+ * ```
+ */
+export interface MfaEnrollSmsParameters {
+ mfaToken: string;
+ factorType: 'sms';
+ phoneNumber: string;
+}
+
+/**
+ * Enroll a voice call MFA factor.
+ * Requires a phone number in E.164 format.
+ *
+ * @example
+ * ```ts
+ * import { MfaFactorType } from 'react-native-auth0';
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.VOICE, phoneNumber: '+12025550135' });
+ * ```
+ */
+export interface MfaEnrollVoiceParameters {
+ mfaToken: string;
+ factorType: 'voice';
+ phoneNumber: string;
+}
+
+/**
+ * Enroll an email MFA factor.
+ *
+ * @example
+ * ```ts
+ * import { MfaFactorType } from 'react-native-auth0';
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.EMAIL, email: 'user@example.com' });
+ * ```
+ */
+export interface MfaEnrollEmailParameters {
+ mfaToken: string;
+ factorType: 'email';
+ email: string;
+}
+
+/**
+ * Enroll a push notification MFA factor (Auth0 Guardian).
+ *
+ * @example
+ * ```ts
+ * import { MfaFactorType } from 'react-native-auth0';
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.PUSH });
+ * ```
+ */
+export interface MfaEnrollPushParameters {
+ mfaToken: string;
+ factorType: 'push';
+}
+
+/**
+ * Union type for all MFA enrollment parameter types.
+ * Each variant is discriminated by the `factorType` field.
+ *
+ * @example
+ * ```ts
+ * import { MfaFactorType } from 'react-native-auth0';
+ *
+ * // OTP
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.OTP });
+ * // SMS
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.SMS, phoneNumber: '+1...' });
+ * // Voice
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.VOICE, phoneNumber: '+1...' });
+ * // Email
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.EMAIL, email: 'user@example.com' });
+ * // Push
+ * await mfa.enroll({ mfaToken, factorType: MfaFactorType.PUSH });
+ * ```
+ */
+export type MfaEnrollParameters =
+ | MfaEnrollOtpParameters
+ | MfaEnrollSmsParameters
+ | MfaEnrollVoiceParameters
+ | MfaEnrollEmailParameters
+ | MfaEnrollPushParameters;
+
+/** Parameters for requesting an MFA challenge via the MFA API. */
+export interface MfaChallengeWithAuthenticatorParameters {
+ mfaToken: string;
+ authenticatorId: string;
+}
+
+/** Parameters for verifying an MFA OTP code (authenticator app). */
+export interface MfaVerifyOtpParameters {
+ mfaToken: string;
+ otp: string;
+}
+
+/** Parameters for verifying an MFA OOB code (SMS/Email/Push). */
+export interface MfaVerifyOobParameters {
+ mfaToken: string;
+ oobCode: string;
+ bindingCode?: string;
+}
+
+/** Parameters for verifying with a recovery code. */
+export interface MfaVerifyRecoveryCodeParameters {
+ mfaToken: string;
+ recoveryCode: string;
+}
+
+/** Union type for all MFA verification parameter types. */
+export type MfaVerifyParameters =
+ | MfaVerifyOtpParameters
+ | MfaVerifyOobParameters
+ | MfaVerifyRecoveryCodeParameters;
+
// ========= User Management & Profile Parameters =========
/** Parameters for accessing the `/userinfo` endpoint. */
diff --git a/yarn.lock b/yarn.lock
index 06895da4..f6c5c716 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3827,6 +3827,13 @@ __metadata:
languageName: node
linkType: hard
+"@nodable/entities@npm:^2.1.0":
+ version: 2.1.0
+ resolution: "@nodable/entities@npm:2.1.0"
+ checksum: 10c0/5a4cba2b61a5b6c726328b18b1de6d033cae4a658a118644bf31e0bcbda126ea7b69385043dc556cf1ed859b9ca220e82b81b5e5c48ef1b519fb8ec104575dee
+ languageName: node
+ linkType: hard
+
"@nodelib/fs.scandir@npm:2.1.5":
version: 2.1.5
resolution: "@nodelib/fs.scandir@npm:2.1.5"
@@ -5985,9 +5992,9 @@ __metadata:
linkType: hard
"@xmldom/xmldom@npm:^0.8.8":
- version: 0.8.12
- resolution: "@xmldom/xmldom@npm:0.8.12"
- checksum: 10c0/b733c84292d1bee32ef21a05aba8f9063456b51a54068d0b4a1abf5545156ee0b9894b7ae23775b5881b11c35a8a03871d1b514fb7e1b11654cdbee57e1c2707
+ version: 0.8.13
+ resolution: "@xmldom/xmldom@npm:0.8.13"
+ checksum: 10c0/06405ee6fffba631abf715a305ace338420ebcea8baf1317f19f2752f5c505952b7df45159908e7be8451a42faa54326b780616ab4d08242b20477b2973da24b
languageName: node
linkType: hard
@@ -9710,25 +9717,26 @@ __metadata:
languageName: node
linkType: hard
-"fast-xml-builder@npm:^1.1.4":
- version: 1.1.4
- resolution: "fast-xml-builder@npm:1.1.4"
+"fast-xml-builder@npm:^1.1.5":
+ version: 1.1.5
+ resolution: "fast-xml-builder@npm:1.1.5"
dependencies:
path-expression-matcher: "npm:^1.1.3"
- checksum: 10c0/d5dfc0660f7f886b9f42747e6aa1d5e16c090c804b322652f65a5d7ffb93aa00153c3e1276cd053629f9f4c4f625131dc6886677394f7048e827e63b97b18927
+ checksum: 10c0/b814ba5559cb3140de46d2846045607ab4d4c0bfc312a49d22c91efb9f7cd7004971314841e5823eeb467a5bf403e3ade8371b7912200e111df027d42ae51715
languageName: node
linkType: hard
"fast-xml-parser@npm:^5.3.6":
- version: 5.5.9
- resolution: "fast-xml-parser@npm:5.5.9"
+ version: 5.7.1
+ resolution: "fast-xml-parser@npm:5.7.1"
dependencies:
- fast-xml-builder: "npm:^1.1.4"
- path-expression-matcher: "npm:^1.2.0"
- strnum: "npm:^2.2.2"
+ "@nodable/entities": "npm:^2.1.0"
+ fast-xml-builder: "npm:^1.1.5"
+ path-expression-matcher: "npm:^1.5.0"
+ strnum: "npm:^2.2.3"
bin:
fxparser: src/cli/cli.js
- checksum: 10c0/b7f40f586c01a916a75be15b11ec0e83a38483885395bdeca51da8992a75e3d4d9b6c2690f362b975bfcb5118909ee4b0393e18ec9c9151345d5e13152370969
+ checksum: 10c0/b8b54e33060da5fc5ce26fdc73c4728f18415f9be9a774f1406b03265a5b411b742c39dba0127c3f0f31fad5b3ee11f51be79aa16df160f69fd5e4b902bfbb85
languageName: node
linkType: hard
@@ -14608,13 +14616,20 @@ __metadata:
languageName: node
linkType: hard
-"path-expression-matcher@npm:^1.1.3, path-expression-matcher@npm:^1.2.0":
+"path-expression-matcher@npm:^1.1.3":
version: 1.2.0
resolution: "path-expression-matcher@npm:1.2.0"
checksum: 10c0/86c661dfb265ed5dd1ddd9188f0dfbecf4ec4dc3ea6cabab081d3a2ba285054d9767a641a233bd6fd694fd89f7d0ef94913032feddf5365252700b02db4bf4e1
languageName: node
linkType: hard
+"path-expression-matcher@npm:^1.5.0":
+ version: 1.5.0
+ resolution: "path-expression-matcher@npm:1.5.0"
+ checksum: 10c0/646cb5bc66cd7d809a52288336f3ac1e6223f156fd8e912936e490e590f7f93e8056d4fd25fcbcc7da61bb698fa520112cb050372a3f65e7b79bd4afa0f77610
+ languageName: node
+ linkType: hard
+
"path-is-absolute@npm:^1.0.0":
version: 1.0.1
resolution: "path-is-absolute@npm:1.0.1"
@@ -16884,10 +16899,10 @@ __metadata:
languageName: node
linkType: hard
-"strnum@npm:^2.2.2":
- version: 2.2.2
- resolution: "strnum@npm:2.2.2"
- checksum: 10c0/89c456de32b9495ae34cd6e3b59cb9ef3406b66d1429bbc931afd70be87485dcd355200c42fd638a132adb3121762542346813098ab0c43e44aac303bf17965d
+"strnum@npm:^2.2.3":
+ version: 2.2.3
+ resolution: "strnum@npm:2.2.3"
+ checksum: 10c0/1ee78101f1cd73a5b32f63cfd0be501bd246801a002f5987efef903a49e9297d1b63574e302ab3c06ee5e715c524d6cbdfef010e372ec1ea848e0179836cc208
languageName: node
linkType: hard
@@ -17493,9 +17508,9 @@ __metadata:
linkType: hard
"undici@npm:^6.18.2":
- version: 6.22.0
- resolution: "undici@npm:6.22.0"
- checksum: 10c0/47903c489d73e26bd47960cf2f04d63282ed050818b672cb05f8dfb6403381b850cf1b1751832654fd3af22aacd9d780e5e61aff563cd97943f5c4f10d5b3e23
+ version: 6.25.0
+ resolution: "undici@npm:6.25.0"
+ checksum: 10c0/2597cc6689bdb02c210c557b1f85febbfda65becae6e6fc1061508e2f33734d25207f81cd8af56ada9956329eb3a7bd7431e87dcfeceba20ee87059b57dcf985
languageName: node
linkType: hard