diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md
index 91101aec5..cf3d3573e 100644
--- a/auth0_flutter/EXAMPLES.md
+++ b/auth0_flutter/EXAMPLES.md
@@ -55,6 +55,12 @@
- [Enrolling a passkey](#enrolling-a-passkey)
- [Using DPoP](#using-dpop)
- [Errors](#errors-3)
+- [๐๐ฑ Multi-Factor Authentication (MFA)](#-multi-factor-authentication-mfa)
+ - [Obtaining an `mfa_token`](#obtaining-an-mfa_token)
+ - [Listing authenticators and challenging a factor](#listing-authenticators-and-challenging-a-factor)
+ - [Enrolling a new factor](#enrolling-a-new-factor)
+ - [Verifying and exchanging for credentials](#verifying-and-exchanging-for-credentials)
+ - [Errors](#errors-4)
## ๐ฑ Web Authentication
@@ -1642,3 +1648,188 @@ try {
---
[Go up โคด](#examples)
+
+## ๐๐ฑ Multi-Factor Authentication (MFA)
+
+> **Note:** This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to enable it for your tenant.
+
+The MFA API lets you complete a multi-factor authentication flow using an `mfa_token` โ Auth0's [flexible/expanded grant support](https://auth0.com/docs/secure/multi-factor-authentication). It is available on **mobile (Android/iOS)** and **Web**; Windows is not supported.
+
+Unlike the [My Account API](#-my-account-api) โ which manages a signed-in user's authenticators โ the MFA API is used **mid-login**, when a token request fails because MFA is required. You use the `mfa_token` from that failure to list, challenge, enroll, and verify a factor, and the successful verification returns the user's `Credentials`.
+
+The mobile and web APIs are largely symmetric โ `getAuthenticators`, `enrollTotp`/`enrollPhone`/`enrollEmail`/`enrollPush`, `challenge`, `verifyOtp`/`verifyOob`/`verifyRecoveryCode` โ so the examples below apply to both. Two differences follow the underlying native SDKs and are noted inline: mobile `getAuthenticators` requires a non-empty `factorsAllowed` list (web takes none), and mobile `enrollPhone` is SMS-only while web also supports voice.
+
+### Obtaining an `mfa_token`
+
+When an authentication request requires a second factor, the SDK surfaces an `mfa_token` in the resulting error. Pass that token to `mfa(...)` to start the flow.
+
+
+ Mobile (Android/iOS)
+
+A failing token request (for example a database login or a credentials renewal) throws an `ApiException` whose `isMultifactorRequired` flag is `true` and which carries an `mfaToken`.
+
+```dart
+final auth0 = Auth0('YOUR_DOMAIN', 'YOUR_CLIENT_ID');
+
+try {
+ await auth0.api.login(
+ usernameOrEmail: 'user@example.com',
+ password: 'secret',
+ connectionOrRealm: 'Username-Password-Authentication',
+ scopes: {'openid', 'profile', 'email'},
+ );
+} on ApiException catch (e) {
+ if (e.isMultifactorRequired && e.mfaToken != null) {
+ final mfa = auth0.mfa(mfaToken: e.mfaToken!);
+
+ // `mfaRequirements` (when present) tells you which factors the user can
+ // be challenged with, and which they can newly enroll.
+ final requirements = e.mfaRequirements;
+ print('Can challenge: '
+ '${requirements?.challenge.map((f) => f.type).toList()}');
+ print('Can enroll: '
+ '${requirements?.enroll.map((f) => f.type).toList()}');
+
+ // ...drive the challenge or enrollment flow with `mfa` (see below).
+ }
+}
+```
+
+
+
+
+ Web
+
+> **Note:** Web MFA is backed by the [auth0-spa-js](https://github.com/auth0/auth0-spa-js) programmatic MFA API and requires **auth0-spa-js v2.21.0 or later** loaded on your page. The MFA context is stored on the underlying client, so you must call `mfa(...)` on the **same `Auth0Web` instance** that triggered the error.
+
+On the web, a failing token request throws a `WebException` with `code == 'MFA_REQUIRED'`. The `mfa_token` is carried in its `details` map under the `mfaToken` key.
+
+```dart
+final auth0Web = Auth0Web('YOUR_DOMAIN', 'YOUR_CLIENT_ID');
+
+try {
+ await auth0Web.credentials();
+} on WebException catch (e) {
+ final mfaToken = e.details['mfaToken'] as String?;
+ if (e.code == 'MFA_REQUIRED' && mfaToken != null) {
+ final mfa = auth0Web.mfa(mfaToken: mfaToken);
+
+ // ...drive the challenge or enrollment flow with `mfa` (see below).
+ }
+}
+```
+
+
+
+### Listing authenticators and challenging a factor
+
+If the user already has authenticators enrolled, list them and trigger a challenge on the one they choose. For out-of-band factors (SMS, Voice, Email, Push) the challenge delivers the code and returns an `oobCode`; for TOTP you verify the code directly without challenging.
+
+```dart
+// Mobile: `factorsAllowed` is required and must list at least one factor type
+// (e.g. ['otp', 'oob']) โ the native SDKs reject an empty list with
+// `invalid_request`.
+final authenticators =
+ await mfa.getAuthenticators(factorsAllowed: ['otp', 'oob']);
+
+// Web: `getAuthenticators()` takes no `factorsAllowed` argument โ filtering is
+// applied server-side from the mfa_token's requirements.
+// final authenticators = await mfa.getAuthenticators();
+
+final selected = authenticators.first;
+final challenge = await mfa.challenge(authenticatorId: selected.id);
+
+// `challenge.oobCode` is used to verify out-of-band factors (see below).
+// When `challenge.bindingMethod == 'prompt'`, the user must also enter the
+// code they received as the `bindingCode`.
+```
+
+### Enrolling a new factor
+
+If the user has no suitable authenticator yet, enroll one. Each enrollment returns an `MfaEnrollmentChallenge`; which of its fields are populated depends on the factor.
+
+```dart
+// TOTP (authenticator app): render `barcodeUri` as a QR code (or show
+// `totpSecret`), then verify with the OTP from the app.
+final totp = await mfa.enrollTotp();
+// totp.barcodeUri, totp.totpSecret, totp.recoveryCodes
+
+// Phone: an OOB code is sent to the number via SMS.
+// On mobile the native SDKs only support the SMS channel, so `enrollPhone`
+// takes no `type`. On web you may pass `type: PhoneType.voice` for a voice call.
+final phone = await mfa.enrollPhone(phoneNumber: '+1234567890');
+
+// Email: an OOB code is sent to the address.
+final email = await mfa.enrollEmail(email: 'user@example.com');
+
+// Push (Auth0 Guardian): render `barcodeUri` for the user to scan.
+final push = await mfa.enrollPush();
+```
+
+### Verifying and exchanging for credentials
+
+Verifying completes the flow and exchanges the `mfa_token` for the user's `Credentials`. Pick the method that matches the factor:
+
+Call the one method that matches the factor (each returns `Credentials`):
+
+```dart
+// TOTP โ the code from the authenticator app.
+final credentials = await mfa.verifyOtp(otp: '123456');
+```
+
+```dart
+// Out-of-band (SMS, Voice, Email, Push) โ the `oobCode` from the challenge
+// (or enrollment). Provide `bindingCode` when the challenge's bindingMethod
+// is `prompt` (the user enters the code they received).
+final credentials = await mfa.verifyOob(
+ oobCode: challenge.oobCode!,
+ bindingCode: '123456',
+);
+```
+
+```dart
+// Recovery code โ a one-time code the user saved during enrollment.
+final credentials = await mfa.verifyRecoveryCode(recoveryCode: 'ABCD1234...');
+```
+
+On **mobile**, persist the returned credentials as usual:
+
+```dart
+await auth0.credentialsManager.storeCredentials(credentials);
+```
+
+On **Web**, auth0-spa-js manages the credential cache for you, so there is no separate store step โ retrieve credentials afterward with `auth0Web.credentials()`.
+
+### Errors
+
+On **mobile**, MFA API calls throw an `MfaException` on failure. It exposes convenience getters for the common cases so you can branch without inspecting raw error codes.
+
+```dart
+try {
+ final credentials = await mfa.verifyOtp(otp: '000000');
+} on MfaException catch (e) {
+ if (e.isMfaTokenExpired) {
+ // The mfa_token is no longer valid โ restart the login flow.
+ } else if (e.isInvalidCode) {
+ // The user entered a wrong/expired code โ let them retry.
+ } else if (e.isNetworkError) {
+ // Transient โ `e.isRetryable` is true.
+ } else {
+ print('${e.code}: ${e.message}');
+ }
+}
+```
+
+On **Web**, MFA API calls throw a `WebException`. Inspect its `code` and `message` to branch (for example, an expired `mfa_token` surfaces with code `expired_token`).
+
+```dart
+try {
+ final credentials = await mfa.verifyOtp(otp: '000000');
+} on WebException catch (e) {
+ print('${e.code}: ${e.message}');
+}
+```
+
+---
+
+[Go up โคด](#examples)
diff --git a/auth0_flutter/README.md b/auth0_flutter/README.md
index af2661d8c..351e1c506 100644
--- a/auth0_flutter/README.md
+++ b/auth0_flutter/README.md
@@ -589,6 +589,7 @@ void dispose() {
- [Log in with passkeys](EXAMPLES.md#log-in-with-passkeys) - authenticate an existing user with a passkey using the platform authenticator (iOS/Android only).
- [Sign up with passkeys](EXAMPLES.md#sign-up-with-passkeys) - register a new user with a passkey using the platform authenticator (iOS/Android only).
- [Native to Web SSO](EXAMPLES.md#native-to-web-sso) - obtain a session transfer token to authenticate a WebView without re-prompting the user.
+- [Multi-Factor Authentication (MFA)](EXAMPLES.md#-multi-factor-authentication-mfa) - complete an MFA flow mid-login using an `mfa_token`: list, challenge, enroll, and verify factors.
- [Handle Android process death](#android-handle-process-death-during-login) - recover credentials when the OS kills your app during login.
### ๐ช Windows
@@ -601,6 +602,7 @@ void dispose() {
### ๐ Web
- [Handling credentials on the web](EXAMPLES.md#handling-credentials-on-the-web) - how to check and retrieve credentials on the web platform.
+- [Multi-Factor Authentication (MFA)](EXAMPLES.md#-multi-factor-authentication-mfa) - complete an MFA flow mid-login using an `mfa_token`. Backed by [auth0-spa-js](https://github.com/auth0/auth0-spa-js) and requires auth0-spa-js v2.21.0+ loaded on your page.
## API reference
@@ -778,6 +780,7 @@ try {
- [onLoad](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter_web/Auth0Web/onLoad.html)
- [logout](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter_web/Auth0Web/logout.html)
- [credentials](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter_web/Auth0Web/credentials.html)
+- [mfa](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter_web/Auth0Web/mfa.html) - drive a Multi-Factor Authentication flow with an `mfa_token`
- [hasValidCredentials](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter_web/Auth0Web/hasValidCredentials.html)
## Feedback
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterMfaMethodCallHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterMfaMethodCallHandler.kt
new file mode 100644
index 000000000..7fa80ce45
--- /dev/null
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterMfaMethodCallHandler.kt
@@ -0,0 +1,30 @@
+package com.auth0.auth0_flutter
+
+import androidx.annotation.NonNull
+import com.auth0.android.authentication.AuthenticationAPIClient
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import com.auth0.auth0_flutter.request_handlers.mfa.MfaRequestHandler
+import com.auth0.auth0_flutter.utils.assertHasProperties
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel.MethodCallHandler
+import io.flutter.plugin.common.MethodChannel.Result
+
+class Auth0FlutterMfaMethodCallHandler(
+ private val mfaRequestHandlers: List
+) : MethodCallHandler {
+
+ override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
+ val request = MethodCallRequest.fromCall(call)
+
+ val handler = mfaRequestHandlers.find { it.method == call.method }
+ if (handler != null) {
+ assertHasProperties(listOf("mfaToken"), request.data)
+ val mfaToken = request.data["mfaToken"] as String
+ val client = AuthenticationAPIClient(request.account).mfaClient(mfaToken)
+
+ handler.handle(client, request, result)
+ } else {
+ result.notImplemented()
+ }
+ }
+}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt
index 7129ab672..542a660ed 100644
--- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt
@@ -29,6 +29,7 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
private lateinit var credentialsManagerMethodChannel : MethodChannel
private lateinit var dpopMethodChannel : MethodChannel
private lateinit var myAccountMethodChannel : MethodChannel
+ private lateinit var mfaMethodChannel : MethodChannel
private lateinit var binding: FlutterPlugin.FlutterPluginBinding
private lateinit var authCallHandler: Auth0FlutterAuthMethodCallHandler
private var pendingRecoveredCredentials: Map? = null
@@ -69,6 +70,15 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
ConfirmEnrollmentRequestHandler(),
UpdateAuthenticationMethodRequestHandler()
))
+ private val mfaCallHandler = Auth0FlutterMfaMethodCallHandler(listOf(
+ com.auth0.auth0_flutter.request_handlers.mfa.GetAuthenticatorsRequestHandler(),
+ com.auth0.auth0_flutter.request_handlers.mfa.EnrollTotpRequestHandler(),
+ com.auth0.auth0_flutter.request_handlers.mfa.EnrollPhoneRequestHandler(),
+ com.auth0.auth0_flutter.request_handlers.mfa.EnrollEmailRequestHandler(),
+ com.auth0.auth0_flutter.request_handlers.mfa.EnrollPushRequestHandler(),
+ com.auth0.auth0_flutter.request_handlers.mfa.ChallengeRequestHandler(),
+ com.auth0.auth0_flutter.request_handlers.mfa.VerifyRequestHandler()
+ ))
private val processDeathCallback = object : Callback {
override fun onSuccess(credentials: Credentials) {
@@ -152,6 +162,9 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
myAccountMethodChannel = MethodChannel(messenger, "auth0.com/auth0_flutter/my_account")
myAccountMethodChannel.setMethodCallHandler(myAccountCallHandler)
myAccountCallHandler.context = context
+
+ mfaMethodChannel = MethodChannel(messenger, "auth0.com/auth0_flutter/mfa")
+ mfaMethodChannel.setMethodCallHandler(mfaCallHandler)
}
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: MethodChannel.Result) {
@@ -178,6 +191,7 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
credentialsManagerMethodChannel.setMethodCallHandler(null)
dpopMethodChannel.setMethodCallHandler(null)
myAccountMethodChannel.setMethodCallHandler(null)
+ mfaMethodChannel.setMethodCallHandler(null)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MfaExtensions.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MfaExtensions.kt
new file mode 100644
index 000000000..b0e4a7285
--- /dev/null
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MfaExtensions.kt
@@ -0,0 +1,77 @@
+package com.auth0.auth0_flutter
+
+import com.auth0.android.authentication.mfa.MfaException
+import com.auth0.android.result.Authenticator
+import com.auth0.android.result.Challenge
+import com.auth0.android.result.Credentials
+import com.auth0.android.result.EnrollmentChallenge
+import com.auth0.android.result.MfaEnrollmentChallenge
+import com.auth0.android.result.OobEnrollmentChallenge
+import com.auth0.android.result.RecoveryCodeEnrollmentChallenge
+import com.auth0.android.result.TotpEnrollmentChallenge
+
+fun Authenticator.toMfaMap(): Map = buildMap {
+ put("id", id)
+ put("type", type)
+ put("authenticator_type", authenticatorType)
+ put("active", active)
+ put("oob_channel", oobChannel)
+ put("name", name)
+}
+
+fun Challenge.toMfaChallengeMap(): Map = buildMap {
+ put("challenge_type", challengeType)
+ put("oob_code", oobCode)
+ put("binding_method", bindingMethod)
+}
+
+fun EnrollmentChallenge.toMfaEnrollmentMap(): Map = buildMap {
+ put("id", id)
+ put("auth_session", authSession)
+ put("oob_code", oobCode)
+ when (val challenge = this@toMfaEnrollmentMap) {
+ is TotpEnrollmentChallenge -> {
+ put("authenticator_type", "otp")
+ put("totp_secret", challenge.manualInputCode)
+ put("barcode_uri", challenge.barcodeUri)
+ }
+ is OobEnrollmentChallenge -> {
+ put("authenticator_type", "oob")
+ put("binding_method", challenge.bindingMethod)
+ }
+ is RecoveryCodeEnrollmentChallenge -> {
+ put("authenticator_type", "recovery-code")
+ put("recovery_codes", listOf(challenge.recoveryCode))
+ }
+ is MfaEnrollmentChallenge -> {}
+ else -> {}
+ }
+}
+
+fun Credentials.toMfaCredentialsMap(): Map {
+ val scopes = scope?.split(" ") ?: listOf()
+ val formattedDate = expiresAt.toInstant().toString()
+ return mapOf(
+ "accessToken" to accessToken,
+ "idToken" to idToken,
+ "refreshToken" to refreshToken,
+ "userProfile" to user.toMap(),
+ "expiresAt" to formattedDate,
+ "scopes" to scopes,
+ "tokenType" to type
+ )
+}
+
+fun MfaException.toMfaMap(): Map = buildMap {
+ // `code` and `description` carry the actual error from the native MFA
+ // SDK; surface them in the details map (in addition to the top-level
+ // result.error code/message) so the Dart layer always has them.
+ put("code", getCode())
+ put("description", getDescription())
+ put("_statusCode", statusCode)
+ put(
+ "_errorFlags", mapOf(
+ "isNetworkError" to (getCode() == "network_error")
+ )
+ )
+}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/ChallengeRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/ChallengeRequestHandler.kt
new file mode 100644
index 000000000..ba8992ca5
--- /dev/null
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/ChallengeRequestHandler.kt
@@ -0,0 +1,41 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaException.MfaChallengeException
+import com.auth0.android.callback.Callback
+import com.auth0.android.result.Challenge
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import com.auth0.auth0_flutter.toMfaChallengeMap
+import com.auth0.auth0_flutter.toMfaMap
+import com.auth0.auth0_flutter.utils.assertHasProperties
+import io.flutter.plugin.common.MethodChannel
+
+private const val MFA_CHALLENGE_METHOD = "mfa#challenge"
+
+class ChallengeRequestHandler : MfaRequestHandler {
+ override val method: String = MFA_CHALLENGE_METHOD
+
+ override fun handle(
+ client: MfaApiClient,
+ request: MethodCallRequest,
+ result: MethodChannel.Result
+ ) {
+ assertHasProperties(listOf("authenticatorId"), request.data)
+ val authenticatorId = request.data["authenticatorId"] as String
+
+ client.challenge(authenticatorId)
+ .start(object : Callback {
+ override fun onFailure(exception: MfaChallengeException) {
+ result.error(
+ exception.getCode(),
+ exception.getDescription(),
+ exception.toMfaMap()
+ )
+ }
+
+ override fun onSuccess(res: Challenge) {
+ result.success(res.toMfaChallengeMap())
+ }
+ })
+ }
+}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollEmailRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollEmailRequestHandler.kt
new file mode 100644
index 000000000..511afcaa3
--- /dev/null
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollEmailRequestHandler.kt
@@ -0,0 +1,42 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaEnrollmentType
+import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException
+import com.auth0.android.callback.Callback
+import com.auth0.android.result.EnrollmentChallenge
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import com.auth0.auth0_flutter.toMfaEnrollmentMap
+import com.auth0.auth0_flutter.toMfaMap
+import com.auth0.auth0_flutter.utils.assertHasProperties
+import io.flutter.plugin.common.MethodChannel
+
+private const val MFA_ENROLL_EMAIL_METHOD = "mfa#enrollEmail"
+
+class EnrollEmailRequestHandler : MfaRequestHandler {
+ override val method: String = MFA_ENROLL_EMAIL_METHOD
+
+ override fun handle(
+ client: MfaApiClient,
+ request: MethodCallRequest,
+ result: MethodChannel.Result
+ ) {
+ assertHasProperties(listOf("email"), request.data)
+ val email = request.data["email"] as String
+
+ client.enroll(MfaEnrollmentType.Email(email))
+ .start(object : Callback {
+ override fun onFailure(exception: MfaEnrollmentException) {
+ result.error(
+ exception.getCode(),
+ exception.getDescription(),
+ exception.toMfaMap()
+ )
+ }
+
+ override fun onSuccess(res: EnrollmentChallenge) {
+ result.success(res.toMfaEnrollmentMap())
+ }
+ })
+ }
+}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPhoneRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPhoneRequestHandler.kt
new file mode 100644
index 000000000..caf4954c0
--- /dev/null
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPhoneRequestHandler.kt
@@ -0,0 +1,42 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaEnrollmentType
+import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException
+import com.auth0.android.callback.Callback
+import com.auth0.android.result.EnrollmentChallenge
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import com.auth0.auth0_flutter.toMfaEnrollmentMap
+import com.auth0.auth0_flutter.toMfaMap
+import com.auth0.auth0_flutter.utils.assertHasProperties
+import io.flutter.plugin.common.MethodChannel
+
+private const val MFA_ENROLL_PHONE_METHOD = "mfa#enrollPhone"
+
+class EnrollPhoneRequestHandler : MfaRequestHandler {
+ override val method: String = MFA_ENROLL_PHONE_METHOD
+
+ override fun handle(
+ client: MfaApiClient,
+ request: MethodCallRequest,
+ result: MethodChannel.Result
+ ) {
+ assertHasProperties(listOf("phoneNumber"), request.data)
+ val phoneNumber = request.data["phoneNumber"] as String
+
+ client.enroll(MfaEnrollmentType.Phone(phoneNumber))
+ .start(object : Callback {
+ override fun onFailure(exception: MfaEnrollmentException) {
+ result.error(
+ exception.getCode(),
+ exception.getDescription(),
+ exception.toMfaMap()
+ )
+ }
+
+ override fun onSuccess(res: EnrollmentChallenge) {
+ result.success(res.toMfaEnrollmentMap())
+ }
+ })
+ }
+}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPushRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPushRequestHandler.kt
new file mode 100644
index 000000000..fd175a972
--- /dev/null
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPushRequestHandler.kt
@@ -0,0 +1,38 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaEnrollmentType
+import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException
+import com.auth0.android.callback.Callback
+import com.auth0.android.result.EnrollmentChallenge
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import com.auth0.auth0_flutter.toMfaEnrollmentMap
+import com.auth0.auth0_flutter.toMfaMap
+import io.flutter.plugin.common.MethodChannel
+
+private const val MFA_ENROLL_PUSH_METHOD = "mfa#enrollPush"
+
+class EnrollPushRequestHandler : MfaRequestHandler {
+ override val method: String = MFA_ENROLL_PUSH_METHOD
+
+ override fun handle(
+ client: MfaApiClient,
+ request: MethodCallRequest,
+ result: MethodChannel.Result
+ ) {
+ client.enroll(MfaEnrollmentType.Push)
+ .start(object : Callback {
+ override fun onFailure(exception: MfaEnrollmentException) {
+ result.error(
+ exception.getCode(),
+ exception.getDescription(),
+ exception.toMfaMap()
+ )
+ }
+
+ override fun onSuccess(res: EnrollmentChallenge) {
+ result.success(res.toMfaEnrollmentMap())
+ }
+ })
+ }
+}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollTotpRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollTotpRequestHandler.kt
new file mode 100644
index 000000000..d78481ab8
--- /dev/null
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollTotpRequestHandler.kt
@@ -0,0 +1,38 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaEnrollmentType
+import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException
+import com.auth0.android.callback.Callback
+import com.auth0.android.result.EnrollmentChallenge
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import com.auth0.auth0_flutter.toMfaEnrollmentMap
+import com.auth0.auth0_flutter.toMfaMap
+import io.flutter.plugin.common.MethodChannel
+
+private const val MFA_ENROLL_TOTP_METHOD = "mfa#enrollTotp"
+
+class EnrollTotpRequestHandler : MfaRequestHandler {
+ override val method: String = MFA_ENROLL_TOTP_METHOD
+
+ override fun handle(
+ client: MfaApiClient,
+ request: MethodCallRequest,
+ result: MethodChannel.Result
+ ) {
+ client.enroll(MfaEnrollmentType.Otp)
+ .start(object : Callback {
+ override fun onFailure(exception: MfaEnrollmentException) {
+ result.error(
+ exception.getCode(),
+ exception.getDescription(),
+ exception.toMfaMap()
+ )
+ }
+
+ override fun onSuccess(res: EnrollmentChallenge) {
+ result.success(res.toMfaEnrollmentMap())
+ }
+ })
+ }
+}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/GetAuthenticatorsRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/GetAuthenticatorsRequestHandler.kt
new file mode 100644
index 000000000..dfc8f97b0
--- /dev/null
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/GetAuthenticatorsRequestHandler.kt
@@ -0,0 +1,39 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaException.MfaListAuthenticatorsException
+import com.auth0.android.callback.Callback
+import com.auth0.android.result.Authenticator
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import com.auth0.auth0_flutter.toMfaMap
+import io.flutter.plugin.common.MethodChannel
+
+private const val MFA_GET_AUTHENTICATORS_METHOD = "mfa#getAuthenticators"
+
+class GetAuthenticatorsRequestHandler : MfaRequestHandler {
+ override val method: String = MFA_GET_AUTHENTICATORS_METHOD
+
+ override fun handle(
+ client: MfaApiClient,
+ request: MethodCallRequest,
+ result: MethodChannel.Result
+ ) {
+ @Suppress("UNCHECKED_CAST")
+ val factorsAllowed = (request.data["factorsAllowed"] as? List) ?: listOf()
+
+ client.getAuthenticators(factorsAllowed)
+ .start(object : Callback, MfaListAuthenticatorsException> {
+ override fun onFailure(exception: MfaListAuthenticatorsException) {
+ result.error(
+ exception.getCode(),
+ exception.getDescription(),
+ exception.toMfaMap()
+ )
+ }
+
+ override fun onSuccess(res: List) {
+ result.success(res.map { it.toMfaMap() })
+ }
+ })
+ }
+}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/MfaRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/MfaRequestHandler.kt
new file mode 100644
index 000000000..1f805ff1f
--- /dev/null
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/MfaRequestHandler.kt
@@ -0,0 +1,10 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import io.flutter.plugin.common.MethodChannel
+
+interface MfaRequestHandler {
+ val method: String
+ fun handle(client: MfaApiClient, request: MethodCallRequest, result: MethodChannel.Result)
+}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/VerifyRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/VerifyRequestHandler.kt
new file mode 100644
index 000000000..0473f7ecf
--- /dev/null
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/VerifyRequestHandler.kt
@@ -0,0 +1,70 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaException.MfaVerifyException
+import com.auth0.android.authentication.mfa.MfaVerificationType
+import com.auth0.android.callback.Callback
+import com.auth0.android.result.Credentials
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import com.auth0.auth0_flutter.toMfaCredentialsMap
+import com.auth0.auth0_flutter.toMfaMap
+import com.auth0.auth0_flutter.utils.assertHasProperties
+import io.flutter.plugin.common.MethodChannel
+
+private const val MFA_VERIFY_METHOD = "mfa#verify"
+
+class VerifyRequestHandler : MfaRequestHandler {
+ override val method: String = MFA_VERIFY_METHOD
+
+ override fun handle(
+ client: MfaApiClient,
+ request: MethodCallRequest,
+ result: MethodChannel.Result
+ ) {
+ assertHasProperties(listOf("grantType"), request.data)
+
+ val type = when (val grantType = request.data["grantType"] as String) {
+ "otp" -> {
+ assertHasProperties(listOf("otp"), request.data)
+ MfaVerificationType.Otp(request.data["otp"] as String)
+ }
+ "oob" -> {
+ assertHasProperties(listOf("oobCode"), request.data)
+ MfaVerificationType.Oob(
+ request.data["oobCode"] as String,
+ request.data["bindingCode"] as? String
+ )
+ }
+ "recovery_code" -> {
+ assertHasProperties(listOf("recoveryCode"), request.data)
+ MfaVerificationType.RecoveryCode(request.data["recoveryCode"] as String)
+ }
+ else -> throw IllegalArgumentException("Unknown grantType: $grantType")
+ }
+
+ val verifyRequest = client.verify(type)
+
+ val scopes = (request.data["scopes"] ?: arrayListOf()) as ArrayList<*>
+ if (scopes.isNotEmpty()) {
+ verifyRequest.addParameter("scope", scopes.joinToString(separator = " "))
+ }
+ if (request.data["audience"] is String) {
+ verifyRequest.addParameter("audience", request.data["audience"] as String)
+ }
+
+ verifyRequest
+ .start(object : Callback {
+ override fun onFailure(exception: MfaVerifyException) {
+ result.error(
+ exception.getCode(),
+ exception.getDescription(),
+ exception.toMfaMap()
+ )
+ }
+
+ override fun onSuccess(res: Credentials) {
+ result.success(res.toMfaCredentialsMap())
+ }
+ })
+ }
+}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterPluginTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterPluginTest.kt
index 6a4ea3b3e..fe225affa 100644
--- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterPluginTest.kt
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterPluginTest.kt
@@ -39,8 +39,9 @@ class Auth0FlutterPluginTest {
assertMethodcallHandler(2)
assertMethodcallHandler(3)
assertMethodcallHandler(4)
+ assertMethodcallHandler(5)
- assert(constructed.size == 5)
+ assert(constructed.size == 6)
}
}
@@ -71,8 +72,9 @@ class Auth0FlutterPluginTest {
assertMethodcallHandler(2)
assertMethodcallHandler(3)
assertMethodcallHandler(4)
+ assertMethodcallHandler(5)
- assert(constructed.size == 5)
+ assert(constructed.size == 6)
}
}
@@ -110,7 +112,7 @@ class Auth0FlutterPluginTest {
assert(getHandler(1).activity == mockActivity)
assert(getHandler(1).context == mockContext)
- assert(constructed.size == 5)
+ assert(constructed.size == 6)
}
}
}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/MfaExtensionsTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/MfaExtensionsTest.kt
new file mode 100644
index 000000000..2fd45f4d7
--- /dev/null
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/MfaExtensionsTest.kt
@@ -0,0 +1,54 @@
+package com.auth0.auth0_flutter
+
+import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException
+import org.hamcrest.CoreMatchers.equalTo
+import org.hamcrest.MatcherAssert.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class MfaExtensionsTest {
+
+ @Test
+ fun `toMfaMap sets isNetworkError true when code is network_error`() {
+ val exception = mock()
+ whenever(exception.getCode()).thenReturn("network_error")
+ whenever(exception.getDescription()).thenReturn("Network error")
+ whenever(exception.statusCode).thenReturn(0)
+
+ val map = exception.toMfaMap()
+ val errorFlags = map["_errorFlags"] as Map<*, *>
+
+ assertThat(errorFlags["isNetworkError"], equalTo(true))
+ }
+
+ @Test
+ fun `toMfaMap sets isNetworkError false for non-network codes`() {
+ val exception = mock()
+ whenever(exception.getCode()).thenReturn("invalid_request")
+ whenever(exception.getDescription()).thenReturn("Invalid request")
+ whenever(exception.statusCode).thenReturn(400)
+
+ val map = exception.toMfaMap()
+ val errorFlags = map["_errorFlags"] as Map<*, *>
+
+ assertThat(errorFlags["isNetworkError"], equalTo(false))
+ assertThat(map["_statusCode"], equalTo(400))
+ }
+
+ @Test
+ fun `toMfaMap carries the native error code and description`() {
+ val exception = mock()
+ whenever(exception.getCode()).thenReturn("invalid_request")
+ whenever(exception.getDescription()).thenReturn("Invalid request")
+ whenever(exception.statusCode).thenReturn(400)
+
+ val map = exception.toMfaMap()
+
+ assertThat(map["code"], equalTo("invalid_request"))
+ assertThat(map["description"], equalTo("Invalid request"))
+ }
+}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/Auth0FlutterMfaMethodCallHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/Auth0FlutterMfaMethodCallHandlerTest.kt
new file mode 100644
index 000000000..6fc185aa4
--- /dev/null
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/Auth0FlutterMfaMethodCallHandlerTest.kt
@@ -0,0 +1,73 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.auth0_flutter.Auth0FlutterMfaMethodCallHandler
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel.Result
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.`when`
+import org.mockito.kotlin.*
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class Auth0FlutterMfaMethodCallHandlerTest {
+ private val defaultArguments = hashMapOf(
+ "_account" to mapOf(
+ "domain" to "test.auth0.com",
+ "clientId" to "test-client",
+ ),
+ "_userAgent" to mapOf(
+ "name" to "auth0-flutter",
+ "version" to "1.0.0"
+ ),
+ "mfaToken" to "test-mfa-token"
+ )
+
+ @Test
+ fun `handler should result in notImplemented if no matching handler`() {
+ val handler = Auth0FlutterMfaMethodCallHandler(emptyList())
+ val mockResult = mock()
+
+ handler.onMethodCall(MethodCall("mfa#unknown", defaultArguments), mockResult)
+
+ verify(mockResult).notImplemented()
+ }
+
+ @Test
+ fun `handler should call the correct handler when matched`() {
+ val mockRequestHandler = mock()
+ `when`(mockRequestHandler.method).thenReturn("mfa#getAuthenticators")
+
+ val handler = Auth0FlutterMfaMethodCallHandler(listOf(mockRequestHandler))
+ val mockResult = mock()
+
+ handler.onMethodCall(
+ MethodCall("mfa#getAuthenticators", defaultArguments),
+ mockResult
+ )
+
+ verify(mockRequestHandler).handle(any(), any(), eq(mockResult))
+ }
+
+ @Test
+ fun `handler should not call non-matching handlers`() {
+ val getAuthenticatorsHandler = mock()
+ val challengeHandler = mock()
+
+ `when`(getAuthenticatorsHandler.method).thenReturn("mfa#getAuthenticators")
+ `when`(challengeHandler.method).thenReturn("mfa#challenge")
+
+ val handler = Auth0FlutterMfaMethodCallHandler(
+ listOf(getAuthenticatorsHandler, challengeHandler)
+ )
+ val mockResult = mock()
+
+ handler.onMethodCall(
+ MethodCall("mfa#getAuthenticators", defaultArguments),
+ mockResult
+ )
+
+ verify(getAuthenticatorsHandler).handle(any(), any(), eq(mockResult))
+ verify(challengeHandler, times(0)).handle(any(), any(), any())
+ }
+}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/ChallengeRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/ChallengeRequestHandlerTest.kt
new file mode 100644
index 000000000..dea2eb3e5
--- /dev/null
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/ChallengeRequestHandlerTest.kt
@@ -0,0 +1,85 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.Auth0
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaException.MfaChallengeException
+import com.auth0.android.callback.Callback
+import com.auth0.android.request.Request
+import com.auth0.android.result.Challenge
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import io.flutter.plugin.common.MethodChannel.Result
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.*
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class ChallengeRequestHandlerTest {
+
+ @Test
+ fun `should call challenge with authenticatorId`() {
+ val handler = ChallengeRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf(
+ "mfaToken" to "mfa-token",
+ "authenticatorId" to "sms|dev_1"
+ )
+ )
+
+ whenever(mockClient.challenge(any())).thenReturn(mockRequest)
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockClient).challenge(eq("sms|dev_1"))
+ }
+
+ @Test
+ fun `should call result success with challenge on success`() {
+ val handler = ChallengeRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf(
+ "mfaToken" to "mfa-token",
+ "authenticatorId" to "sms|dev_1"
+ )
+ )
+
+ val challenge = mock()
+ whenever(challenge.challengeType).thenReturn("oob")
+ whenever(challenge.oobCode).thenReturn("oob-code")
+ whenever(challenge.bindingMethod).thenReturn("prompt")
+
+ whenever(mockClient.challenge(any())).thenReturn(mockRequest)
+ doAnswer {
+ val callback = it.getArgument>(0)
+ callback.onSuccess(challenge)
+ }.whenever(mockRequest).start(any())
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockResult).success(any())
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `should throw when authenticatorId is missing`() {
+ val handler = ChallengeRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf("mfaToken" to "mfa-token")
+ )
+
+ handler.handle(mockClient, request, mockResult)
+ }
+}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollEmailRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollEmailRequestHandlerTest.kt
new file mode 100644
index 000000000..c7ddb0a48
--- /dev/null
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollEmailRequestHandlerTest.kt
@@ -0,0 +1,55 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.Auth0
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaEnrollmentType
+import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException
+import com.auth0.android.callback.Callback
+import com.auth0.android.request.Request
+import com.auth0.android.result.EnrollmentChallenge
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import io.flutter.plugin.common.MethodChannel.Result
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.*
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class EnrollEmailRequestHandlerTest {
+
+ @Test
+ fun `should call enroll with Email type`() {
+ val handler = EnrollEmailRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf(
+ "mfaToken" to "mfa-token",
+ "email" to "user@example.com"
+ )
+ )
+
+ whenever(mockClient.enroll(any())).thenReturn(mockRequest)
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockClient).enroll(eq(MfaEnrollmentType.Email("user@example.com")))
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `should throw when email is missing`() {
+ val handler = EnrollEmailRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf("mfaToken" to "mfa-token")
+ )
+
+ handler.handle(mockClient, request, mockResult)
+ }
+}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPhoneRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPhoneRequestHandlerTest.kt
new file mode 100644
index 000000000..f0ef2846d
--- /dev/null
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPhoneRequestHandlerTest.kt
@@ -0,0 +1,86 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.Auth0
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaEnrollmentType
+import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException
+import com.auth0.android.callback.Callback
+import com.auth0.android.request.Request
+import com.auth0.android.result.EnrollmentChallenge
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import io.flutter.plugin.common.MethodChannel.Result
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.*
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class EnrollPhoneRequestHandlerTest {
+
+ @Test
+ fun `should call enroll with Phone type`() {
+ val handler = EnrollPhoneRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf(
+ "mfaToken" to "mfa-token",
+ "phoneNumber" to "+1234567890"
+ )
+ )
+
+ whenever(mockClient.enroll(any())).thenReturn(mockRequest)
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockClient).enroll(eq(MfaEnrollmentType.Phone("+1234567890")))
+ }
+
+ @Test
+ fun `should call result error on failure`() {
+ val handler = EnrollPhoneRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf(
+ "mfaToken" to "mfa-token",
+ "phoneNumber" to "+1234567890"
+ )
+ )
+ val exception = mock()
+ whenever(exception.getCode()).thenReturn("invalid_phone")
+ whenever(exception.getDescription()).thenReturn("Invalid phone")
+ whenever(exception.statusCode).thenReturn(400)
+
+ whenever(mockClient.enroll(any())).thenReturn(mockRequest)
+ doAnswer {
+ val callback =
+ it.getArgument>(0)
+ callback.onFailure(exception)
+ }.whenever(mockRequest).start(any())
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockResult).error(eq("invalid_phone"), eq("Invalid phone"), any())
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `should throw when phoneNumber is missing`() {
+ val handler = EnrollPhoneRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf("mfaToken" to "mfa-token")
+ )
+
+ handler.handle(mockClient, request, mockResult)
+ }
+}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPushRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPushRequestHandlerTest.kt
new file mode 100644
index 000000000..02112aa40
--- /dev/null
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPushRequestHandlerTest.kt
@@ -0,0 +1,37 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.Auth0
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaEnrollmentType
+import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException
+import com.auth0.android.request.Request
+import com.auth0.android.result.EnrollmentChallenge
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import io.flutter.plugin.common.MethodChannel.Result
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.*
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class EnrollPushRequestHandlerTest {
+
+ @Test
+ fun `should call enroll with Push type`() {
+ val handler = EnrollPushRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf("mfaToken" to "mfa-token")
+ )
+
+ whenever(mockClient.enroll(any())).thenReturn(mockRequest)
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockClient).enroll(eq(MfaEnrollmentType.Push))
+ }
+}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollTotpRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollTotpRequestHandlerTest.kt
new file mode 100644
index 000000000..6902c9991
--- /dev/null
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollTotpRequestHandlerTest.kt
@@ -0,0 +1,90 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.Auth0
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaEnrollmentType
+import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException
+import com.auth0.android.callback.Callback
+import com.auth0.android.request.Request
+import com.auth0.android.result.EnrollmentChallenge
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import io.flutter.plugin.common.MethodChannel.Result
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.*
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class EnrollTotpRequestHandlerTest {
+
+ @Test
+ fun `should call enroll with Otp type`() {
+ val handler = EnrollTotpRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf("mfaToken" to "mfa-token")
+ )
+
+ whenever(mockClient.enroll(any())).thenReturn(mockRequest)
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockClient).enroll(eq(MfaEnrollmentType.Otp))
+ }
+
+ @Test
+ fun `should call result success with challenge on success`() {
+ val handler = EnrollTotpRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf("mfaToken" to "mfa-token")
+ )
+
+ whenever(mockClient.enroll(any())).thenReturn(mockRequest)
+ doAnswer {
+ val callback =
+ it.getArgument>(0)
+ callback.onSuccess(mock())
+ }.whenever(mockRequest).start(any())
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockResult).success(any())
+ }
+
+ @Test
+ fun `should call result error on failure`() {
+ val handler = EnrollTotpRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+ val request = MethodCallRequest(
+ account = mockAccount,
+ hashMapOf("mfaToken" to "mfa-token")
+ )
+ val exception = mock()
+ whenever(exception.getCode()).thenReturn("invalid_request")
+ whenever(exception.getDescription()).thenReturn("Invalid request")
+ whenever(exception.statusCode).thenReturn(400)
+
+ whenever(mockClient.enroll(any())).thenReturn(mockRequest)
+ doAnswer {
+ val callback =
+ it.getArgument>(0)
+ callback.onFailure(exception)
+ }.whenever(mockRequest).start(any())
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockResult).error(eq("invalid_request"), eq("Invalid request"), any())
+ }
+}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/GetAuthenticatorsRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/GetAuthenticatorsRequestHandlerTest.kt
new file mode 100644
index 000000000..45fa87e0b
--- /dev/null
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/GetAuthenticatorsRequestHandlerTest.kt
@@ -0,0 +1,95 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.Auth0
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaException.MfaListAuthenticatorsException
+import com.auth0.android.callback.Callback
+import com.auth0.android.request.Request
+import com.auth0.android.result.Authenticator
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import io.flutter.plugin.common.MethodChannel.Result
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.*
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class GetAuthenticatorsRequestHandlerTest {
+
+ @Test
+ fun `should call getAuthenticators with factorsAllowed`() {
+ val handler = GetAuthenticatorsRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock, MfaListAuthenticatorsException>>()
+ val options = hashMapOf(
+ "mfaToken" to "mfa-token",
+ "factorsAllowed" to listOf("oob")
+ )
+ val request = MethodCallRequest(account = mockAccount, options)
+
+ whenever(mockClient.getAuthenticators(any())).thenReturn(mockRequest)
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockClient).getAuthenticators(eq(listOf("oob")))
+ }
+
+ @Test
+ fun `should call result success with authenticators on success`() {
+ val handler = GetAuthenticatorsRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock, MfaListAuthenticatorsException>>()
+ val options = hashMapOf("mfaToken" to "mfa-token")
+ val request = MethodCallRequest(account = mockAccount, options)
+
+ val authenticator = mock()
+ whenever(authenticator.id).thenReturn("sms|1")
+ whenever(authenticator.type).thenReturn("phone")
+ whenever(authenticator.authenticatorType).thenReturn("oob")
+ whenever(authenticator.active).thenReturn(true)
+ whenever(authenticator.oobChannel).thenReturn("sms")
+ whenever(authenticator.name).thenReturn("****4761")
+
+ whenever(mockClient.getAuthenticators(any())).thenReturn(mockRequest)
+ doAnswer {
+ val callback =
+ it.getArgument, MfaListAuthenticatorsException>>(0)
+ callback.onSuccess(listOf(authenticator))
+ }.whenever(mockRequest).start(any())
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockResult).success(any())
+ }
+
+ @Test
+ fun `should call result error on failure`() {
+ val handler = GetAuthenticatorsRequestHandler()
+ val mockResult = mock()
+ val mockAccount = mock()
+ val mockClient = mock()
+ val mockRequest = mock, MfaListAuthenticatorsException>>()
+ val options = hashMapOf("mfaToken" to "mfa-token")
+ val request = MethodCallRequest(account = mockAccount, options)
+ val exception = mock()
+
+ whenever(exception.getCode()).thenReturn("invalid_request")
+ whenever(exception.getDescription()).thenReturn("Invalid request")
+ whenever(exception.statusCode).thenReturn(400)
+
+ whenever(mockClient.getAuthenticators(any())).thenReturn(mockRequest)
+ doAnswer {
+ val callback =
+ it.getArgument, MfaListAuthenticatorsException>>(0)
+ callback.onFailure(exception)
+ }.whenever(mockRequest).start(any())
+
+ handler.handle(mockClient, request, mockResult)
+
+ verify(mockResult).error(eq("invalid_request"), eq("Invalid request"), any())
+ }
+}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/VerifyRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/VerifyRequestHandlerTest.kt
new file mode 100644
index 000000000..86c61c13d
--- /dev/null
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/VerifyRequestHandlerTest.kt
@@ -0,0 +1,212 @@
+package com.auth0.auth0_flutter.request_handlers.mfa
+
+import com.auth0.android.Auth0
+import com.auth0.android.authentication.mfa.MfaApiClient
+import com.auth0.android.authentication.mfa.MfaException.MfaVerifyException
+import com.auth0.android.authentication.mfa.MfaVerificationType
+import com.auth0.android.callback.Callback
+import com.auth0.android.request.Request
+import com.auth0.android.result.Credentials
+import com.auth0.android.result.UserProfile
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import io.flutter.plugin.common.MethodChannel.Result
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.*
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class VerifyRequestHandlerTest {
+
+ private fun requestWith(data: Map): MethodCallRequest {
+ val map = hashMapOf("mfaToken" to "mfa-token")
+ map.putAll(data)
+ return MethodCallRequest(account = mock(), map)
+ }
+
+ @Test
+ fun `should call verify with Otp type`() {
+ val handler = VerifyRequestHandler()
+ val mockResult = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+
+ whenever(mockClient.verify(any())).thenReturn(mockRequest)
+
+ handler.handle(
+ mockClient,
+ requestWith(mapOf("grantType" to "otp", "otp" to "123456")),
+ mockResult
+ )
+
+ verify(mockClient).verify(eq(MfaVerificationType.Otp("123456")))
+ }
+
+ @Test
+ fun `should call verify with Oob type and binding code`() {
+ val handler = VerifyRequestHandler()
+ val mockResult = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+
+ whenever(mockClient.verify(any())).thenReturn(mockRequest)
+
+ handler.handle(
+ mockClient,
+ requestWith(
+ mapOf(
+ "grantType" to "oob",
+ "oobCode" to "oob-code",
+ "bindingCode" to "000111"
+ )
+ ),
+ mockResult
+ )
+
+ verify(mockClient).verify(eq(MfaVerificationType.Oob("oob-code", "000111")))
+ }
+
+ @Test
+ fun `should call verify with RecoveryCode type`() {
+ val handler = VerifyRequestHandler()
+ val mockResult = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+
+ whenever(mockClient.verify(any())).thenReturn(mockRequest)
+
+ handler.handle(
+ mockClient,
+ requestWith(mapOf("grantType" to "recovery_code", "recoveryCode" to "ABCD")),
+ mockResult
+ )
+
+ verify(mockClient).verify(eq(MfaVerificationType.RecoveryCode("ABCD")))
+ }
+
+ @Test
+ fun `should add scope and audience parameters when provided`() {
+ val handler = VerifyRequestHandler()
+ val mockResult = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+
+ whenever(mockClient.verify(any())).thenReturn(mockRequest)
+ whenever(mockRequest.addParameter(any(), any())).thenReturn(mockRequest)
+
+ handler.handle(
+ mockClient,
+ requestWith(
+ mapOf(
+ "grantType" to "otp",
+ "otp" to "123456",
+ "scopes" to arrayListOf("openid", "profile"),
+ "audience" to "https://my-api.example.com"
+ )
+ ),
+ mockResult
+ )
+
+ verify(mockRequest).addParameter("scope", "openid profile")
+ verify(mockRequest).addParameter("audience", "https://my-api.example.com")
+ }
+
+ @Test
+ fun `should not add scope or audience parameters when absent`() {
+ val handler = VerifyRequestHandler()
+ val mockResult = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+
+ whenever(mockClient.verify(any())).thenReturn(mockRequest)
+
+ handler.handle(
+ mockClient,
+ requestWith(mapOf("grantType" to "otp", "otp" to "123456")),
+ mockResult
+ )
+
+ verify(mockRequest, never()).addParameter(eq("scope"), any())
+ verify(mockRequest, never()).addParameter(eq("audience"), any())
+ }
+
+ @Test
+ fun `should call result success with credentials on success`() {
+ val handler = VerifyRequestHandler()
+ val mockResult = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+
+ val credentials = mock()
+ whenever(credentials.accessToken).thenReturn("access-token")
+ whenever(credentials.idToken).thenReturn("id-token")
+ whenever(credentials.type).thenReturn("Bearer")
+ whenever(credentials.scope).thenReturn("openid")
+ whenever(credentials.expiresAt).thenReturn(java.util.Date(0))
+ val user = UserProfile(
+ "user-id",
+ "John Doe",
+ "johndoe",
+ null,
+ "john.doe@example.com",
+ true,
+ "Doe",
+ null,
+ null,
+ mapOf("sub" to "user-id"),
+ null,
+ null,
+ "John"
+ )
+ whenever(credentials.user).thenReturn(user)
+
+ whenever(mockClient.verify(any())).thenReturn(mockRequest)
+ doAnswer {
+ val callback = it.getArgument>(0)
+ callback.onSuccess(credentials)
+ }.whenever(mockRequest).start(any())
+
+ handler.handle(
+ mockClient,
+ requestWith(mapOf("grantType" to "otp", "otp" to "123456")),
+ mockResult
+ )
+
+ verify(mockResult).success(any())
+ }
+
+ @Test
+ fun `should call result error on failure`() {
+ val handler = VerifyRequestHandler()
+ val mockResult = mock()
+ val mockClient = mock()
+ val mockRequest = mock>()
+ val exception = mock()
+ whenever(exception.getCode()).thenReturn("invalid_grant")
+ whenever(exception.getDescription()).thenReturn("Invalid otp code")
+ whenever(exception.statusCode).thenReturn(403)
+
+ whenever(mockClient.verify(any())).thenReturn(mockRequest)
+ doAnswer {
+ val callback = it.getArgument>(0)
+ callback.onFailure(exception)
+ }.whenever(mockRequest).start(any())
+
+ handler.handle(
+ mockClient,
+ requestWith(mapOf("grantType" to "otp", "otp" to "123456")),
+ mockResult
+ )
+
+ verify(mockResult).error(eq("invalid_grant"), eq("Invalid otp code"), any())
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `should throw when grantType is missing`() {
+ val handler = VerifyRequestHandler()
+ val mockResult = mock()
+ val mockClient = mock()
+
+ handler.handle(mockClient, requestWith(emptyMap()), mockResult)
+ }
+}
diff --git a/auth0_flutter/darwin/Classes/MfaAPI/MfaChallengeMethodHandler.swift b/auth0_flutter/darwin/Classes/MfaAPI/MfaChallengeMethodHandler.swift
new file mode 100644
index 000000000..cc2a7e573
--- /dev/null
+++ b/auth0_flutter/darwin/Classes/MfaAPI/MfaChallengeMethodHandler.swift
@@ -0,0 +1,31 @@
+import Auth0
+
+#if os(iOS)
+import Flutter
+#else
+import FlutterMacOS
+#endif
+
+struct MfaChallengeMethodHandler: MethodHandler {
+ let client: MFAClient
+
+ func handle(with arguments: [String: Any], callback: @escaping FlutterResult) {
+ guard let mfaToken = arguments["mfaToken"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("mfaToken")))
+ }
+ guard let authenticatorId = arguments["authenticatorId"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("authenticatorId")))
+ }
+
+ client
+ .challenge(with: authenticatorId, mfaToken: mfaToken)
+ .start {
+ switch $0 {
+ case let .success(challenge):
+ callback(challenge.asDictionary())
+ case let .failure(error):
+ callback(FlutterError(from: error))
+ }
+ }
+ }
+}
diff --git a/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift b/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift
new file mode 100644
index 000000000..8971516df
--- /dev/null
+++ b/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift
@@ -0,0 +1,31 @@
+import Auth0
+
+#if os(iOS)
+import Flutter
+#else
+import FlutterMacOS
+#endif
+
+struct MfaEnrollEmailMethodHandler: MethodHandler {
+ let client: MFAClient
+
+ func handle(with arguments: [String: Any], callback: @escaping FlutterResult) {
+ guard let mfaToken = arguments["mfaToken"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("mfaToken")))
+ }
+ guard let email = arguments["email"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("email")))
+ }
+
+ client
+ .enroll(mfaToken: mfaToken, email: email)
+ .start {
+ switch $0 {
+ case let .success(challenge):
+ callback(challenge.asDictionary())
+ case let .failure(error):
+ callback(FlutterError(from: error))
+ }
+ }
+ }
+}
diff --git a/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift b/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift
new file mode 100644
index 000000000..5f2fcc67e
--- /dev/null
+++ b/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift
@@ -0,0 +1,31 @@
+import Auth0
+
+#if os(iOS)
+import Flutter
+#else
+import FlutterMacOS
+#endif
+
+struct MfaEnrollPhoneMethodHandler: MethodHandler {
+ let client: MFAClient
+
+ func handle(with arguments: [String: Any], callback: @escaping FlutterResult) {
+ guard let mfaToken = arguments["mfaToken"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("mfaToken")))
+ }
+ guard let phoneNumber = arguments["phoneNumber"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("phoneNumber")))
+ }
+
+ client
+ .enroll(mfaToken: mfaToken, phoneNumber: phoneNumber)
+ .start {
+ switch $0 {
+ case let .success(challenge):
+ callback(challenge.asDictionary())
+ case let .failure(error):
+ callback(FlutterError(from: error))
+ }
+ }
+ }
+}
diff --git a/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift b/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift
new file mode 100644
index 000000000..99a89f013
--- /dev/null
+++ b/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift
@@ -0,0 +1,28 @@
+import Auth0
+
+#if os(iOS)
+import Flutter
+#else
+import FlutterMacOS
+#endif
+
+struct MfaEnrollPushMethodHandler: MethodHandler {
+ let client: MFAClient
+
+ func handle(with arguments: [String: Any], callback: @escaping FlutterResult) {
+ guard let mfaToken = arguments["mfaToken"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("mfaToken")))
+ }
+
+ let request: Request =
+ client.enroll(mfaToken: mfaToken)
+ request.start {
+ switch $0 {
+ case let .success(challenge):
+ callback(challenge.asDictionary())
+ case let .failure(error):
+ callback(FlutterError(from: error))
+ }
+ }
+ }
+}
diff --git a/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift b/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift
new file mode 100644
index 000000000..b3a0c434b
--- /dev/null
+++ b/auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift
@@ -0,0 +1,28 @@
+import Auth0
+
+#if os(iOS)
+import Flutter
+#else
+import FlutterMacOS
+#endif
+
+struct MfaEnrollTotpMethodHandler: MethodHandler {
+ let client: MFAClient
+
+ func handle(with arguments: [String: Any], callback: @escaping FlutterResult) {
+ guard let mfaToken = arguments["mfaToken"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("mfaToken")))
+ }
+
+ let request: Request =
+ client.enroll(mfaToken: mfaToken)
+ request.start {
+ switch $0 {
+ case let .success(challenge):
+ callback(challenge.asDictionary())
+ case let .failure(error):
+ callback(FlutterError(from: error))
+ }
+ }
+ }
+}
diff --git a/auth0_flutter/darwin/Classes/MfaAPI/MfaExtensions.swift b/auth0_flutter/darwin/Classes/MfaAPI/MfaExtensions.swift
new file mode 100644
index 000000000..10f792e64
--- /dev/null
+++ b/auth0_flutter/darwin/Classes/MfaAPI/MfaExtensions.swift
@@ -0,0 +1,115 @@
+import Auth0
+
+#if os(iOS)
+import Flutter
+#else
+import FlutterMacOS
+#endif
+
+extension FlutterError {
+ convenience init(from error: MfaListAuthenticatorsError) {
+ self.init(fromMfaInfo: error.info, code: error.code, statusCode: error.statusCode)
+ }
+
+ convenience init(from error: MfaEnrollmentError) {
+ self.init(fromMfaInfo: error.info, code: error.code, statusCode: error.statusCode)
+ }
+
+ convenience init(from error: MfaChallengeError) {
+ self.init(fromMfaInfo: error.info, code: error.code, statusCode: error.statusCode)
+ }
+
+ convenience init(from error: MFAVerifyError) {
+ self.init(fromMfaInfo: error.info, code: error.code, statusCode: error.statusCode)
+ }
+
+ private convenience init(fromMfaInfo info: [String: Any], code: String, statusCode: Int) {
+ let isNetworkError = (info["cause"] as? Error) != nil
+ let description = info["error_description"] as? String
+ ?? info["description"] as? String
+ ?? code
+ let details: [String: Any] = [
+ // `code` and `description` carry the actual error; surface them in
+ // the details map (in addition to the top-level code/message) so the
+ // Dart layer always has them.
+ "code": code,
+ "description": description,
+ "_statusCode": statusCode,
+ "_errorFlags": [
+ "isNetworkError": isNetworkError
+ ]
+ ]
+ self.init(code: code, message: description, details: details)
+ }
+}
+
+extension Authenticator {
+ func asDictionary() -> [String: Any?] {
+ return [
+ "id": id,
+ "type": type,
+ "authenticator_type": authenticatorType,
+ "active": active,
+ "oob_channel": oobChannel,
+ "name": name
+ ]
+ }
+}
+
+extension MFAChallenge {
+ func asDictionary() -> [String: Any?] {
+ return [
+ "challenge_type": challengeType,
+ "oob_code": oobCode,
+ "binding_method": bindingMethod
+ ]
+ }
+}
+
+extension MFAEnrollmentChallenge {
+ func asDictionary() -> [String: Any?] {
+ return [
+ "authenticator_type": authenticatorType,
+ "oob_channel": oobChannel,
+ "oob_code": oobCode,
+ "binding_method": bindingMethod,
+ "totp_secret": nil,
+ "barcode_uri": nil,
+ "recovery_codes": recoveryCodes,
+ "id": nil,
+ "auth_session": nil
+ ]
+ }
+}
+
+extension OTPMFAEnrollmentChallenge {
+ func asDictionary() -> [String: Any?] {
+ return [
+ "authenticator_type": authenticatorType,
+ "oob_channel": nil,
+ "oob_code": nil,
+ "binding_method": nil,
+ "totp_secret": secret,
+ "barcode_uri": barcodeUri,
+ "recovery_codes": recoveryCodes,
+ "id": nil,
+ "auth_session": nil
+ ]
+ }
+}
+
+extension PushMFAEnrollmentChallenge {
+ func asDictionary() -> [String: Any?] {
+ return [
+ "authenticator_type": authenticatorType,
+ "oob_channel": oobChannel,
+ "oob_code": oobCode,
+ "binding_method": nil,
+ "totp_secret": nil,
+ "barcode_uri": barcodeUri,
+ "recovery_codes": recoveryCodes,
+ "id": nil,
+ "auth_session": nil
+ ]
+ }
+}
diff --git a/auth0_flutter/darwin/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift b/auth0_flutter/darwin/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift
new file mode 100644
index 000000000..8a95e357c
--- /dev/null
+++ b/auth0_flutter/darwin/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift
@@ -0,0 +1,30 @@
+import Auth0
+
+#if os(iOS)
+import Flutter
+#else
+import FlutterMacOS
+#endif
+
+struct MfaGetAuthenticatorsMethodHandler: MethodHandler {
+ let client: MFAClient
+
+ func handle(with arguments: [String: Any], callback: @escaping FlutterResult) {
+ guard let mfaToken = arguments["mfaToken"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("mfaToken")))
+ }
+
+ let factorsAllowed = arguments["factorsAllowed"] as? [String] ?? []
+
+ client
+ .getAuthenticators(mfaToken: mfaToken, factorsAllowed: factorsAllowed)
+ .start {
+ switch $0 {
+ case let .success(authenticators):
+ callback(authenticators.map { $0.asDictionary() })
+ case let .failure(error):
+ callback(FlutterError(from: error))
+ }
+ }
+ }
+}
diff --git a/auth0_flutter/darwin/Classes/MfaAPI/MfaHandler.swift b/auth0_flutter/darwin/Classes/MfaAPI/MfaHandler.swift
new file mode 100644
index 000000000..bb9f66a33
--- /dev/null
+++ b/auth0_flutter/darwin/Classes/MfaAPI/MfaHandler.swift
@@ -0,0 +1,79 @@
+import Auth0
+
+#if os(iOS)
+import Flutter
+#else
+import FlutterMacOS
+#endif
+
+typealias MfaClientProvider = (_ account: Account) -> MFAClient
+typealias MfaMethodHandlerProvider = (_ method: MfaHandler.Method, _ client: MFAClient) -> MethodHandler
+
+public class MfaHandler: NSObject, FlutterPlugin {
+ enum Method: String, CaseIterable {
+ case getAuthenticators = "mfa#getAuthenticators"
+ case enrollTotp = "mfa#enrollTotp"
+ case enrollPhone = "mfa#enrollPhone"
+ case enrollEmail = "mfa#enrollEmail"
+ case enrollPush = "mfa#enrollPush"
+ case challenge = "mfa#challenge"
+ case verify = "mfa#verify"
+ }
+
+ private static let channelName = "auth0.com/auth0_flutter/mfa"
+
+ public static func register(with registrar: FlutterPluginRegistrar) {
+ let handler = MfaHandler()
+
+ #if os(iOS)
+ let channel = FlutterMethodChannel(name: MfaHandler.channelName,
+ binaryMessenger: registrar.messenger())
+ #else
+ let channel = FlutterMethodChannel(name: MfaHandler.channelName,
+ binaryMessenger: registrar.messenger)
+ #endif
+
+ registrar.addMethodCallDelegate(handler, channel: channel)
+ }
+
+ var clientProvider: MfaClientProvider = { account in
+ return Auth0.mfa(clientId: account.clientId, domain: account.domain)
+ }
+
+ var methodHandlerProvider: MfaMethodHandlerProvider = { method, client in
+ switch method {
+ case .getAuthenticators: return MfaGetAuthenticatorsMethodHandler(client: client)
+ case .enrollTotp: return MfaEnrollTotpMethodHandler(client: client)
+ case .enrollPhone: return MfaEnrollPhoneMethodHandler(client: client)
+ case .enrollEmail: return MfaEnrollEmailMethodHandler(client: client)
+ case .enrollPush: return MfaEnrollPushMethodHandler(client: client)
+ case .challenge: return MfaChallengeMethodHandler(client: client)
+ case .verify: return MfaVerifyMethodHandler(client: client)
+ }
+ }
+
+ public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ guard let arguments = call.arguments as? [String: Any] else {
+ return result(FlutterError(from: .argumentsMissing))
+ }
+ guard let accountDictionary = arguments[Account.key] as? [String: String],
+ let account = Account(from: accountDictionary) else {
+ return result(FlutterError(from: .accountMissing))
+ }
+ guard let userAgentDictionary = arguments[UserAgent.key] as? [String: String],
+ UserAgent(from: userAgentDictionary) != nil else {
+ return result(FlutterError(from: .userAgentMissing))
+ }
+ guard let method = Method(rawValue: call.method) else {
+ return result(FlutterMethodNotImplemented)
+ }
+ guard arguments["mfaToken"] is String else {
+ return result(FlutterError(from: .requiredArgumentMissing("mfaToken")))
+ }
+
+ let client = clientProvider(account)
+ let methodHandler = methodHandlerProvider(method, client)
+
+ methodHandler.handle(with: arguments, callback: result)
+ }
+}
diff --git a/auth0_flutter/darwin/Classes/MfaAPI/MfaVerifyMethodHandler.swift b/auth0_flutter/darwin/Classes/MfaAPI/MfaVerifyMethodHandler.swift
new file mode 100644
index 000000000..f36176d7d
--- /dev/null
+++ b/auth0_flutter/darwin/Classes/MfaAPI/MfaVerifyMethodHandler.swift
@@ -0,0 +1,64 @@
+import Auth0
+
+#if os(iOS)
+import Flutter
+#else
+import FlutterMacOS
+#endif
+
+struct MfaVerifyMethodHandler: MethodHandler {
+ let client: MFAClient
+
+ func handle(with arguments: [String: Any], callback: @escaping FlutterResult) {
+ guard let mfaToken = arguments["mfaToken"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("mfaToken")))
+ }
+ guard let grantType = arguments["grantType"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("grantType")))
+ }
+
+ let request: Request
+
+ switch grantType {
+ case "otp":
+ guard let otp = arguments["otp"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("otp")))
+ }
+ request = client.verify(otp: otp, mfaToken: mfaToken)
+ case "oob":
+ guard let oobCode = arguments["oobCode"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("oobCode")))
+ }
+ let bindingCode = arguments["bindingCode"] as? String
+ request = client.verify(oobCode: oobCode, bindingCode: bindingCode, mfaToken: mfaToken)
+ case "recovery_code":
+ guard let recoveryCode = arguments["recoveryCode"] as? String else {
+ return callback(FlutterError(from: .requiredArgumentMissing("recoveryCode")))
+ }
+ request = client.verify(recoveryCode: recoveryCode, mfaToken: mfaToken)
+ default:
+ return callback(FlutterError(from: .requiredArgumentMissing("grantType")))
+ }
+
+ var extraParameters: [String: String] = [:]
+ if let scopes = arguments["scopes"] as? [String], !scopes.isEmpty {
+ extraParameters["scope"] = scopes.joined(separator: " ")
+ }
+ if let audience = arguments["audience"] as? String {
+ extraParameters["audience"] = audience
+ }
+
+ let finalRequest = extraParameters.isEmpty
+ ? request
+ : request.parameters(extraParameters)
+
+ finalRequest.start {
+ switch $0 {
+ case let .success(credentials):
+ callback(self.result(from: credentials))
+ case let .failure(error):
+ callback(FlutterError(from: error))
+ }
+ }
+ }
+}
diff --git a/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift b/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift
index 74b9b37a2..31be285f8 100644
--- a/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift
+++ b/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift
@@ -9,7 +9,8 @@ public class SwiftAuth0FlutterPlugin: NSObject, FlutterPlugin {
AuthAPIHandler.self,
DPoPHandler.self,
CredentialsManagerHandler.self,
- MyAccountHandler.self]
+ MyAccountHandler.self,
+ MfaHandler.self]
public static func register(with registrar: FlutterPluginRegistrar) {
handlers.forEach { $0.register(with: registrar) }
diff --git a/auth0_flutter/darwin/auth0_flutter.podspec b/auth0_flutter/darwin/auth0_flutter.podspec
index 2212784b7..3ae9153ee 100644
--- a/auth0_flutter/darwin/auth0_flutter.podspec
+++ b/auth0_flutter/darwin/auth0_flutter.podspec
@@ -19,7 +19,7 @@ Pod::Spec.new do |s|
s.osx.deployment_target = '11.0'
s.osx.dependency 'FlutterMacOS'
- s.dependency 'Auth0', '2.21.2'
+ s.dependency 'Auth0', '2.22.0'
s.dependency 'JWTDecode', '3.3.0'
s.dependency 'SimpleKeychain', '1.3.0'
diff --git a/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj
index 5793356ec..4d5668d97 100644
--- a/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj
+++ b/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj
@@ -63,6 +63,14 @@
B0CA0002000000000000000D /* MyAccountUpdateAuthMethodMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CA0001000000000000000D /* MyAccountUpdateAuthMethodMethodHandlerTests.swift */; };
B0CA0002000000000000000E /* MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CA0001000000000000000E /* MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift */; };
B0CA0002000000000000000F /* MyAccountEnrollPasskeyMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CA0001000000000000000F /* MyAccountEnrollPasskeyMethodHandlerTests.swift */; };
+ B0CB00020000000000000001 /* MfaSpies.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CB00010000000000000001 /* MfaSpies.swift */; };
+ B0CB00020000000000000002 /* MfaGetAuthenticatorsMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CB00010000000000000002 /* MfaGetAuthenticatorsMethodHandlerTests.swift */; };
+ B0CB00020000000000000003 /* MfaEnrollTotpMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CB00010000000000000003 /* MfaEnrollTotpMethodHandlerTests.swift */; };
+ B0CB00020000000000000004 /* MfaEnrollPhoneMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CB00010000000000000004 /* MfaEnrollPhoneMethodHandlerTests.swift */; };
+ B0CB00020000000000000005 /* MfaEnrollEmailMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CB00010000000000000005 /* MfaEnrollEmailMethodHandlerTests.swift */; };
+ B0CB00020000000000000006 /* MfaEnrollPushMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CB00010000000000000006 /* MfaEnrollPushMethodHandlerTests.swift */; };
+ B0CB00020000000000000007 /* MfaChallengeMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CB00010000000000000007 /* MfaChallengeMethodHandlerTests.swift */; };
+ B0CB00020000000000000008 /* MfaVerifyMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CB00010000000000000008 /* MfaVerifyMethodHandlerTests.swift */; };
PK00000000000000000001 /* AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PK00000000000000000003 /* AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift */; };
PK00000000000000000005 /* AuthAPIPasskeyExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PK00000000000000000006 /* AuthAPIPasskeyExtensionsTests.swift */; };
PK10000000000000000001 /* AuthAPIPasskeySignupChallengeMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PK10000000000000000003 /* AuthAPIPasskeySignupChallengeMethodHandlerTests.swift */; };
@@ -175,6 +183,14 @@
B0CA0001000000000000000D /* MyAccountUpdateAuthMethodMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAccountUpdateAuthMethodMethodHandlerTests.swift; sourceTree = ""; };
B0CA0001000000000000000E /* MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift; sourceTree = ""; };
B0CA0001000000000000000F /* MyAccountEnrollPasskeyMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAccountEnrollPasskeyMethodHandlerTests.swift; sourceTree = ""; };
+ B0CB00010000000000000001 /* MfaSpies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MfaSpies.swift; sourceTree = ""; };
+ B0CB00010000000000000002 /* MfaGetAuthenticatorsMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MfaGetAuthenticatorsMethodHandlerTests.swift; sourceTree = ""; };
+ B0CB00010000000000000003 /* MfaEnrollTotpMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MfaEnrollTotpMethodHandlerTests.swift; sourceTree = ""; };
+ B0CB00010000000000000004 /* MfaEnrollPhoneMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MfaEnrollPhoneMethodHandlerTests.swift; sourceTree = ""; };
+ B0CB00010000000000000005 /* MfaEnrollEmailMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MfaEnrollEmailMethodHandlerTests.swift; sourceTree = ""; };
+ B0CB00010000000000000006 /* MfaEnrollPushMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MfaEnrollPushMethodHandlerTests.swift; sourceTree = ""; };
+ B0CB00010000000000000007 /* MfaChallengeMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MfaChallengeMethodHandlerTests.swift; sourceTree = ""; };
+ B0CB00010000000000000008 /* MfaVerifyMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MfaVerifyMethodHandlerTests.swift; sourceTree = ""; };
PK00000000000000000003 /* AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift; sourceTree = ""; };
PK00000000000000000006 /* AuthAPIPasskeyExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthAPIPasskeyExtensionsTests.swift; sourceTree = ""; };
PK10000000000000000003 /* AuthAPIPasskeySignupChallengeMethodHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthAPIPasskeySignupChallengeMethodHandlerTests.swift; sourceTree = ""; };
@@ -226,6 +242,7 @@
5C328B5127F7B11300451E70 /* WebAuth */,
5C4E65BC286D022200141449 /* CredentialsManager */,
B0CA000300000000000000A1 /* MyAccount */,
+ B0CB000300000000000000A1 /* Mfa */,
5C335E3E27FBCD1D00EDDE3A /* SwiftAuth0FlutterPluginTests.swift */,
5CAAA4A1281A0C7D007666F1 /* ModelsTests.swift */,
5C335E4027FBD2FE00EDDE3A /* ExtensionsTests.swift */,
@@ -381,6 +398,21 @@
path = MyAccount;
sourceTree = "";
};
+ B0CB000300000000000000A1 /* Mfa */ = {
+ isa = PBXGroup;
+ children = (
+ B0CB00010000000000000001 /* MfaSpies.swift */,
+ B0CB00010000000000000002 /* MfaGetAuthenticatorsMethodHandlerTests.swift */,
+ B0CB00010000000000000003 /* MfaEnrollTotpMethodHandlerTests.swift */,
+ B0CB00010000000000000004 /* MfaEnrollPhoneMethodHandlerTests.swift */,
+ B0CB00010000000000000005 /* MfaEnrollEmailMethodHandlerTests.swift */,
+ B0CB00010000000000000006 /* MfaEnrollPushMethodHandlerTests.swift */,
+ B0CB00010000000000000007 /* MfaChallengeMethodHandlerTests.swift */,
+ B0CB00010000000000000008 /* MfaVerifyMethodHandlerTests.swift */,
+ );
+ path = Mfa;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -705,6 +737,14 @@
B0CA0002000000000000000D /* MyAccountUpdateAuthMethodMethodHandlerTests.swift in Sources */,
B0CA0002000000000000000E /* MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift in Sources */,
B0CA0002000000000000000F /* MyAccountEnrollPasskeyMethodHandlerTests.swift in Sources */,
+ B0CB00020000000000000001 /* MfaSpies.swift in Sources */,
+ B0CB00020000000000000002 /* MfaGetAuthenticatorsMethodHandlerTests.swift in Sources */,
+ B0CB00020000000000000003 /* MfaEnrollTotpMethodHandlerTests.swift in Sources */,
+ B0CB00020000000000000004 /* MfaEnrollPhoneMethodHandlerTests.swift in Sources */,
+ B0CB00020000000000000005 /* MfaEnrollEmailMethodHandlerTests.swift in Sources */,
+ B0CB00020000000000000006 /* MfaEnrollPushMethodHandlerTests.swift in Sources */,
+ B0CB00020000000000000007 /* MfaChallengeMethodHandlerTests.swift in Sources */,
+ B0CB00020000000000000008 /* MfaVerifyMethodHandlerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/auth0_flutter/example/ios/Tests/Mfa/MfaChallengeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/Mfa/MfaChallengeMethodHandlerTests.swift
new file mode 100644
index 000000000..95b3e5e62
--- /dev/null
+++ b/auth0_flutter/example/ios/Tests/Mfa/MfaChallengeMethodHandlerTests.swift
@@ -0,0 +1,66 @@
+import XCTest
+import Auth0
+
+@testable import auth0_flutter
+
+class MfaChallengeMethodHandlerTests: XCTestCase {
+ var spy: SpyMFAClient!
+ var sut: MfaChallengeMethodHandler!
+
+ override func setUpWithError() throws {
+ spy = SpyMFAClient()
+ sut = MfaChallengeMethodHandler(client: spy)
+ }
+
+ func testProducesErrorWhenMfaTokenMissing() {
+ let expectation = self.expectation(description: "Missing mfaToken")
+ sut.handle(with: ["authenticatorId": "sms|dev_1"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesErrorWhenAuthenticatorIdMissing() {
+ let expectation = self.expectation(description: "Missing authenticatorId")
+ sut.handle(with: ["mfaToken": "mfa-token"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testPassesAuthenticatorIdToSDK() {
+ let expectation = self.expectation(description: "Authenticator id passed to SDK")
+ sut.handle(with: ["mfaToken": "mfa-token", "authenticatorId": "sms|dev_1"]) { _ in
+ XCTAssertTrue(self.spy.calledChallenge)
+ XCTAssertEqual(self.spy.challengeAuthenticatorIdArg, "sms|dev_1")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesChallengeOnSuccess() {
+ let expectation = self.expectation(description: "Produced challenge")
+ sut.handle(with: ["mfaToken": "mfa-token", "authenticatorId": "sms|dev_1"]) { result in
+ guard let dict = result as? [String: Any?] else {
+ return XCTFail("Did not produce dictionary")
+ }
+ XCTAssertEqual(dict["challenge_type"] as? String, "oob")
+ XCTAssertEqual(dict["oob_code"] as? String, "oob-code")
+ XCTAssertEqual(dict["binding_method"] as? String, "prompt")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesFlutterErrorOnFailure() {
+ let expectation = self.expectation(description: "Produced FlutterError")
+ spy.challengeResult = .failure(MfaChallengeError(info: [:], statusCode: 400))
+ sut.handle(with: ["mfaToken": "mfa-token", "authenticatorId": "sms|dev_1"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+}
diff --git a/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollEmailMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollEmailMethodHandlerTests.swift
new file mode 100644
index 000000000..5fcc8667c
--- /dev/null
+++ b/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollEmailMethodHandlerTests.swift
@@ -0,0 +1,66 @@
+import XCTest
+import Auth0
+
+@testable import auth0_flutter
+
+class MfaEnrollEmailMethodHandlerTests: XCTestCase {
+ var spy: SpyMFAClient!
+ var sut: MfaEnrollEmailMethodHandler!
+
+ override func setUpWithError() throws {
+ spy = SpyMFAClient()
+ sut = MfaEnrollEmailMethodHandler(client: spy)
+ }
+
+ func testProducesErrorWhenMfaTokenMissing() {
+ let expectation = self.expectation(description: "Missing mfaToken")
+ sut.handle(with: ["email": "user@example.com"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesErrorWhenEmailMissing() {
+ let expectation = self.expectation(description: "Missing email")
+ sut.handle(with: ["mfaToken": "mfa-token"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testPassesEmailToSDK() {
+ let expectation = self.expectation(description: "Email passed to SDK")
+ sut.handle(with: ["mfaToken": "mfa-token", "email": "user@example.com"]) { _ in
+ XCTAssertTrue(self.spy.calledEnrollEmail)
+ XCTAssertEqual(self.spy.enrollEmailArg, "user@example.com")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesChallengeOnSuccess() {
+ let expectation = self.expectation(description: "Produced challenge")
+ sut.handle(with: ["mfaToken": "mfa-token", "email": "user@example.com"]) { result in
+ guard let dict = result as? [String: Any?] else {
+ return XCTFail("Did not produce dictionary")
+ }
+ XCTAssertEqual(dict["authenticator_type"] as? String, "oob")
+ XCTAssertEqual(dict["oob_channel"] as? String, "email")
+ XCTAssertEqual(dict["oob_code"] as? String, "oob-code")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesFlutterErrorOnFailure() {
+ let expectation = self.expectation(description: "Produced FlutterError")
+ spy.enrollEmailResult = .failure(MfaEnrollmentError(info: [:], statusCode: 400))
+ sut.handle(with: ["mfaToken": "mfa-token", "email": "user@example.com"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+}
diff --git a/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollPhoneMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollPhoneMethodHandlerTests.swift
new file mode 100644
index 000000000..45bcae76c
--- /dev/null
+++ b/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollPhoneMethodHandlerTests.swift
@@ -0,0 +1,66 @@
+import XCTest
+import Auth0
+
+@testable import auth0_flutter
+
+class MfaEnrollPhoneMethodHandlerTests: XCTestCase {
+ var spy: SpyMFAClient!
+ var sut: MfaEnrollPhoneMethodHandler!
+
+ override func setUpWithError() throws {
+ spy = SpyMFAClient()
+ sut = MfaEnrollPhoneMethodHandler(client: spy)
+ }
+
+ func testProducesErrorWhenMfaTokenMissing() {
+ let expectation = self.expectation(description: "Missing mfaToken")
+ sut.handle(with: ["phoneNumber": "+1234567890"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesErrorWhenPhoneNumberMissing() {
+ let expectation = self.expectation(description: "Missing phoneNumber")
+ sut.handle(with: ["mfaToken": "mfa-token"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testPassesPhoneNumberToSDK() {
+ let expectation = self.expectation(description: "Phone passed to SDK")
+ sut.handle(with: ["mfaToken": "mfa-token", "phoneNumber": "+1234567890"]) { _ in
+ XCTAssertTrue(self.spy.calledEnrollPhone)
+ XCTAssertEqual(self.spy.enrollPhoneNumberArg, "+1234567890")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesChallengeOnSuccess() {
+ let expectation = self.expectation(description: "Produced challenge")
+ sut.handle(with: ["mfaToken": "mfa-token", "phoneNumber": "+1234567890"]) { result in
+ guard let dict = result as? [String: Any?] else {
+ return XCTFail("Did not produce dictionary")
+ }
+ XCTAssertEqual(dict["authenticator_type"] as? String, "oob")
+ XCTAssertEqual(dict["oob_channel"] as? String, "sms")
+ XCTAssertEqual(dict["oob_code"] as? String, "oob-code")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesFlutterErrorOnFailure() {
+ let expectation = self.expectation(description: "Produced FlutterError")
+ spy.enrollPhoneResult = .failure(MfaEnrollmentError(info: [:], statusCode: 400))
+ sut.handle(with: ["mfaToken": "mfa-token", "phoneNumber": "+1234567890"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+}
diff --git a/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollPushMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollPushMethodHandlerTests.swift
new file mode 100644
index 000000000..b2ec62094
--- /dev/null
+++ b/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollPushMethodHandlerTests.swift
@@ -0,0 +1,57 @@
+import XCTest
+import Auth0
+
+@testable import auth0_flutter
+
+class MfaEnrollPushMethodHandlerTests: XCTestCase {
+ var spy: SpyMFAClient!
+ var sut: MfaEnrollPushMethodHandler!
+
+ override func setUpWithError() throws {
+ spy = SpyMFAClient()
+ sut = MfaEnrollPushMethodHandler(client: spy)
+ }
+
+ func testProducesErrorWhenMfaTokenMissing() {
+ let expectation = self.expectation(description: "Missing mfaToken")
+ sut.handle(with: [:]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testCallsEnrollPush() {
+ let expectation = self.expectation(description: "Called enroll Push")
+ sut.handle(with: ["mfaToken": "mfa-token"]) { _ in
+ XCTAssertTrue(self.spy.calledEnrollPush)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesChallengeWithBarcodeOnSuccess() {
+ let expectation = self.expectation(description: "Produced push challenge")
+ sut.handle(with: ["mfaToken": "mfa-token"]) { result in
+ guard let dict = result as? [String: Any?] else {
+ return XCTFail("Did not produce dictionary")
+ }
+ XCTAssertEqual(dict["authenticator_type"] as? String, "oob")
+ XCTAssertEqual(dict["oob_channel"] as? String, "auth0")
+ XCTAssertEqual(dict["oob_code"] as? String, "oob-code")
+ XCTAssertEqual(dict["barcode_uri"] as? String, "otpauth://push/test")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesFlutterErrorOnFailure() {
+ let expectation = self.expectation(description: "Produced FlutterError")
+ spy.enrollPushResult = .failure(MfaEnrollmentError(info: [:], statusCode: 400))
+ sut.handle(with: ["mfaToken": "mfa-token"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+}
diff --git a/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollTotpMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollTotpMethodHandlerTests.swift
new file mode 100644
index 000000000..34ee8d9ad
--- /dev/null
+++ b/auth0_flutter/example/ios/Tests/Mfa/MfaEnrollTotpMethodHandlerTests.swift
@@ -0,0 +1,57 @@
+import XCTest
+import Auth0
+
+@testable import auth0_flutter
+
+class MfaEnrollTotpMethodHandlerTests: XCTestCase {
+ var spy: SpyMFAClient!
+ var sut: MfaEnrollTotpMethodHandler!
+
+ override func setUpWithError() throws {
+ spy = SpyMFAClient()
+ sut = MfaEnrollTotpMethodHandler(client: spy)
+ }
+
+ func testProducesErrorWhenMfaTokenMissing() {
+ let expectation = self.expectation(description: "Missing mfaToken")
+ sut.handle(with: [:]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testCallsEnrollTotp() {
+ let expectation = self.expectation(description: "Called enroll TOTP")
+ sut.handle(with: ["mfaToken": "mfa-token"]) { _ in
+ XCTAssertTrue(self.spy.calledEnrollTotp)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesChallengeWithSecretOnSuccess() {
+ let expectation = self.expectation(description: "Produced TOTP challenge")
+ sut.handle(with: ["mfaToken": "mfa-token"]) { result in
+ guard let dict = result as? [String: Any?] else {
+ return XCTFail("Did not produce dictionary")
+ }
+ XCTAssertEqual(dict["authenticator_type"] as? String, "otp")
+ XCTAssertEqual(dict["totp_secret"] as? String, "SECRET")
+ XCTAssertEqual(dict["barcode_uri"] as? String, "otpauth://totp/test")
+ XCTAssertEqual(dict["recovery_codes"] as? [String], ["r1"])
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesFlutterErrorOnFailure() {
+ let expectation = self.expectation(description: "Produced FlutterError")
+ spy.enrollTotpResult = .failure(MfaEnrollmentError(info: [:], statusCode: 400))
+ sut.handle(with: ["mfaToken": "mfa-token"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+}
diff --git a/auth0_flutter/example/ios/Tests/Mfa/MfaGetAuthenticatorsMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/Mfa/MfaGetAuthenticatorsMethodHandlerTests.swift
new file mode 100644
index 000000000..cc8a17bac
--- /dev/null
+++ b/auth0_flutter/example/ios/Tests/Mfa/MfaGetAuthenticatorsMethodHandlerTests.swift
@@ -0,0 +1,71 @@
+import XCTest
+@testable import Auth0
+
+@testable import auth0_flutter
+
+class MfaGetAuthenticatorsMethodHandlerTests: XCTestCase {
+ var spy: SpyMFAClient!
+ var sut: MfaGetAuthenticatorsMethodHandler!
+
+ override func setUpWithError() throws {
+ spy = SpyMFAClient()
+ sut = MfaGetAuthenticatorsMethodHandler(client: spy)
+ }
+
+ func testProducesErrorWhenMfaTokenMissing() {
+ let expectation = self.expectation(description: "Missing mfaToken")
+ sut.handle(with: [:]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testPassesMfaTokenAndFactorsToSDK() {
+ let expectation = self.expectation(description: "Args passed to SDK")
+ sut.handle(with: ["mfaToken": "mfa-token", "factorsAllowed": ["otp", "oob"]]) { _ in
+ XCTAssertTrue(self.spy.calledGetAuthenticators)
+ XCTAssertEqual(self.spy.getAuthenticatorsMfaTokenArg, "mfa-token")
+ XCTAssertEqual(self.spy.getAuthenticatorsFactorsArg, ["otp", "oob"])
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testDefaultsToEmptyFactorsWhenMissing() {
+ let expectation = self.expectation(description: "Empty factors default")
+ sut.handle(with: ["mfaToken": "mfa-token"]) { _ in
+ XCTAssertEqual(self.spy.getAuthenticatorsFactorsArg, [])
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesAuthenticatorListOnSuccess() {
+ let expectation = self.expectation(description: "Produced list")
+ spy.getAuthenticatorsResult = .success([
+ Authenticator(authenticatorType: "oob", oobChannel: "sms", id: "sms|dev_1",
+ name: "+1******90", active: true, type: "sms")
+ ])
+ sut.handle(with: ["mfaToken": "mfa-token"]) { result in
+ guard let list = result as? [[String: Any?]] else {
+ return XCTFail("Did not produce a list")
+ }
+ XCTAssertEqual(list.count, 1)
+ XCTAssertEqual(list.first?["id"] as? String, "sms|dev_1")
+ XCTAssertEqual(list.first?["oob_channel"] as? String, "sms")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesFlutterErrorOnFailure() {
+ let expectation = self.expectation(description: "Produced FlutterError")
+ spy.getAuthenticatorsResult = .failure(MfaListAuthenticatorsError(info: [:], statusCode: 401))
+ sut.handle(with: ["mfaToken": "mfa-token"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+}
diff --git a/auth0_flutter/example/ios/Tests/Mfa/MfaSpies.swift b/auth0_flutter/example/ios/Tests/Mfa/MfaSpies.swift
new file mode 100644
index 000000000..88116140b
--- /dev/null
+++ b/auth0_flutter/example/ios/Tests/Mfa/MfaSpies.swift
@@ -0,0 +1,145 @@
+@testable import Auth0
+
+#if os(iOS)
+import Flutter
+#else
+import FlutterMacOS
+#endif
+
+// MARK: - Spy MFAClient
+
+class SpyMFAClient: MFAClient {
+ var dpop: DPoP?
+ var telemetry = Telemetry()
+ var logger: Logger?
+
+ // MARK: Stubbed results
+
+ var getAuthenticatorsResult: Result<[Authenticator], MfaListAuthenticatorsError> =
+ .success([])
+ var enrollPhoneResult: Result = .success(
+ MFAEnrollmentChallenge(authenticatorType: "oob", bindingMethod: "prompt",
+ recoveryCodes: nil, oobChannel: "sms", oobCode: "oob-code")
+ )
+ var enrollEmailResult: Result = .success(
+ MFAEnrollmentChallenge(authenticatorType: "oob", bindingMethod: "prompt",
+ recoveryCodes: nil, oobChannel: "email", oobCode: "oob-code")
+ )
+ var enrollTotpResult: Result = .success(
+ OTPMFAEnrollmentChallenge(authenticatorType: "otp", secret: "SECRET",
+ barcodeUri: "otpauth://totp/test", recoveryCodes: ["r1"])
+ )
+ var enrollPushResult: Result = .success(
+ PushMFAEnrollmentChallenge(authenticatorType: "oob", oobChannel: "auth0",
+ oobCode: "oob-code", barcodeUri: "otpauth://push/test",
+ recoveryCodes: nil)
+ )
+ var challengeResult: Result = .success(
+ MFAChallenge(challengeType: "oob", oobCode: "oob-code", bindingMethod: "prompt")
+ )
+ var verifyResult: Result = .success(
+ Credentials(accessToken: "access-token", tokenType: "Bearer",
+ idToken: testIdToken, refreshToken: "refresh-token",
+ expiresIn: Date(timeIntervalSinceNow: 3600), scope: "openid")
+ )
+
+ // MARK: Spied args
+
+ var calledGetAuthenticators = false
+ var getAuthenticatorsMfaTokenArg: String?
+ var getAuthenticatorsFactorsArg: [String]?
+ var calledEnrollPhone = false
+ var enrollPhoneNumberArg: String?
+ var calledEnrollEmail = false
+ var enrollEmailArg: String?
+ var calledEnrollTotp = false
+ var calledEnrollPush = false
+ var calledChallenge = false
+ var challengeAuthenticatorIdArg: String?
+ var calledVerify = false
+ var verifyOtpArg: String?
+ var verifyOobCodeArg: String?
+ var verifyBindingCodeArg: String?
+ var verifyRecoveryCodeArg: String?
+
+ func getAuthenticators(mfaToken: String,
+ factorsAllowed: [String]) -> Request<[Authenticator], MfaListAuthenticatorsError> {
+ calledGetAuthenticators = true
+ getAuthenticatorsMfaTokenArg = mfaToken
+ getAuthenticatorsFactorsArg = factorsAllowed
+ return request(getAuthenticatorsResult)
+ }
+
+ func enroll(mfaToken: String, phoneNumber: String) -> Request {
+ calledEnrollPhone = true
+ enrollPhoneNumberArg = phoneNumber
+ return request(enrollPhoneResult)
+ }
+
+ func enroll(mfaToken: String, email: String) -> Request {
+ calledEnrollEmail = true
+ enrollEmailArg = email
+ return request(enrollEmailResult)
+ }
+
+ func enroll(mfaToken: String) -> Request {
+ calledEnrollTotp = true
+ return request(enrollTotpResult)
+ }
+
+ func enroll(mfaToken: String) -> Request {
+ calledEnrollPush = true
+ return request(enrollPushResult)
+ }
+
+ func challenge(with authenticatorId: String,
+ mfaToken: String) -> Request {
+ calledChallenge = true
+ challengeAuthenticatorIdArg = authenticatorId
+ return request(challengeResult)
+ }
+
+ func verify(oobCode: String, bindingCode: String?,
+ mfaToken: String) -> Request {
+ calledVerify = true
+ verifyOobCodeArg = oobCode
+ verifyBindingCodeArg = bindingCode
+ return request(verifyResult)
+ }
+
+ func verify(otp: String, mfaToken: String) -> Request {
+ calledVerify = true
+ verifyOtpArg = otp
+ return request(verifyResult)
+ }
+
+ func verify(recoveryCode: String, mfaToken: String) -> Request {
+ calledVerify = true
+ verifyRecoveryCodeArg = recoveryCode
+ return request(verifyResult)
+ }
+}
+
+// MARK: - Request Helper
+
+private extension SpyMFAClient {
+ func request(_ result: Result) -> Request {
+ Request(session: mockURLSession, url: mockURL, method: "",
+ handle: { _, callback in callback(result) }, logger: nil, telemetry: telemetry)
+ }
+
+ func request(_ result: Result) -> Request {
+ Request(session: mockURLSession, url: mockURL, method: "",
+ handle: { _, callback in callback(result) }, logger: nil, telemetry: telemetry)
+ }
+
+ func request(_ result: Result) -> Request {
+ Request(session: mockURLSession, url: mockURL, method: "",
+ handle: { _, callback in callback(result) }, logger: nil, telemetry: telemetry)
+ }
+
+ func request(_ result: Result) -> Request {
+ Request(session: mockURLSession, url: mockURL, method: "",
+ handle: { _, callback in callback(result) }, logger: nil, telemetry: telemetry)
+ }
+}
diff --git a/auth0_flutter/example/ios/Tests/Mfa/MfaVerifyMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/Mfa/MfaVerifyMethodHandlerTests.swift
new file mode 100644
index 000000000..f152994f4
--- /dev/null
+++ b/auth0_flutter/example/ios/Tests/Mfa/MfaVerifyMethodHandlerTests.swift
@@ -0,0 +1,129 @@
+import XCTest
+import Auth0
+
+@testable import auth0_flutter
+
+class MfaVerifyMethodHandlerTests: XCTestCase {
+ var spy: SpyMFAClient!
+ var sut: MfaVerifyMethodHandler!
+
+ override func setUpWithError() throws {
+ spy = SpyMFAClient()
+ sut = MfaVerifyMethodHandler(client: spy)
+ }
+
+ func testProducesErrorWhenMfaTokenMissing() {
+ let expectation = self.expectation(description: "Missing mfaToken")
+ sut.handle(with: ["grantType": "otp", "otp": "123456"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesErrorWhenGrantTypeMissing() {
+ let expectation = self.expectation(description: "Missing grantType")
+ sut.handle(with: ["mfaToken": "mfa-token"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesErrorWhenOtpMissing() {
+ let expectation = self.expectation(description: "Missing otp")
+ sut.handle(with: ["mfaToken": "mfa-token", "grantType": "otp"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesErrorWhenOobCodeMissing() {
+ let expectation = self.expectation(description: "Missing oobCode")
+ sut.handle(with: ["mfaToken": "mfa-token", "grantType": "oob"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesErrorWhenRecoveryCodeMissing() {
+ let expectation = self.expectation(description: "Missing recoveryCode")
+ sut.handle(with: ["mfaToken": "mfa-token", "grantType": "recovery_code"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesErrorOnUnknownGrantType() {
+ let expectation = self.expectation(description: "Unknown grantType")
+ sut.handle(with: ["mfaToken": "mfa-token", "grantType": "magic"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testVerifiesWithOtp() {
+ let expectation = self.expectation(description: "Verified with OTP")
+ sut.handle(with: ["mfaToken": "mfa-token", "grantType": "otp", "otp": "123456"]) { _ in
+ XCTAssertTrue(self.spy.calledVerify)
+ XCTAssertEqual(self.spy.verifyOtpArg, "123456")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testVerifiesWithOobAndBindingCode() {
+ let expectation = self.expectation(description: "Verified with OOB")
+ sut.handle(with: [
+ "mfaToken": "mfa-token",
+ "grantType": "oob",
+ "oobCode": "oob-code",
+ "bindingCode": "000111"
+ ]) { _ in
+ XCTAssertEqual(self.spy.verifyOobCodeArg, "oob-code")
+ XCTAssertEqual(self.spy.verifyBindingCodeArg, "000111")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testVerifiesWithRecoveryCode() {
+ let expectation = self.expectation(description: "Verified with recovery code")
+ sut.handle(with: [
+ "mfaToken": "mfa-token",
+ "grantType": "recovery_code",
+ "recoveryCode": "ABCD-EFGH"
+ ]) { _ in
+ XCTAssertEqual(self.spy.verifyRecoveryCodeArg, "ABCD-EFGH")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesCredentialsOnSuccess() {
+ let expectation = self.expectation(description: "Produced credentials")
+ sut.handle(with: ["mfaToken": "mfa-token", "grantType": "otp", "otp": "123456"]) { result in
+ guard let dict = result as? [String: Any] else {
+ return XCTFail("Did not produce dictionary")
+ }
+ XCTAssertEqual(dict[CredentialsProperty.accessToken.rawValue] as? String, "access-token")
+ XCTAssertEqual(dict[CredentialsProperty.refreshToken.rawValue] as? String, "refresh-token")
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+
+ func testProducesFlutterErrorOnFailure() {
+ let expectation = self.expectation(description: "Produced FlutterError")
+ spy.verifyResult = .failure(MFAVerifyError(info: [:], statusCode: 403))
+ sut.handle(with: ["mfaToken": "mfa-token", "grantType": "otp", "otp": "123456"]) { result in
+ XCTAssertTrue(result is FlutterError)
+ expectation.fulfill()
+ }
+ wait(for: [expectation])
+ }
+}
diff --git a/auth0_flutter/example/web/index.html b/auth0_flutter/example/web/index.html
index abcd7a7aa..11a3bb650 100644
--- a/auth0_flutter/example/web/index.html
+++ b/auth0_flutter/example/web/index.html
@@ -39,12 +39,13 @@
-
+
+