From 20ef2475e5d01373bdd86941babfc33338c0ec3e Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 19 Jun 2026 18:16:56 +0530 Subject: [PATCH 01/11] feat: Add Multi-Factor Authentication (MFA) support for mobile --- auth0_flutter/EXAMPLES.md | 131 +++++++++ auth0_flutter/android/build.gradle | 2 +- .../Auth0FlutterMfaMethodCallHandler.kt | 30 ++ .../auth0/auth0_flutter/Auth0FlutterPlugin.kt | 14 + .../com/auth0/auth0_flutter/MfaExtensions.kt | 72 +++++ .../mfa/ChallengeRequestHandler.kt | 41 +++ .../mfa/EnrollEmailRequestHandler.kt | 42 +++ .../mfa/EnrollPhoneRequestHandler.kt | 42 +++ .../mfa/EnrollPushRequestHandler.kt | 38 +++ .../mfa/EnrollTotpRequestHandler.kt | 38 +++ .../mfa/GetAuthenticatorsRequestHandler.kt | 39 +++ .../request_handlers/mfa/MfaRequestHandler.kt | 10 + .../mfa/VerifyRequestHandler.kt | 60 ++++ .../Auth0FlutterMfaMethodCallHandlerTest.kt | 73 +++++ .../mfa/ChallengeRequestHandlerTest.kt | 85 ++++++ .../mfa/EnrollEmailRequestHandlerTest.kt | 55 ++++ .../mfa/EnrollPhoneRequestHandlerTest.kt | 86 ++++++ .../mfa/EnrollPushRequestHandlerTest.kt | 37 +++ .../mfa/EnrollTotpRequestHandlerTest.kt | 90 ++++++ .../GetAuthenticatorsRequestHandlerTest.kt | 95 ++++++ .../mfa/VerifyRequestHandlerTest.kt | 152 ++++++++++ .../Classes/SwiftAuth0FlutterPlugin.swift | 3 +- auth0_flutter/darwin/auth0_flutter.podspec | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 40 +++ .../Mfa/MfaChallengeMethodHandlerTests.swift | 66 +++++ .../MfaEnrollEmailMethodHandlerTests.swift | 66 +++++ .../MfaEnrollPhoneMethodHandlerTests.swift | 66 +++++ .../Mfa/MfaEnrollPushMethodHandlerTests.swift | 57 ++++ .../Mfa/MfaEnrollTotpMethodHandlerTests.swift | 57 ++++ ...aGetAuthenticatorsMethodHandlerTests.swift | 71 +++++ .../example/ios/Tests/Mfa/MfaSpies.swift | 145 +++++++++ .../Mfa/MfaVerifyMethodHandlerTests.swift | 129 ++++++++ .../MfaAPI/MfaChallengeMethodHandler.swift | 31 ++ .../MfaAPI/MfaEnrollEmailMethodHandler.swift | 31 ++ .../MfaAPI/MfaEnrollPhoneMethodHandler.swift | 31 ++ .../MfaAPI/MfaEnrollPushMethodHandler.swift | 28 ++ .../MfaAPI/MfaEnrollTotpMethodHandler.swift | 28 ++ .../ios/Classes/MfaAPI/MfaExtensions.swift | 110 +++++++ .../MfaGetAuthenticatorsMethodHandler.swift | 30 ++ .../ios/Classes/MfaAPI/MfaHandler.swift | 79 +++++ .../MfaAPI/MfaVerifyMethodHandler.swift | 52 ++++ auth0_flutter/ios/auth0_flutter.podspec | 2 +- auth0_flutter/lib/auth0_flutter.dart | 29 ++ auth0_flutter/lib/src/mobile/mfa_api.dart | 215 ++++++++++++++ auth0_flutter/macos/auth0_flutter.podspec | 2 +- auth0_flutter/test/mobile/mfa_api_test.dart | 184 ++++++++++++ .../test/mobile/mfa_api_test.mocks.dart | 151 ++++++++++ .../lib/auth0_flutter_platform_interface.dart | 14 + .../lib/src/auth/api_exception.dart | 10 + .../src/mfa/auth0_flutter_mfa_platform.dart | 62 ++++ .../mfa/method_channel_auth0_flutter_mfa.dart | 132 +++++++++ .../lib/src/mfa/mfa_authenticator.dart | 56 ++++ .../lib/src/mfa/mfa_challenge.dart | 34 +++ .../lib/src/mfa/mfa_challenge_options.dart | 22 ++ .../lib/src/mfa/mfa_enroll_email_options.dart | 21 ++ .../lib/src/mfa/mfa_enroll_phone_options.dart | 27 ++ .../lib/src/mfa/mfa_enroll_push_options.dart | 14 + .../lib/src/mfa/mfa_enroll_totp_options.dart | 14 + .../lib/src/mfa/mfa_enrollment_challenge.dart | 89 ++++++ .../lib/src/mfa/mfa_exception.dart | 53 ++++ .../mfa/mfa_get_authenticators_options.dart | 23 ++ .../lib/src/mfa/mfa_requirements.dart | 48 +++ .../lib/src/mfa/mfa_verify_options.dart | 69 +++++ .../test/api_exception_test.dart | 59 ++++ ...method_channel_auth0_flutter_mfa_test.dart | 278 ++++++++++++++++++ 65 files changed, 3957 insertions(+), 5 deletions(-) create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterMfaMethodCallHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MfaExtensions.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/ChallengeRequestHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollEmailRequestHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPhoneRequestHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPushRequestHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollTotpRequestHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/GetAuthenticatorsRequestHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/MfaRequestHandler.kt create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/VerifyRequestHandler.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/Auth0FlutterMfaMethodCallHandlerTest.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/ChallengeRequestHandlerTest.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollEmailRequestHandlerTest.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPhoneRequestHandlerTest.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollPushRequestHandlerTest.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollTotpRequestHandlerTest.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/GetAuthenticatorsRequestHandlerTest.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/VerifyRequestHandlerTest.kt create mode 100644 auth0_flutter/example/ios/Tests/Mfa/MfaChallengeMethodHandlerTests.swift create mode 100644 auth0_flutter/example/ios/Tests/Mfa/MfaEnrollEmailMethodHandlerTests.swift create mode 100644 auth0_flutter/example/ios/Tests/Mfa/MfaEnrollPhoneMethodHandlerTests.swift create mode 100644 auth0_flutter/example/ios/Tests/Mfa/MfaEnrollPushMethodHandlerTests.swift create mode 100644 auth0_flutter/example/ios/Tests/Mfa/MfaEnrollTotpMethodHandlerTests.swift create mode 100644 auth0_flutter/example/ios/Tests/Mfa/MfaGetAuthenticatorsMethodHandlerTests.swift create mode 100644 auth0_flutter/example/ios/Tests/Mfa/MfaSpies.swift create mode 100644 auth0_flutter/example/ios/Tests/Mfa/MfaVerifyMethodHandlerTests.swift create mode 100644 auth0_flutter/ios/Classes/MfaAPI/MfaChallengeMethodHandler.swift create mode 100644 auth0_flutter/ios/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift create mode 100644 auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift create mode 100644 auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift create mode 100644 auth0_flutter/ios/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift create mode 100644 auth0_flutter/ios/Classes/MfaAPI/MfaExtensions.swift create mode 100644 auth0_flutter/ios/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift create mode 100644 auth0_flutter/ios/Classes/MfaAPI/MfaHandler.swift create mode 100644 auth0_flutter/ios/Classes/MfaAPI/MfaVerifyMethodHandler.swift create mode 100644 auth0_flutter/lib/src/mobile/mfa_api.dart create mode 100644 auth0_flutter/test/mobile/mfa_api_test.dart create mode 100644 auth0_flutter/test/mobile/mfa_api_test.mocks.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/auth0_flutter_mfa_platform.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/method_channel_auth0_flutter_mfa.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_authenticator.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_challenge.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_challenge_options.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_email_options.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_phone_options.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_push_options.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_totp_options.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_enrollment_challenge.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_exception.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_get_authenticators_options.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_requirements.dart create mode 100644 auth0_flutter_platform_interface/lib/src/mfa/mfa_verify_options.dart create mode 100644 auth0_flutter_platform_interface/test/method_channel_auth0_flutter_mfa_test.dart diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index 91101aec5..244577b46 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,128 @@ try { --- [Go up โคด](#examples) + +## ๐Ÿ“ฑ Multi-Factor Authentication (MFA) + +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) only**; Web and Windows are 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`. + +### Obtaining an `mfa_token` + +When an authentication request (for example a database login or a credentials renewal) requires a second factor, the SDK throws an `ApiException` whose `isMultifactorRequired` flag is `true` and which carries an `mfaToken`. Pass that token to `auth0.mfa(...)` to start the flow. + +```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). + } +} +``` + +### 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 +final authenticators = await mfa.getAuthenticators(); + +// Optionally narrow the results to specific factor types. +final oobOnly = await mfa.getAuthenticators(factorsAllowed: ['oob']); + +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 (SMS by default, or Voice): an OOB code is sent to the number. +final phone = await mfa.enrollPhone( + phoneNumber: '+1234567890', + type: PhoneType.sms, // or PhoneType.voice +); + +// 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: + +```dart +// TOTP โ€” the code from the authenticator app. +final credentials = await mfa.verifyOtp(otp: '123456'); + +// 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', +); + +// Recovery code โ€” a one-time code the user saved during enrollment. +final credentials = await mfa.verifyRecoveryCode(recoveryCode: 'ABCD1234...'); + +// On success, persist the credentials as usual. +await auth0.credentialsManager.storeCredentials(credentials); +``` + +### Errors + +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}'); + } +} +``` + +--- + +[Go up โคด](#examples) diff --git a/auth0_flutter/android/build.gradle b/auth0_flutter/android/build.gradle index cec326c79..28613185a 100644 --- a/auth0_flutter/android/build.gradle +++ b/auth0_flutter/android/build.gradle @@ -73,7 +73,7 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation 'com.auth0.android:auth0:3.18.0' + implementation 'com.auth0.android:auth0:3.19.0' implementation 'com.google.code.gson:gson:2.10.1' testImplementation 'junit:junit:4.13.2' testImplementation 'org.hamcrest:java-hamcrest:2.0.0.0' 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..930d89c65 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MfaExtensions.kt @@ -0,0 +1,72 @@ +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 { + put("_statusCode", statusCode) + put( + "_errorFlags", mapOf( + "isNetworkError" to false + ) + ) +} 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..47a87cc4c --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/VerifyRequestHandler.kt @@ -0,0 +1,60 @@ +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") + } + + client.verify(type) + .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/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..ddc6d5d2b --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/VerifyRequestHandlerTest.kt @@ -0,0 +1,152 @@ +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.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 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 = mock() + whenever(user.toMap()).thenReturn(mapOf("sub" to "user-id")) + 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/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..4533949e8 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/Mfa/MfaGetAuthenticatorsMethodHandlerTests.swift @@ -0,0 +1,71 @@ +import XCTest +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(id: "sms|dev_1", authenticatorType: "oob", active: true, + oobChannel: "sms", name: "+1******90") + ]) + 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/ios/Classes/MfaAPI/MfaChallengeMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaChallengeMethodHandler.swift new file mode 100644 index 000000000..cc2a7e573 --- /dev/null +++ b/auth0_flutter/ios/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/ios/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift new file mode 100644 index 000000000..8971516df --- /dev/null +++ b/auth0_flutter/ios/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/ios/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift new file mode 100644 index 000000000..5f2fcc67e --- /dev/null +++ b/auth0_flutter/ios/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/ios/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift new file mode 100644 index 000000000..99a89f013 --- /dev/null +++ b/auth0_flutter/ios/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/ios/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift new file mode 100644 index 000000000..b3a0c434b --- /dev/null +++ b/auth0_flutter/ios/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/ios/Classes/MfaAPI/MfaExtensions.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaExtensions.swift new file mode 100644 index 000000000..f4cf71f31 --- /dev/null +++ b/auth0_flutter/ios/Classes/MfaAPI/MfaExtensions.swift @@ -0,0 +1,110 @@ +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] = [ + "_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/ios/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift new file mode 100644 index 000000000..8a95e357c --- /dev/null +++ b/auth0_flutter/ios/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/ios/Classes/MfaAPI/MfaHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaHandler.swift new file mode 100644 index 000000000..bb9f66a33 --- /dev/null +++ b/auth0_flutter/ios/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/ios/Classes/MfaAPI/MfaVerifyMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaVerifyMethodHandler.swift new file mode 100644 index 000000000..79c5481f0 --- /dev/null +++ b/auth0_flutter/ios/Classes/MfaAPI/MfaVerifyMethodHandler.swift @@ -0,0 +1,52 @@ +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"))) + } + + request.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/ios/auth0_flutter.podspec b/auth0_flutter/ios/auth0_flutter.podspec index 2212784b7..3ae9153ee 100644 --- a/auth0_flutter/ios/auth0_flutter.podspec +++ b/auth0_flutter/ios/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/lib/auth0_flutter.dart b/auth0_flutter/lib/auth0_flutter.dart index 2e2cc5a3a..29e2964ce 100644 --- a/auth0_flutter/lib/auth0_flutter.dart +++ b/auth0_flutter/lib/auth0_flutter.dart @@ -3,6 +3,7 @@ import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interfac import 'src/desktop/windows_web_authentication.dart'; import 'src/mobile/authentication_api.dart'; import 'src/mobile/credentials_manager.dart'; +import 'src/mobile/mfa_api.dart'; import 'src/mobile/my_account_api.dart'; import 'src/mobile/web_authentication.dart'; import 'src/version.dart'; @@ -21,6 +22,12 @@ export 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interfac IdTokenValidationConfig, LocalAuthentication, LocalAuthenticationLevel, + MfaAuthenticator, + MfaChallenge, + MfaEnrollmentChallenge, + MfaException, + MfaFactor, + MfaRequirements, MyAccountException, PasswordlessType, PasskeyAuthenticatorResponse, @@ -40,6 +47,7 @@ export 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interfac export 'src/desktop/windows_web_authentication.dart'; export 'src/mobile/authentication_api.dart'; export 'src/mobile/credentials_manager.dart'; +export 'src/mobile/mfa_api.dart'; export 'src/mobile/my_account_api.dart'; export 'src/mobile/web_authentication.dart'; @@ -169,6 +177,27 @@ class Auth0 { }) => MyAccountApi(_account, _userAgent, accessToken, useDPoP: useDPoP); + /// Creates an instance of [MfaApi] for completing a Multi-Factor + /// Authentication flow with the given [mfaToken]. + /// + /// Obtain the [mfaToken] from an [ApiException] raised when a token request + /// fails with an `mfa_required` error (see [ApiException.mfaToken] and + /// [ApiException.mfaRequirements]). Use the returned [MfaApi] to list, + /// enroll, challenge and verify authenticators. + /// + /// **Note:** Only supported on mobile platforms (Android/iOS). Web and + /// Windows are not supported. + /// + /// Usage example: + /// + /// ```dart + /// final auth0 = Auth0('DOMAIN', 'CLIENT_ID'); + /// final mfa = auth0.mfa(mfaToken: 'MFA_TOKEN'); + /// final authenticators = await mfa.getAuthenticators(); + /// ``` + MfaApi mfa({required final String mfaToken}) => + MfaApi(_account, _userAgent, mfaToken); + /// Generates DPoP (Demonstrating Proof-of-Possession) headers for making /// authenticated API calls with DPoP-bound tokens. /// diff --git a/auth0_flutter/lib/src/mobile/mfa_api.dart b/auth0_flutter/lib/src/mobile/mfa_api.dart new file mode 100644 index 000000000..28f3b6f0c --- /dev/null +++ b/auth0_flutter/lib/src/mobile/mfa_api.dart @@ -0,0 +1,215 @@ +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; + +/// An interface for completing Multi-Factor Authentication (MFA) flows using +/// an `mfa_token`, as part of Auth0's +/// [flexible/expanded grant support](https://auth0.com/docs/secure/multi-factor-authentication). +/// +/// When a token request fails because MFA is required, the resulting +/// [ApiException] exposes [ApiException.mfaToken] (and +/// [ApiException.mfaRequirements]). Pass that token to `Auth0.mfa(mfaToken:)` +/// to obtain an instance of this class and drive the challenge or enrollment +/// flow. +/// +/// It is not intended for you to instantiate this class yourself; an instance +/// is available via `Auth0.mfa(mfaToken:)`. +/// +/// **Note:** This API is only supported on mobile platforms (Android/iOS). +/// Web and Windows are not supported. +/// +/// ## Usage example +/// +/// ```dart +/// final auth0 = Auth0('DOMAIN', 'CLIENT_ID'); +/// +/// try { +/// await auth0.api.renewCredentials(refreshToken: refreshToken); +/// } on ApiException catch (e) { +/// if (e.isMultifactorRequired && e.mfaToken != null) { +/// final mfa = auth0.mfa(mfaToken: e.mfaToken!); +/// +/// final authenticators = await mfa.getAuthenticators(); +/// final challenge = await mfa.challenge( +/// authenticatorId: authenticators.first.id, +/// ); +/// +/// // For OTP factors, collect the code from the user, then: +/// final credentials = await mfa.verifyOtp(otp: '123456'); +/// } +/// } +/// ``` +class MfaApi { + final Account _account; + final UserAgent _userAgent; + final String _mfaToken; + + MfaApi(this._account, this._userAgent, this._mfaToken); + + /// Lists the authenticators that can be used with the current `mfa_token`. + /// + /// The results are filtered to the factors allowed by the `mfa_token`'s + /// `mfa_requirements`. Optionally narrow the results further by passing + /// [factorsAllowed] (e.g. `['otp', 'oob']`). + /// + /// ## Usage example + /// + /// ```dart + /// final authenticators = await mfa.getAuthenticators(); + /// ``` + Future> getAuthenticators( + {final List factorsAllowed = const []}) => + Auth0FlutterMfaPlatform.instance.getAuthenticators(_createApiRequest( + MfaGetAuthenticatorsOptions( + mfaToken: _mfaToken, factorsAllowed: factorsAllowed))); + + /// Enrolls a new TOTP (authenticator app) factor. + /// + /// Returns an [MfaEnrollmentChallenge] whose `barcodeUri` and `totpSecret` + /// you present to the user (typically as a QR code). After the user adds the + /// account in their authenticator app, complete enrollment with + /// [verifyOtp]. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await mfa.enrollTotp(); + /// // Render challenge.barcodeUri as a QR code, then: + /// final credentials = await mfa.verifyOtp(otp: '123456'); + /// ``` + Future enrollTotp() => + Auth0FlutterMfaPlatform.instance.enrollTotp( + _createApiRequest(MfaEnrollTotpOptions(mfaToken: _mfaToken))); + + /// Enrolls a new phone (SMS or Voice) factor for the given [phoneNumber]. + /// + /// Returns an [MfaEnrollmentChallenge] containing the `oobCode` used to + /// complete verification via [verifyOob] once the user receives the code. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await mfa.enrollPhone(phoneNumber: '+1234567890'); + /// final credentials = await mfa.verifyOob( + /// oobCode: challenge.oobCode!, + /// bindingCode: '123456', + /// ); + /// ``` + Future enrollPhone({ + required final String phoneNumber, + final PhoneType type = PhoneType.sms, + }) => + Auth0FlutterMfaPlatform.instance.enrollPhone(_createApiRequest( + MfaEnrollPhoneOptions( + mfaToken: _mfaToken, phoneNumber: phoneNumber, type: type))); + + /// Enrolls a new email factor for the given [email] address. + /// + /// Returns an [MfaEnrollmentChallenge] containing the `oobCode` used to + /// complete verification via [verifyOob] once the user receives the code. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await mfa.enrollEmail(email: 'user@example.com'); + /// ``` + Future enrollEmail({required final String email}) => + Auth0FlutterMfaPlatform.instance.enrollEmail(_createApiRequest( + MfaEnrollEmailOptions(mfaToken: _mfaToken, email: email))); + + /// Enrolls a new push notification factor (Auth0 Guardian). + /// + /// Returns an [MfaEnrollmentChallenge] whose `barcodeUri` you present to the + /// user to scan with the Guardian app. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await mfa.enrollPush(); + /// ``` + Future enrollPush() => + Auth0FlutterMfaPlatform.instance.enrollPush( + _createApiRequest(MfaEnrollPushOptions(mfaToken: _mfaToken))); + + /// Initiates a challenge for the authenticator identified by + /// [authenticatorId] (obtained from [getAuthenticators]). + /// + /// For out-of-band factors (SMS, Email, Push) this triggers delivery of the + /// code and returns an [MfaChallenge] whose `oobCode` you pass to + /// [verifyOob]. For TOTP, verify directly with [verifyOtp]. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await mfa.challenge( + /// authenticatorId: authenticators.first.id, + /// ); + /// ``` + Future challenge({required final String authenticatorId}) => + Auth0FlutterMfaPlatform.instance.challenge(_createApiRequest( + MfaChallengeOptions( + mfaToken: _mfaToken, authenticatorId: authenticatorId))); + + /// Verifies a one-time password ([otp]) for a TOTP authenticator and + /// exchanges the `mfa_token` for [Credentials]. + /// + /// ## Usage example + /// + /// ```dart + /// final credentials = await mfa.verifyOtp(otp: '123456'); + /// ``` + Future verifyOtp({required final String otp}) => + Auth0FlutterMfaPlatform.instance.verify(_createApiRequest( + MfaVerifyOptions( + mfaToken: _mfaToken, + grantType: MfaVerifyGrantType.otp, + otp: otp))); + + /// Verifies an out-of-band challenge (SMS, Email, Push) and exchanges the + /// `mfa_token` for [Credentials]. + /// + /// [oobCode] comes from the [MfaChallenge] returned by [challenge] (or the + /// [MfaEnrollmentChallenge] returned by an enrollment). Provide + /// [bindingCode] when the challenge's `bindingMethod` is `prompt` (i.e. the + /// user must enter a code received via the channel). + /// + /// ## Usage example + /// + /// ```dart + /// final credentials = await mfa.verifyOob( + /// oobCode: challenge.oobCode!, + /// bindingCode: '123456', + /// ); + /// ``` + Future verifyOob({ + required final String oobCode, + final String? bindingCode, + }) => + Auth0FlutterMfaPlatform.instance.verify(_createApiRequest( + MfaVerifyOptions( + mfaToken: _mfaToken, + grantType: MfaVerifyGrantType.oob, + oobCode: oobCode, + bindingCode: bindingCode))); + + /// Verifies a [recoveryCode] and exchanges the `mfa_token` for + /// [Credentials]. + /// + /// ## Usage example + /// + /// ```dart + /// final credentials = await mfa.verifyRecoveryCode( + /// recoveryCode: 'ABCD1234...', + /// ); + /// ``` + Future verifyRecoveryCode( + {required final String recoveryCode}) => + Auth0FlutterMfaPlatform.instance.verify(_createApiRequest( + MfaVerifyOptions( + mfaToken: _mfaToken, + grantType: MfaVerifyGrantType.recoveryCode, + recoveryCode: recoveryCode))); + + ApiRequest _createApiRequest( + final TOptions options) => + ApiRequest( + account: _account, options: options, userAgent: _userAgent); +} diff --git a/auth0_flutter/macos/auth0_flutter.podspec b/auth0_flutter/macos/auth0_flutter.podspec index 2212784b7..3ae9153ee 100644 --- a/auth0_flutter/macos/auth0_flutter.podspec +++ b/auth0_flutter/macos/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/test/mobile/mfa_api_test.dart b/auth0_flutter/test/mobile/mfa_api_test.dart new file mode 100644 index 000000000..94bcb7c49 --- /dev/null +++ b/auth0_flutter/test/mobile/mfa_api_test.dart @@ -0,0 +1,184 @@ +import 'package:auth0_flutter/auth0_flutter.dart'; +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'mfa_api_test.mocks.dart'; + +class TestPlatform extends Mock + with + // ignore: prefer_mixin + MockPlatformInterfaceMixin + implements + Auth0FlutterMfaPlatform { + static const MfaAuthenticator authenticator = MfaAuthenticator( + id: 'sms|dev_1', + type: 'phone', + authenticatorType: 'oob', + active: true, + oobChannel: 'sms', + name: '****4761', + ); + + static const MfaChallenge challengeResult = MfaChallenge( + challengeType: 'oob', + oobCode: 'oob-code', + bindingMethod: 'prompt', + ); + + static const MfaEnrollmentChallenge enrollmentChallenge = + MfaEnrollmentChallenge( + authenticatorType: 'otp', + totpSecret: 'SECRET', + barcodeUri: 'otpauth://totp/...', + ); + + static Credentials credentials = Credentials.fromMap({ + 'accessToken': 'accessToken', + 'idToken': 'idToken', + 'refreshToken': 'refreshToken', + 'expiresAt': DateTime.now().toIso8601String(), + 'scopes': ['openid'], + 'userProfile': {'sub': '123'}, + 'tokenType': 'Bearer' + }); +} + +@GenerateMocks([TestPlatform]) +void main() { + final mockedPlatform = MockTestPlatform(); + + setUp(() { + Auth0FlutterMfaPlatform.instance = mockedPlatform; + }); + + MfaApi mfa() => + Auth0('test-domain', 'test-clientId').mfa(mfaToken: 'mfa-token'); + + group('getAuthenticators', () { + test('forwards mfaToken and factors to the platform', () async { + when(mockedPlatform.getAuthenticators(any)) + .thenAnswer((final _) async => [TestPlatform.authenticator]); + + final result = await mfa().getAuthenticators(factorsAllowed: ['oob']); + + final captured = verify(mockedPlatform.getAuthenticators(captureAny)) + .captured + .single as ApiRequest; + expect(captured.account.domain, 'test-domain'); + expect(captured.options.mfaToken, 'mfa-token'); + expect(captured.options.factorsAllowed, ['oob']); + expect(result.single.id, 'sms|dev_1'); + }); + }); + + group('enroll', () { + test('enrollTotp delegates to the platform', () async { + when(mockedPlatform.enrollTotp(any)) + .thenAnswer((final _) async => TestPlatform.enrollmentChallenge); + + final result = await mfa().enrollTotp(); + + final captured = verify(mockedPlatform.enrollTotp(captureAny)) + .captured + .single as ApiRequest; + expect(captured.options.mfaToken, 'mfa-token'); + expect(result.totpSecret, 'SECRET'); + }); + + test('enrollPhone forwards phoneNumber and type', () async { + when(mockedPlatform.enrollPhone(any)) + .thenAnswer((final _) async => TestPlatform.enrollmentChallenge); + + await mfa() + .enrollPhone(phoneNumber: '+1234567890', type: PhoneType.voice); + + final captured = verify(mockedPlatform.enrollPhone(captureAny)) + .captured + .single as ApiRequest; + expect(captured.options.phoneNumber, '+1234567890'); + expect(captured.options.type, PhoneType.voice); + }); + + test('enrollEmail forwards the email', () async { + when(mockedPlatform.enrollEmail(any)) + .thenAnswer((final _) async => TestPlatform.enrollmentChallenge); + + await mfa().enrollEmail(email: 'user@example.com'); + + final captured = verify(mockedPlatform.enrollEmail(captureAny)) + .captured + .single as ApiRequest; + expect(captured.options.email, 'user@example.com'); + }); + + test('enrollPush delegates to the platform', () async { + when(mockedPlatform.enrollPush(any)) + .thenAnswer((final _) async => TestPlatform.enrollmentChallenge); + + await mfa().enrollPush(); + + verify(mockedPlatform.enrollPush(any)).called(1); + }); + }); + + group('challenge', () { + test('forwards authenticatorId', () async { + when(mockedPlatform.challenge(any)) + .thenAnswer((final _) async => TestPlatform.challengeResult); + + final result = await mfa().challenge(authenticatorId: 'sms|dev_1'); + + final captured = verify(mockedPlatform.challenge(captureAny)) + .captured + .single as ApiRequest; + expect(captured.options.authenticatorId, 'sms|dev_1'); + expect(result.oobCode, 'oob-code'); + }); + }); + + group('verify', () { + test('verifyOtp uses the otp grant type', () async { + when(mockedPlatform.verify(any)) + .thenAnswer((final _) async => TestPlatform.credentials); + + final result = await mfa().verifyOtp(otp: '123456'); + + final captured = verify(mockedPlatform.verify(captureAny)) + .captured + .single as ApiRequest; + expect(captured.options.grantType, MfaVerifyGrantType.otp); + expect(captured.options.otp, '123456'); + expect(result.accessToken, 'accessToken'); + }); + + test('verifyOob uses the oob grant type with binding code', () async { + when(mockedPlatform.verify(any)) + .thenAnswer((final _) async => TestPlatform.credentials); + + await mfa().verifyOob(oobCode: 'oob-code', bindingCode: '000111'); + + final captured = verify(mockedPlatform.verify(captureAny)) + .captured + .single as ApiRequest; + expect(captured.options.grantType, MfaVerifyGrantType.oob); + expect(captured.options.oobCode, 'oob-code'); + expect(captured.options.bindingCode, '000111'); + }); + + test('verifyRecoveryCode uses the recovery_code grant type', () async { + when(mockedPlatform.verify(any)) + .thenAnswer((final _) async => TestPlatform.credentials); + + await mfa().verifyRecoveryCode(recoveryCode: 'ABCD'); + + final captured = verify(mockedPlatform.verify(captureAny)) + .captured + .single as ApiRequest; + expect(captured.options.grantType, MfaVerifyGrantType.recoveryCode); + expect(captured.options.recoveryCode, 'ABCD'); + }); + }); +} diff --git a/auth0_flutter/test/mobile/mfa_api_test.mocks.dart b/auth0_flutter/test/mobile/mfa_api_test.mocks.dart new file mode 100644 index 000000000..32dae23b6 --- /dev/null +++ b/auth0_flutter/test/mobile/mfa_api_test.mocks.dart @@ -0,0 +1,151 @@ +// Mocks generated by Mockito 5.4.5 from annotations +// in auth0_flutter/example/windows/flutter/ephemeral/.plugin_symlinks/auth0_flutter/test/mobile/mfa_api_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:auth0_flutter/auth0_flutter.dart' as _i2; +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart' + as _i5; +import 'package:mockito/mockito.dart' as _i1; + +import 'mfa_api_test.dart' as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeMfaEnrollmentChallenge_0 extends _i1.SmartFake + implements _i2.MfaEnrollmentChallenge { + _FakeMfaEnrollmentChallenge_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMfaChallenge_1 extends _i1.SmartFake implements _i2.MfaChallenge { + _FakeMfaChallenge_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeCredentials_2 extends _i1.SmartFake implements _i2.Credentials { + _FakeCredentials_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [TestPlatform]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTestPlatform extends _i1.Mock implements _i3.TestPlatform { + MockTestPlatform() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future> getAuthenticators( + _i5.ApiRequest<_i5.MfaGetAuthenticatorsOptions>? request, + ) => + (super.noSuchMethod( + Invocation.method(#getAuthenticators, [request]), + returnValue: _i4.Future>.value( + <_i2.MfaAuthenticator>[], + ), + ) + as _i4.Future>); + + @override + _i4.Future<_i2.MfaEnrollmentChallenge> enrollTotp( + _i5.ApiRequest<_i5.MfaEnrollTotpOptions>? request, + ) => + (super.noSuchMethod( + Invocation.method(#enrollTotp, [request]), + returnValue: _i4.Future<_i2.MfaEnrollmentChallenge>.value( + _FakeMfaEnrollmentChallenge_0( + this, + Invocation.method(#enrollTotp, [request]), + ), + ), + ) + as _i4.Future<_i2.MfaEnrollmentChallenge>); + + @override + _i4.Future<_i2.MfaEnrollmentChallenge> enrollPhone( + _i5.ApiRequest<_i5.MfaEnrollPhoneOptions>? request, + ) => + (super.noSuchMethod( + Invocation.method(#enrollPhone, [request]), + returnValue: _i4.Future<_i2.MfaEnrollmentChallenge>.value( + _FakeMfaEnrollmentChallenge_0( + this, + Invocation.method(#enrollPhone, [request]), + ), + ), + ) + as _i4.Future<_i2.MfaEnrollmentChallenge>); + + @override + _i4.Future<_i2.MfaEnrollmentChallenge> enrollEmail( + _i5.ApiRequest<_i5.MfaEnrollEmailOptions>? request, + ) => + (super.noSuchMethod( + Invocation.method(#enrollEmail, [request]), + returnValue: _i4.Future<_i2.MfaEnrollmentChallenge>.value( + _FakeMfaEnrollmentChallenge_0( + this, + Invocation.method(#enrollEmail, [request]), + ), + ), + ) + as _i4.Future<_i2.MfaEnrollmentChallenge>); + + @override + _i4.Future<_i2.MfaEnrollmentChallenge> enrollPush( + _i5.ApiRequest<_i5.MfaEnrollPushOptions>? request, + ) => + (super.noSuchMethod( + Invocation.method(#enrollPush, [request]), + returnValue: _i4.Future<_i2.MfaEnrollmentChallenge>.value( + _FakeMfaEnrollmentChallenge_0( + this, + Invocation.method(#enrollPush, [request]), + ), + ), + ) + as _i4.Future<_i2.MfaEnrollmentChallenge>); + + @override + _i4.Future<_i2.MfaChallenge> challenge( + _i5.ApiRequest<_i5.MfaChallengeOptions>? request, + ) => + (super.noSuchMethod( + Invocation.method(#challenge, [request]), + returnValue: _i4.Future<_i2.MfaChallenge>.value( + _FakeMfaChallenge_1( + this, + Invocation.method(#challenge, [request]), + ), + ), + ) + as _i4.Future<_i2.MfaChallenge>); + + @override + _i4.Future<_i2.Credentials> verify( + _i5.ApiRequest<_i5.MfaVerifyOptions>? request, + ) => + (super.noSuchMethod( + Invocation.method(#verify, [request]), + returnValue: _i4.Future<_i2.Credentials>.value( + _FakeCredentials_2(this, Invocation.method(#verify, [request])), + ), + ) + as _i4.Future<_i2.Credentials>); +} diff --git a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart index cd634c977..65b029fde 100644 --- a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart +++ b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart @@ -47,6 +47,20 @@ export 'src/login_options.dart'; export 'src/method_channel_auth0_flutter_auth.dart'; export 'src/method_channel_auth0_flutter_dpop.dart'; export 'src/method_channel_auth0_flutter_web_auth.dart'; +export 'src/mfa/auth0_flutter_mfa_platform.dart'; +export 'src/mfa/method_channel_auth0_flutter_mfa.dart'; +export 'src/mfa/mfa_authenticator.dart'; +export 'src/mfa/mfa_challenge.dart'; +export 'src/mfa/mfa_challenge_options.dart'; +export 'src/mfa/mfa_enroll_email_options.dart'; +export 'src/mfa/mfa_enroll_phone_options.dart'; +export 'src/mfa/mfa_enroll_push_options.dart'; +export 'src/mfa/mfa_enroll_totp_options.dart'; +export 'src/mfa/mfa_enrollment_challenge.dart'; +export 'src/mfa/mfa_exception.dart'; +export 'src/mfa/mfa_get_authenticators_options.dart'; +export 'src/mfa/mfa_requirements.dart'; +export 'src/mfa/mfa_verify_options.dart'; export 'src/myaccount/auth0_flutter_my_account_platform.dart'; export 'src/myaccount/authentication_method.dart'; export 'src/myaccount/authentication_method_type.dart'; diff --git a/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart b/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart index 02cd441d8..5e47a70ba 100644 --- a/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart +++ b/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import '../auth0_exception.dart'; import '../extensions/exception_extensions.dart'; import '../extensions/map_extensions.dart'; +import '../mfa/mfa_requirements.dart'; class ApiException extends Auth0Exception { static const _errorFlagsKey = '_errorFlags'; @@ -39,6 +40,15 @@ class ApiException extends Auth0Exception { details.containsKey('mfa_token') && details['mfa_token'] is String ? details['mfa_token'] as String : null; + + /// The MFA factors the user can challenge or enroll, parsed from the + /// `mfa_requirements` field of an `mfa_required` error. Returns `null` when + /// the error carries no requirements. + MfaRequirements? get mfaRequirements => + details['mfa_requirements'] is Map + ? MfaRequirements.fromMap( + Map.from(details['mfa_requirements'] as Map)) + : null; bool get isMultifactorEnrollRequired => _errorFlags.getBooleanOrFalse('isMultifactorEnrollRequired'); bool get isMultifactorCodeInvalid => diff --git a/auth0_flutter_platform_interface/lib/src/mfa/auth0_flutter_mfa_platform.dart b/auth0_flutter_platform_interface/lib/src/mfa/auth0_flutter_mfa_platform.dart new file mode 100644 index 000000000..fd581f59e --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/auth0_flutter_mfa_platform.dart @@ -0,0 +1,62 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import '../credentials.dart'; +import '../request/request.dart'; +import 'method_channel_auth0_flutter_mfa.dart'; +import 'mfa_authenticator.dart'; +import 'mfa_challenge.dart'; +import 'mfa_challenge_options.dart'; +import 'mfa_enroll_email_options.dart'; +import 'mfa_enroll_phone_options.dart'; +import 'mfa_enroll_push_options.dart'; +import 'mfa_enroll_totp_options.dart'; +import 'mfa_enrollment_challenge.dart'; +import 'mfa_get_authenticators_options.dart'; +import 'mfa_verify_options.dart'; + +abstract class Auth0FlutterMfaPlatform extends PlatformInterface { + Auth0FlutterMfaPlatform() : super(token: _token); + + static Auth0FlutterMfaPlatform get instance => _instance; + static final Object _token = Object(); + static Auth0FlutterMfaPlatform _instance = MethodChannelAuth0FlutterMfa(); + + static set instance(final Auth0FlutterMfaPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future> getAuthenticators( + final ApiRequest request) { + throw UnimplementedError('getAuthenticators() has not been implemented'); + } + + Future enrollTotp( + final ApiRequest request) { + throw UnimplementedError('enrollTotp() has not been implemented'); + } + + Future enrollPhone( + final ApiRequest request) { + throw UnimplementedError('enrollPhone() has not been implemented'); + } + + Future enrollEmail( + final ApiRequest request) { + throw UnimplementedError('enrollEmail() has not been implemented'); + } + + Future enrollPush( + final ApiRequest request) { + throw UnimplementedError('enrollPush() has not been implemented'); + } + + Future challenge( + final ApiRequest request) { + throw UnimplementedError('challenge() has not been implemented'); + } + + Future verify(final ApiRequest request) { + throw UnimplementedError('verify() has not been implemented'); + } +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/method_channel_auth0_flutter_mfa.dart b/auth0_flutter_platform_interface/lib/src/mfa/method_channel_auth0_flutter_mfa.dart new file mode 100644 index 000000000..1be699248 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/method_channel_auth0_flutter_mfa.dart @@ -0,0 +1,132 @@ +import 'package:flutter/services.dart'; + +import '../credentials.dart'; +import '../request/request.dart'; +import '../request/request_options.dart'; +import 'auth0_flutter_mfa_platform.dart'; +import 'mfa_authenticator.dart'; +import 'mfa_challenge.dart'; +import 'mfa_challenge_options.dart'; +import 'mfa_enroll_email_options.dart'; +import 'mfa_enroll_phone_options.dart'; +import 'mfa_enroll_push_options.dart'; +import 'mfa_enroll_totp_options.dart'; +import 'mfa_enrollment_challenge.dart'; +import 'mfa_exception.dart'; +import 'mfa_get_authenticators_options.dart'; +import 'mfa_verify_options.dart'; + +const MethodChannel _channel = MethodChannel('auth0.com/auth0_flutter/mfa'); + +const String mfaGetAuthenticatorsMethod = 'mfa#getAuthenticators'; +const String mfaEnrollTotpMethod = 'mfa#enrollTotp'; +const String mfaEnrollPhoneMethod = 'mfa#enrollPhone'; +const String mfaEnrollEmailMethod = 'mfa#enrollEmail'; +const String mfaEnrollPushMethod = 'mfa#enrollPush'; +const String mfaChallengeMethod = 'mfa#challenge'; +const String mfaVerifyMethod = 'mfa#verify'; + +class MethodChannelAuth0FlutterMfa extends Auth0FlutterMfaPlatform { + @override + Future> getAuthenticators( + final ApiRequest request) async { + final List result = await _invokeListRequest( + method: mfaGetAuthenticatorsMethod, request: request); + + return result + .map((final item) => + MfaAuthenticator.fromMap(Map.from(item as Map))) + .toList(); + } + + @override + Future enrollTotp( + final ApiRequest request) async { + final Map result = + await _invokeMapRequest(method: mfaEnrollTotpMethod, request: request); + + return MfaEnrollmentChallenge.fromMap(result); + } + + @override + Future enrollPhone( + final ApiRequest request) async { + final Map result = + await _invokeMapRequest(method: mfaEnrollPhoneMethod, request: request); + + return MfaEnrollmentChallenge.fromMap(result); + } + + @override + Future enrollEmail( + final ApiRequest request) async { + final Map result = + await _invokeMapRequest(method: mfaEnrollEmailMethod, request: request); + + return MfaEnrollmentChallenge.fromMap(result); + } + + @override + Future enrollPush( + final ApiRequest request) async { + final Map result = + await _invokeMapRequest(method: mfaEnrollPushMethod, request: request); + + return MfaEnrollmentChallenge.fromMap(result); + } + + @override + Future challenge( + final ApiRequest request) async { + final Map result = + await _invokeMapRequest(method: mfaChallengeMethod, request: request); + + return MfaChallenge.fromMap(result); + } + + @override + Future verify( + final ApiRequest request) async { + final Map result = + await _invokeMapRequest(method: mfaVerifyMethod, request: request); + + return Credentials.fromMap(result); + } + + Future> + _invokeMapRequest({ + required final String method, + required final ApiRequest request, + }) async { + final Map? result; + try { + result = await _channel.invokeMapMethod(method, request.toMap()); + } on PlatformException catch (e) { + throw MfaException.fromPlatformException(e); + } + + if (result == null) { + throw const MfaException.unknown('Channel returned null.'); + } + + return result; + } + + Future> _invokeListRequest({ + required final String method, + required final ApiRequest request, + }) async { + final List? result; + try { + result = await _channel.invokeListMethod(method, request.toMap()); + } on PlatformException catch (e) { + throw MfaException.fromPlatformException(e); + } + + if (result == null) { + throw const MfaException.unknown('Channel returned null.'); + } + + return result; + } +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_authenticator.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_authenticator.dart new file mode 100644 index 000000000..26ed83095 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_authenticator.dart @@ -0,0 +1,56 @@ +/// An MFA authenticator enrolled by the user, as returned by +/// `MfaApi.getAuthenticators`. +class MfaAuthenticator { + /// The identifier of the authenticator, in the form `{type}|{id}`, e.g. + /// `sms|dev_authenticator_id`. + final String id; + + /// The factor type used when filtering by `mfa_requirements`, e.g. `phone`, + /// `email`, `otp`, `push-notification`. + /// + /// Only present when the tenant has the `mfa_authenticators_enable_type` + /// flag enabled. + final String? type; + + /// The underlying authenticator type, e.g. `oob`, `otp`, `recovery-code`. + final String? authenticatorType; + + /// Whether the authenticator is active (fully enrolled). + final bool active; + + /// The out-of-band channel for `oob` authenticators, e.g. `sms`, `email`, + /// `auth0` (Push). `null` for non-OOB authenticators. + final String? oobChannel; + + /// A human-readable name for the authenticator, e.g. a masked phone number + /// or email. + final String? name; + + const MfaAuthenticator({ + required this.id, + this.type, + this.authenticatorType, + this.active = false, + this.oobChannel, + this.name, + }); + + factory MfaAuthenticator.fromMap(final Map result) => + MfaAuthenticator( + id: result['id'] as String, + type: result['type'] as String?, + authenticatorType: result['authenticator_type'] as String?, + active: result['active'] as bool? ?? false, + oobChannel: result['oob_channel'] as String?, + name: result['name'] as String?, + ); + + Map toMap() => { + 'id': id, + 'type': type, + 'authenticator_type': authenticatorType, + 'active': active, + 'oob_channel': oobChannel, + 'name': name, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_challenge.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_challenge.dart new file mode 100644 index 000000000..f8baf644a --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_challenge.dart @@ -0,0 +1,34 @@ +/// The result of initiating an MFA challenge via `MfaApi.challenge`. +class MfaChallenge { + /// The type of challenge, e.g. `oob` or `otp`. + final String challengeType; + + /// The out-of-band code identifying this challenge session. Pass it to + /// `MfaApi.verifyOob` to complete the challenge. `null` for `otp` + /// challenges, which are verified directly with the user-entered code. + final String? oobCode; + + /// How the out-of-band code is bound, e.g. `prompt` (the user must enter a + /// binding code received via the channel) or `transfer`. `null` when not + /// applicable. + final String? bindingMethod; + + const MfaChallenge({ + required this.challengeType, + this.oobCode, + this.bindingMethod, + }); + + factory MfaChallenge.fromMap(final Map result) => + MfaChallenge( + challengeType: result['challenge_type'] as String, + oobCode: result['oob_code'] as String?, + bindingMethod: result['binding_method'] as String?, + ); + + Map toMap() => { + 'challenge_type': challengeType, + 'oob_code': oobCode, + 'binding_method': bindingMethod, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_challenge_options.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_challenge_options.dart new file mode 100644 index 000000000..7e31fe11d --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_challenge_options.dart @@ -0,0 +1,22 @@ +import '../request/request_options.dart'; + +/// Options for requesting an MFA challenge against an enrolled authenticator. +class MfaChallengeOptions implements RequestOptions { + /// The `mfa_token` obtained from a `mfa_required` authentication error. + final String mfaToken; + + /// The identifier of the enrolled authenticator to challenge, as returned + /// by `getAuthenticators`. + final String authenticatorId; + + MfaChallengeOptions({ + required this.mfaToken, + required this.authenticatorId, + }); + + @override + Map toMap() => { + 'mfaToken': mfaToken, + 'authenticatorId': authenticatorId, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_email_options.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_email_options.dart new file mode 100644 index 000000000..3cf7f04b8 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_email_options.dart @@ -0,0 +1,21 @@ +import '../request/request_options.dart'; + +/// Options for enrolling a new email-based out-of-band factor. +class MfaEnrollEmailOptions implements RequestOptions { + /// The `mfa_token` obtained from a `mfa_required` authentication error. + final String mfaToken; + + /// The email address to enroll and deliver the out-of-band code to. + final String email; + + MfaEnrollEmailOptions({ + required this.mfaToken, + required this.email, + }); + + @override + Map toMap() => { + 'mfaToken': mfaToken, + 'email': email, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_phone_options.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_phone_options.dart new file mode 100644 index 000000000..d38980934 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_phone_options.dart @@ -0,0 +1,27 @@ +import '../myaccount/phone_type.dart'; +import '../request/request_options.dart'; + +/// Options for enrolling a new phone-based out-of-band factor (SMS or voice). +class MfaEnrollPhoneOptions implements RequestOptions { + /// The `mfa_token` obtained from a `mfa_required` authentication error. + final String mfaToken; + + /// The phone number to enroll, in E.164 format (e.g. `+15551234567`). + final String phoneNumber; + + /// Whether the out-of-band code is delivered via SMS or voice call. + final PhoneType type; + + MfaEnrollPhoneOptions({ + required this.mfaToken, + required this.phoneNumber, + required this.type, + }); + + @override + Map toMap() => { + 'mfaToken': mfaToken, + 'phoneNumber': phoneNumber, + 'type': type.toValue(), + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_push_options.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_push_options.dart new file mode 100644 index 000000000..12c7be3c6 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_push_options.dart @@ -0,0 +1,14 @@ +import '../request/request_options.dart'; + +/// Options for enrolling a new push-notification factor (Guardian app). +class MfaEnrollPushOptions implements RequestOptions { + /// The `mfa_token` obtained from a `mfa_required` authentication error. + final String mfaToken; + + MfaEnrollPushOptions({required this.mfaToken}); + + @override + Map toMap() => { + 'mfaToken': mfaToken, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_totp_options.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_totp_options.dart new file mode 100644 index 000000000..17fb62a7c --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_enroll_totp_options.dart @@ -0,0 +1,14 @@ +import '../request/request_options.dart'; + +/// Options for enrolling a new TOTP (authenticator app) factor. +class MfaEnrollTotpOptions implements RequestOptions { + /// The `mfa_token` obtained from a `mfa_required` authentication error. + final String mfaToken; + + MfaEnrollTotpOptions({required this.mfaToken}); + + @override + Map toMap() => { + 'mfaToken': mfaToken, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_enrollment_challenge.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_enrollment_challenge.dart new file mode 100644 index 000000000..e967350e9 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_enrollment_challenge.dart @@ -0,0 +1,89 @@ +/// The result of initiating an MFA enrollment via one of the `MfaApi.enroll*` +/// methods. +/// +/// This is a flattened superset of the factor-specific enrollment challenges +/// returned by the underlying native SDKs. Which fields are populated depends +/// on the factor: +/// +/// - **TOTP** (`enrollTotp`): [totpSecret] and [barcodeUri] (render a QR code +/// from [barcodeUri]); plus [recoveryCodes] on first enrollment. +/// - **SMS/Voice & Email** (`enrollPhone`, `enrollEmail`): [oobCode], +/// [oobChannel] and [bindingMethod]. Confirm with `verifyOob`. +/// - **Push** (`enrollPush`): [oobCode], [oobChannel] and [barcodeUri] (scan +/// with the Guardian app). +class MfaEnrollmentChallenge { + /// The underlying authenticator type, e.g. `oob` or `otp`. + final String? authenticatorType; + + /// The out-of-band channel for OOB enrollments, e.g. `sms`, `email`, + /// `auth0` (Push). + /// + /// Note: this is surfaced on iOS but is not exposed by the Android native + /// SDK for phone/email enrollments, so it may be `null` on Android even for + /// OOB factors. + final String? oobChannel; + + /// The out-of-band code identifying this enrollment session, used to + /// complete verification. + final String? oobCode; + + /// How the out-of-band code is bound, e.g. `prompt` or `transfer`. + final String? bindingMethod; + + /// The shared secret for TOTP enrollment, for manual entry into an + /// authenticator app. + final String? totpSecret; + + /// A URI (`otpauth://` for TOTP, or a Guardian URI for Push) to render as a + /// QR code. + final String? barcodeUri; + + /// Recovery codes issued on first enrollment of a new authenticator type. + /// Store these securely and present them to the user. + final List? recoveryCodes; + + /// The enrollment identifier, when surfaced by the platform. + final String? id; + + /// The authentication session, when surfaced by the platform. + final String? authSession; + + const MfaEnrollmentChallenge({ + this.authenticatorType, + this.oobChannel, + this.oobCode, + this.bindingMethod, + this.totpSecret, + this.barcodeUri, + this.recoveryCodes, + this.id, + this.authSession, + }); + + factory MfaEnrollmentChallenge.fromMap(final Map result) => + MfaEnrollmentChallenge( + authenticatorType: result['authenticator_type'] as String?, + oobChannel: result['oob_channel'] as String?, + oobCode: result['oob_code'] as String?, + bindingMethod: result['binding_method'] as String?, + totpSecret: result['totp_secret'] as String?, + barcodeUri: result['barcode_uri'] as String?, + recoveryCodes: (result['recovery_codes'] as List?) + ?.map((final e) => e as String) + .toList(), + id: result['id'] as String?, + authSession: result['auth_session'] as String?, + ); + + Map toMap() => { + 'authenticator_type': authenticatorType, + 'oob_channel': oobChannel, + 'oob_code': oobCode, + 'binding_method': bindingMethod, + 'totp_secret': totpSecret, + 'barcode_uri': barcodeUri, + 'recovery_codes': recoveryCodes, + 'id': id, + 'auth_session': authSession, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_exception.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_exception.dart new file mode 100644 index 000000000..daa1aeb89 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_exception.dart @@ -0,0 +1,53 @@ +import 'package:flutter/services.dart'; + +import '../auth0_exception.dart'; +import '../extensions/exception_extensions.dart'; +import '../extensions/map_extensions.dart'; + +/// An error raised by an MFA operation (`getAuthenticators`, `enroll*`, +/// `challenge`, `verify`). +class MfaException extends Auth0Exception { + static const _statusCodeKey = '_statusCode'; + static const _errorFlagsKey = '_errorFlags'; + + final int statusCode; + final Map _errorFlags; + + const MfaException(final String code, final String message, + final Map details, this._errorFlags, this.statusCode) + : super(code, message, details); + + const MfaException.unknown(final String message) + : _errorFlags = const {}, + statusCode = 0, + super.unknown(message); // coverage:ignore-line + + factory MfaException.fromPlatformException(final PlatformException e) { + final Map errorDetails = e.detailsMap; + final statusCode = errorDetails.getOrDefault(_statusCodeKey, 0); + final errorFlags = + errorDetails.getOrDefault(_errorFlagsKey, {}); + + errorDetails.remove(_statusCodeKey); + errorDetails.remove(_errorFlagsKey); + + return MfaException( + e.code, e.messageString, errorDetails, errorFlags, statusCode); + } + + /// Whether the `mfa_token` has expired (default expiry is 10 minutes). The + /// user must restart the original authentication request to obtain a new + /// token. + bool get isMfaTokenExpired => code == 'expired_token'; + + /// Whether the request was malformed, e.g. an invalid challenge type or a + /// verification with an authenticator not allowed by the `mfa_token`. + bool get isInvalidRequest => code == 'invalid_request'; + + /// Whether the one-time / out-of-band code provided to `verify` was invalid. + bool get isInvalidCode => + code == 'invalid_grant' || code == 'invalid_otp_code'; + + bool get isNetworkError => _errorFlags.getBooleanOrFalse('isNetworkError'); + bool get isRetryable => isNetworkError; +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_get_authenticators_options.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_get_authenticators_options.dart new file mode 100644 index 000000000..50353966b --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_get_authenticators_options.dart @@ -0,0 +1,23 @@ +import '../request/request_options.dart'; + +/// Options for listing the MFA authenticators enrolled for the user +/// associated with an `mfa_token`. +class MfaGetAuthenticatorsOptions implements RequestOptions { + /// The `mfa_token` obtained from a `mfa_required` authentication error. + final String mfaToken; + + /// An optional allow-list of factor types to return. When empty, all + /// enrolled authenticators are returned. + final List factorsAllowed; + + MfaGetAuthenticatorsOptions({ + required this.mfaToken, + this.factorsAllowed = const [], + }); + + @override + Map toMap() => { + 'mfaToken': mfaToken, + 'factorsAllowed': factorsAllowed, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_requirements.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_requirements.dart new file mode 100644 index 000000000..10f3fdc0a --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_requirements.dart @@ -0,0 +1,48 @@ +/// A single MFA factor referenced in an `mfa_required` error's requirements, +/// e.g. `otp`, `phone`, `email`, `push-notification`, `recovery-code`. +class MfaFactor { + final String type; + + const MfaFactor({required this.type}); + + factory MfaFactor.fromMap(final Map result) => + MfaFactor(type: result['type'] as String); + + Map toMap() => {'type': type}; +} + +/// The factors a user can use or enroll, parsed from the `mfa_requirements` +/// field of an `mfa_required` error. +/// +/// Use [challenge] to decide which authenticators can be challenged, and +/// [enroll] to decide which factors can be newly enrolled. +class MfaRequirements { + /// Factors the user can be challenged with (already enrolled). + final List challenge; + + /// Factors the user can enroll (no active authenticator yet). + final List enroll; + + const MfaRequirements({ + this.challenge = const [], + this.enroll = const [], + }); + + factory MfaRequirements.fromMap(final Map result) => + MfaRequirements( + challenge: _parseFactors(result['challenge']), + enroll: _parseFactors(result['enroll']), + ); + + static List _parseFactors(final Object? value) => + (value as List?) + ?.map((final e) => + MfaFactor.fromMap(Map.from(e as Map))) + .toList() ?? + const []; + + Map toMap() => { + 'challenge': challenge.map((final f) => f.toMap()).toList(), + 'enroll': enroll.map((final f) => f.toMap()).toList(), + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/mfa/mfa_verify_options.dart b/auth0_flutter_platform_interface/lib/src/mfa/mfa_verify_options.dart new file mode 100644 index 000000000..ae23e9eb3 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/mfa/mfa_verify_options.dart @@ -0,0 +1,69 @@ +import '../request/request_options.dart'; + +/// The kind of credential being verified, which determines the MFA grant type +/// used in the token exchange. +enum MfaVerifyGrantType { + /// One-time password (TOTP authenticator app). Uses the `mfa-otp` grant. + otp, + + /// Out-of-band code (SMS, Email, Push). Uses the `mfa-oob` grant. + oob, + + /// Recovery code. Uses the `mfa-recovery-code` grant. + recoveryCode; + + String toValue() { + switch (this) { + case MfaVerifyGrantType.otp: + return 'otp'; + case MfaVerifyGrantType.oob: + return 'oob'; + case MfaVerifyGrantType.recoveryCode: + return 'recovery_code'; + } + } +} + +/// Options for verifying an MFA challenge and exchanging it for credentials. +class MfaVerifyOptions implements RequestOptions { + /// The `mfa_token` obtained from a `mfa_required` authentication error. + final String mfaToken; + + /// The grant type to use for the verification, which selects which of + /// [otp], [oobCode] or [recoveryCode] is required. + final MfaVerifyGrantType grantType; + + /// Required when [grantType] is [MfaVerifyGrantType.otp]. + final String? otp; + + /// Required when [grantType] is [MfaVerifyGrantType.oob]. Obtained from the + /// `oobCode` of the `MfaChallenge` returned by an MFA challenge. + final String? oobCode; + + /// Optional binding code entered by the user when the challenge's + /// `binding_method` is `prompt`. Only used when [grantType] is + /// [MfaVerifyGrantType.oob]. + final String? bindingCode; + + /// Required when [grantType] is [MfaVerifyGrantType.recoveryCode]. + final String? recoveryCode; + + MfaVerifyOptions({ + required this.mfaToken, + required this.grantType, + this.otp, + this.oobCode, + this.bindingCode, + this.recoveryCode, + }); + + @override + Map toMap() => { + 'mfaToken': mfaToken, + 'grantType': grantType.toValue(), + 'otp': otp, + 'oobCode': oobCode, + 'bindingCode': bindingCode, + 'recoveryCode': recoveryCode, + }; +} diff --git a/auth0_flutter_platform_interface/test/api_exception_test.dart b/auth0_flutter_platform_interface/test/api_exception_test.dart index 3cedc27f1..4b549596e 100644 --- a/auth0_flutter_platform_interface/test/api_exception_test.dart +++ b/auth0_flutter_platform_interface/test/api_exception_test.dart @@ -282,4 +282,63 @@ void main() { final exception = ApiException.fromPlatformException(platformException); expect(exception.isRetryable, false); }); + + group('mfaRequirements', () { + test('returns null when mfa_requirements is not present', () async { + const details = {}; + final platformException = + PlatformException(code: 'mfa_required', details: details); + + final exception = ApiException.fromPlatformException(platformException); + expect(exception.mfaRequirements, null); + }); + + test('returns null when mfa_requirements is not a map', () async { + final details = {'mfa_requirements': 'not-a-map'}; + final platformException = + PlatformException(code: 'mfa_required', details: details); + + final exception = ApiException.fromPlatformException(platformException); + expect(exception.mfaRequirements, null); + }); + + test('parses challenge and enroll factors', () async { + final details = { + 'mfa_requirements': { + 'challenge': [ + {'type': 'otp'}, + {'type': 'oob'} + ], + 'enroll': [ + {'type': 'push-notification'} + ] + } + }; + final platformException = + PlatformException(code: 'mfa_required', details: details); + + final exception = ApiException.fromPlatformException(platformException); + final requirements = exception.mfaRequirements; + + expect(requirements, isNotNull); + expect( + requirements!.challenge.map((final f) => f.type), ['otp', 'oob']); + expect( + requirements.enroll.map((final f) => f.type), ['push-notification']); + }); + + test('defaults to empty lists when challenge and enroll omitted', + () async { + final details = {'mfa_requirements': {}}; + final platformException = + PlatformException(code: 'mfa_required', details: details); + + final exception = ApiException.fromPlatformException(platformException); + final requirements = exception.mfaRequirements; + + expect(requirements, isNotNull); + expect(requirements!.challenge, isEmpty); + expect(requirements.enroll, isEmpty); + }); + }); } diff --git a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_mfa_test.dart b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_mfa_test.dart new file mode 100644 index 000000000..d7470b642 --- /dev/null +++ b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_mfa_test.dart @@ -0,0 +1,278 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const account = Account('test-domain', 'test-clientId'); + final userAgent = UserAgent(name: 'test-name', version: 'test-version'); + + ApiRequest request( + final TOptions options) => + ApiRequest( + account: account, options: options, userAgent: userAgent); + + group('options.toMap', () { + test('get authenticators serializes token and factors', () { + final map = MfaGetAuthenticatorsOptions( + mfaToken: 'mfa-token', factorsAllowed: ['otp', 'oob']) + .toMap(); + expect(map['mfaToken'], 'mfa-token'); + expect(map['factorsAllowed'], ['otp', 'oob']); + }); + + test('enroll phone serializes phoneNumber and type', () { + final map = MfaEnrollPhoneOptions( + mfaToken: 'mfa-token', + phoneNumber: '+1234567890', + type: PhoneType.voice, + ).toMap(); + expect(map['mfaToken'], 'mfa-token'); + expect(map['phoneNumber'], '+1234567890'); + expect(map['type'], 'voice'); + }); + + test('enroll email serializes email', () { + final map = + MfaEnrollEmailOptions(mfaToken: 'mfa-token', email: 'a@b.com') + .toMap(); + expect(map['email'], 'a@b.com'); + }); + + test('challenge serializes authenticatorId', () { + final map = MfaChallengeOptions( + mfaToken: 'mfa-token', authenticatorId: 'sms|abc') + .toMap(); + expect(map['authenticatorId'], 'sms|abc'); + }); + + test('verify serializes grant type and otp', () { + final map = MfaVerifyOptions( + mfaToken: 'mfa-token', + grantType: MfaVerifyGrantType.otp, + otp: '123456', + ).toMap(); + expect(map['grantType'], 'otp'); + expect(map['otp'], '123456'); + }); + + test('verify serializes oob grant with binding code', () { + final map = MfaVerifyOptions( + mfaToken: 'mfa-token', + grantType: MfaVerifyGrantType.oob, + oobCode: 'oob-code', + bindingCode: '000111', + ).toMap(); + expect(map['grantType'], 'oob'); + expect(map['oobCode'], 'oob-code'); + expect(map['bindingCode'], '000111'); + }); + + test('verify serializes recovery code grant', () { + final map = MfaVerifyOptions( + mfaToken: 'mfa-token', + grantType: MfaVerifyGrantType.recoveryCode, + recoveryCode: 'ABCD', + ).toMap(); + expect(map['grantType'], 'recovery_code'); + expect(map['recoveryCode'], 'ABCD'); + }); + }); + + group('MfaAuthenticator', () { + test('fromMap / toMap round-trip', () { + const map = { + 'id': 'sms|dev_1', + 'type': 'phone', + 'authenticator_type': 'oob', + 'active': true, + 'oob_channel': 'sms', + 'name': '****4761', + }; + final authenticator = + MfaAuthenticator.fromMap(Map.from(map)); + expect(authenticator.id, 'sms|dev_1'); + expect(authenticator.type, 'phone'); + expect(authenticator.authenticatorType, 'oob'); + expect(authenticator.active, true); + expect(authenticator.oobChannel, 'sms'); + expect(authenticator.name, '****4761'); + expect(authenticator.toMap(), map); + }); + }); + + group('MfaChallenge', () { + test('fromMap parses oob challenge', () { + final challenge = MfaChallenge.fromMap(const { + 'challenge_type': 'oob', + 'oob_code': 'code', + 'binding_method': 'prompt', + }); + expect(challenge.challengeType, 'oob'); + expect(challenge.oobCode, 'code'); + expect(challenge.bindingMethod, 'prompt'); + }); + }); + + group('MfaEnrollmentChallenge', () { + test('fromMap parses totp fields', () { + final challenge = MfaEnrollmentChallenge.fromMap(const { + 'authenticator_type': 'otp', + 'totp_secret': 'SECRET', + 'barcode_uri': 'otpauth://totp/...', + 'recovery_codes': ['r1', 'r2'], + }); + expect(challenge.authenticatorType, 'otp'); + expect(challenge.totpSecret, 'SECRET'); + expect(challenge.barcodeUri, 'otpauth://totp/...'); + expect(challenge.recoveryCodes, ['r1', 'r2']); + }); + + test('fromMap parses oob fields', () { + final challenge = MfaEnrollmentChallenge.fromMap(const { + 'authenticator_type': 'oob', + 'oob_channel': 'sms', + 'oob_code': 'oob-code', + 'binding_method': 'prompt', + }); + expect(challenge.oobChannel, 'sms'); + expect(challenge.oobCode, 'oob-code'); + expect(challenge.bindingMethod, 'prompt'); + }); + }); + + group('MfaRequirements', () { + test('parses challenge and enroll factors', () { + final requirements = MfaRequirements.fromMap(const { + 'challenge': [ + {'type': 'otp'}, + {'type': 'email'}, + ], + 'enroll': [ + {'type': 'push-notification'}, + ], + }); + expect(requirements.challenge.map((final f) => f.type), + containsAll(['otp', 'email'])); + expect(requirements.enroll.single.type, 'push-notification'); + }); + + test('defaults to empty lists when missing', () { + final requirements = MfaRequirements.fromMap(const {}); + expect(requirements.challenge, isEmpty); + expect(requirements.enroll, isEmpty); + }); + }); + + group('MethodChannelAuth0FlutterMfa', () { + const channel = MethodChannel('auth0.com/auth0_flutter/mfa'); + final platform = MethodChannelAuth0FlutterMfa(); + final log = []; + + setUp(log.clear); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + void mock(final Object? response) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (final call) async { + log.add(call); + return response; + }); + } + + test('getAuthenticators invokes the correct method and parses result', + () async { + mock([ + {'id': 'sms|1', 'authenticator_type': 'oob', 'active': true}, + ]); + + final result = await platform.getAuthenticators(request( + MfaGetAuthenticatorsOptions( + mfaToken: 'mfa-token', factorsAllowed: ['oob']))); + + expect(log.single.method, 'mfa#getAuthenticators'); + expect((log.single.arguments as Map)['factorsAllowed'], ['oob']); + expect(result.single.id, 'sms|1'); + }); + + test('enrollTotp invokes the correct method and parses result', () async { + mock({'authenticator_type': 'otp', 'totp_secret': 'SECRET'}); + + final result = await platform + .enrollTotp(request(MfaEnrollTotpOptions(mfaToken: 'mfa-token'))); + + expect(log.single.method, 'mfa#enrollTotp'); + expect(result.totpSecret, 'SECRET'); + }); + + test('enrollPhone invokes the correct method', () async { + mock({'authenticator_type': 'oob', 'oob_code': 'code'}); + + await platform.enrollPhone(request(MfaEnrollPhoneOptions( + mfaToken: 'mfa-token', + phoneNumber: '+123', + type: PhoneType.sms))); + + expect(log.single.method, 'mfa#enrollPhone'); + expect((log.single.arguments as Map)['phoneNumber'], '+123'); + }); + + test('challenge invokes the correct method and parses result', () async { + mock({'challenge_type': 'oob', 'oob_code': 'code'}); + + final result = await platform.challenge(request( + MfaChallengeOptions(mfaToken: 'mfa-token', authenticatorId: 'sms|1'))); + + expect(log.single.method, 'mfa#challenge'); + expect(result.oobCode, 'code'); + }); + + test('verify invokes the correct method and parses credentials', () async { + mock({ + 'accessToken': 'access-token', + 'idToken': 'id-token', + 'refreshToken': 'refresh-token', + 'expiresAt': '2050-01-01T00:00:00.000Z', + 'scopes': ['openid'], + 'userProfile': {'sub': 'user-id'}, + 'tokenType': 'Bearer', + }); + + final credentials = await platform.verify(request(MfaVerifyOptions( + mfaToken: 'mfa-token', + grantType: MfaVerifyGrantType.otp, + otp: '123456'))); + + expect(log.single.method, 'mfa#verify'); + expect((log.single.arguments as Map)['grantType'], 'otp'); + expect(credentials.accessToken, 'access-token'); + expect(credentials.user.sub, 'user-id'); + }); + + test('throws MfaException on PlatformException', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (final call) async { + throw PlatformException( + code: 'expired_token', + message: 'mfa_token is expired', + details: {'_statusCode': 401}); + }); + + expect( + () => platform.challenge(request(MfaChallengeOptions( + mfaToken: 'mfa-token', authenticatorId: 'sms|1'))), + throwsA(isA() + .having((final e) => e.code, 'code', 'expired_token') + .having((final e) => e.isMfaTokenExpired, 'isMfaTokenExpired', + true)), + ); + }); + }); +} From 57acc9d6eda0c40f9d530aabe73b3c91ca5b21f7 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 19 Jun 2026 18:30:32 +0530 Subject: [PATCH 02/11] adding early access tag for MFA fgf --- auth0_flutter/EXAMPLES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index 244577b46..8c5b6117c 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -1651,6 +1651,8 @@ try { ## ๐Ÿ“ฑ 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) only**; Web and Windows are 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`. From 07f25d83fb0889bc03073cc5118720cc617fcf78 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Tue, 23 Jun 2026 10:03:00 +0530 Subject: [PATCH 03/11] fix: Correct MFA Swift file layout and Android test import --- .../mfa/VerifyRequestHandlerTest.kt | 1 + .../MfaAPI/MfaChallengeMethodHandler.swift | 31 +++++ .../MfaAPI/MfaEnrollEmailMethodHandler.swift | 31 +++++ .../MfaAPI/MfaEnrollPhoneMethodHandler.swift | 31 +++++ .../MfaAPI/MfaEnrollPushMethodHandler.swift | 28 +++++ .../MfaAPI/MfaEnrollTotpMethodHandler.swift | 28 +++++ .../darwin/Classes/MfaAPI/MfaExtensions.swift | 110 +++++++++++++++++ .../MfaGetAuthenticatorsMethodHandler.swift | 30 +++++ .../darwin/Classes/MfaAPI/MfaHandler.swift | 79 +++++++++++++ .../MfaAPI/MfaVerifyMethodHandler.swift | 52 ++++++++ .../MfaAPI/MfaChallengeMethodHandler.swift | 32 +---- .../MfaAPI/MfaEnrollEmailMethodHandler.swift | 32 +---- .../MfaAPI/MfaEnrollPhoneMethodHandler.swift | 32 +---- .../MfaAPI/MfaEnrollPushMethodHandler.swift | 29 +---- .../MfaAPI/MfaEnrollTotpMethodHandler.swift | 29 +---- .../ios/Classes/MfaAPI/MfaExtensions.swift | 111 +----------------- .../MfaGetAuthenticatorsMethodHandler.swift | 31 +---- .../ios/Classes/MfaAPI/MfaHandler.swift | 80 +------------ .../MfaAPI/MfaVerifyMethodHandler.swift | 53 +-------- .../MfaAPI/MfaChallengeMethodHandler.swift | 1 + .../MfaAPI/MfaEnrollEmailMethodHandler.swift | 1 + .../MfaAPI/MfaEnrollPhoneMethodHandler.swift | 1 + .../MfaAPI/MfaEnrollPushMethodHandler.swift | 1 + .../MfaAPI/MfaEnrollTotpMethodHandler.swift | 1 + .../macos/Classes/MfaAPI/MfaExtensions.swift | 1 + .../MfaGetAuthenticatorsMethodHandler.swift | 1 + .../macos/Classes/MfaAPI/MfaHandler.swift | 1 + .../MfaAPI/MfaVerifyMethodHandler.swift | 1 + 28 files changed, 439 insertions(+), 420 deletions(-) create mode 100644 auth0_flutter/darwin/Classes/MfaAPI/MfaChallengeMethodHandler.swift create mode 100644 auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift create mode 100644 auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift create mode 100644 auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift create mode 100644 auth0_flutter/darwin/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift create mode 100644 auth0_flutter/darwin/Classes/MfaAPI/MfaExtensions.swift create mode 100644 auth0_flutter/darwin/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift create mode 100644 auth0_flutter/darwin/Classes/MfaAPI/MfaHandler.swift create mode 100644 auth0_flutter/darwin/Classes/MfaAPI/MfaVerifyMethodHandler.swift mode change 100644 => 120000 auth0_flutter/ios/Classes/MfaAPI/MfaChallengeMethodHandler.swift mode change 100644 => 120000 auth0_flutter/ios/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift mode change 100644 => 120000 auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift mode change 100644 => 120000 auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift mode change 100644 => 120000 auth0_flutter/ios/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift mode change 100644 => 120000 auth0_flutter/ios/Classes/MfaAPI/MfaExtensions.swift mode change 100644 => 120000 auth0_flutter/ios/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift mode change 100644 => 120000 auth0_flutter/ios/Classes/MfaAPI/MfaHandler.swift mode change 100644 => 120000 auth0_flutter/ios/Classes/MfaAPI/MfaVerifyMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/MfaAPI/MfaChallengeMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/MfaAPI/MfaExtensions.swift create mode 120000 auth0_flutter/macos/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/MfaAPI/MfaHandler.swift create mode 120000 auth0_flutter/macos/Classes/MfaAPI/MfaVerifyMethodHandler.swift 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 index ddc6d5d2b..4d7d80c9f 100644 --- 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 @@ -8,6 +8,7 @@ import com.auth0.android.callback.Callback import com.auth0.android.request.Request import com.auth0.android.result.Credentials import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMap import io.flutter.plugin.common.MethodChannel.Result import org.junit.Test import org.junit.runner.RunWith 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..f4cf71f31 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MfaAPI/MfaExtensions.swift @@ -0,0 +1,110 @@ +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] = [ + "_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..79c5481f0 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MfaAPI/MfaVerifyMethodHandler.swift @@ -0,0 +1,52 @@ +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"))) + } + + request.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/ios/Classes/MfaAPI/MfaChallengeMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaChallengeMethodHandler.swift deleted file mode 100644 index cc2a7e573..000000000 --- a/auth0_flutter/ios/Classes/MfaAPI/MfaChallengeMethodHandler.swift +++ /dev/null @@ -1,31 +0,0 @@ -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/ios/Classes/MfaAPI/MfaChallengeMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaChallengeMethodHandler.swift new file mode 120000 index 000000000..3103042a5 --- /dev/null +++ b/auth0_flutter/ios/Classes/MfaAPI/MfaChallengeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaChallengeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift deleted file mode 100644 index 8971516df..000000000 --- a/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift +++ /dev/null @@ -1,31 +0,0 @@ -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/ios/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift new file mode 120000 index 000000000..f8bf53bbc --- /dev/null +++ b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift deleted file mode 100644 index 5f2fcc67e..000000000 --- a/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift +++ /dev/null @@ -1,31 +0,0 @@ -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/ios/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift new file mode 120000 index 000000000..278d94c10 --- /dev/null +++ b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift deleted file mode 100644 index 99a89f013..000000000 --- a/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift +++ /dev/null @@ -1,28 +0,0 @@ -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/ios/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift new file mode 120000 index 000000000..7de3ea6cd --- /dev/null +++ b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift deleted file mode 100644 index b3a0c434b..000000000 --- a/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift +++ /dev/null @@ -1,28 +0,0 @@ -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/ios/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift new file mode 120000 index 000000000..cdd1b0542 --- /dev/null +++ b/auth0_flutter/ios/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MfaAPI/MfaExtensions.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaExtensions.swift deleted file mode 100644 index f4cf71f31..000000000 --- a/auth0_flutter/ios/Classes/MfaAPI/MfaExtensions.swift +++ /dev/null @@ -1,110 +0,0 @@ -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] = [ - "_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/ios/Classes/MfaAPI/MfaExtensions.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaExtensions.swift new file mode 120000 index 000000000..9b963dd4b --- /dev/null +++ b/auth0_flutter/ios/Classes/MfaAPI/MfaExtensions.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaExtensions.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift deleted file mode 100644 index 8a95e357c..000000000 --- a/auth0_flutter/ios/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift +++ /dev/null @@ -1,30 +0,0 @@ -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/ios/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift new file mode 120000 index 000000000..89afc8f5b --- /dev/null +++ b/auth0_flutter/ios/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MfaAPI/MfaHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaHandler.swift deleted file mode 100644 index bb9f66a33..000000000 --- a/auth0_flutter/ios/Classes/MfaAPI/MfaHandler.swift +++ /dev/null @@ -1,79 +0,0 @@ -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/ios/Classes/MfaAPI/MfaHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaHandler.swift new file mode 120000 index 000000000..16ce8e947 --- /dev/null +++ b/auth0_flutter/ios/Classes/MfaAPI/MfaHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MfaAPI/MfaVerifyMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaVerifyMethodHandler.swift deleted file mode 100644 index 79c5481f0..000000000 --- a/auth0_flutter/ios/Classes/MfaAPI/MfaVerifyMethodHandler.swift +++ /dev/null @@ -1,52 +0,0 @@ -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"))) - } - - request.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/ios/Classes/MfaAPI/MfaVerifyMethodHandler.swift b/auth0_flutter/ios/Classes/MfaAPI/MfaVerifyMethodHandler.swift new file mode 120000 index 000000000..72dc64396 --- /dev/null +++ b/auth0_flutter/ios/Classes/MfaAPI/MfaVerifyMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaVerifyMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MfaAPI/MfaChallengeMethodHandler.swift b/auth0_flutter/macos/Classes/MfaAPI/MfaChallengeMethodHandler.swift new file mode 120000 index 000000000..3103042a5 --- /dev/null +++ b/auth0_flutter/macos/Classes/MfaAPI/MfaChallengeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaChallengeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift b/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift new file mode 120000 index 000000000..f8bf53bbc --- /dev/null +++ b/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaEnrollEmailMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift b/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift new file mode 120000 index 000000000..278d94c10 --- /dev/null +++ b/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaEnrollPhoneMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift b/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift new file mode 120000 index 000000000..7de3ea6cd --- /dev/null +++ b/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaEnrollPushMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift b/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift new file mode 120000 index 000000000..cdd1b0542 --- /dev/null +++ b/auth0_flutter/macos/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaEnrollTotpMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MfaAPI/MfaExtensions.swift b/auth0_flutter/macos/Classes/MfaAPI/MfaExtensions.swift new file mode 120000 index 000000000..9b963dd4b --- /dev/null +++ b/auth0_flutter/macos/Classes/MfaAPI/MfaExtensions.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaExtensions.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift b/auth0_flutter/macos/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift new file mode 120000 index 000000000..89afc8f5b --- /dev/null +++ b/auth0_flutter/macos/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaGetAuthenticatorsMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MfaAPI/MfaHandler.swift b/auth0_flutter/macos/Classes/MfaAPI/MfaHandler.swift new file mode 120000 index 000000000..16ce8e947 --- /dev/null +++ b/auth0_flutter/macos/Classes/MfaAPI/MfaHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MfaAPI/MfaVerifyMethodHandler.swift b/auth0_flutter/macos/Classes/MfaAPI/MfaVerifyMethodHandler.swift new file mode 120000 index 000000000..72dc64396 --- /dev/null +++ b/auth0_flutter/macos/Classes/MfaAPI/MfaVerifyMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MfaAPI/MfaVerifyMethodHandler.swift \ No newline at end of file From 532e5e36f8b3445119f2dfdfb0857a59d2da5b5f Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Tue, 23 Jun 2026 10:37:57 +0530 Subject: [PATCH 04/11] fix(mfa): revert android dep to 3.18.0 and fix failing unit tests --- auth0_flutter/android/build.gradle | 2 +- .../auth0_flutter/Auth0FlutterPluginTest.kt | 8 +++++--- .../mfa/VerifyRequestHandlerTest.kt | 19 ++++++++++++++++--- ...aGetAuthenticatorsMethodHandlerTests.swift | 6 +++--- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/auth0_flutter/android/build.gradle b/auth0_flutter/android/build.gradle index 28613185a..cec326c79 100644 --- a/auth0_flutter/android/build.gradle +++ b/auth0_flutter/android/build.gradle @@ -73,7 +73,7 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation 'com.auth0.android:auth0:3.19.0' + implementation 'com.auth0.android:auth0:3.18.0' implementation 'com.google.code.gson:gson:2.10.1' testImplementation 'junit:junit:4.13.2' testImplementation 'org.hamcrest:java-hamcrest:2.0.0.0' 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/request_handlers/mfa/VerifyRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/VerifyRequestHandlerTest.kt index 4d7d80c9f..968a9a270 100644 --- 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 @@ -7,8 +7,8 @@ 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 com.auth0.auth0_flutter.toMap import io.flutter.plugin.common.MethodChannel.Result import org.junit.Test import org.junit.runner.RunWith @@ -97,8 +97,21 @@ class VerifyRequestHandlerTest { whenever(credentials.type).thenReturn("Bearer") whenever(credentials.scope).thenReturn("openid") whenever(credentials.expiresAt).thenReturn(java.util.Date(0)) - val user = mock() - whenever(user.toMap()).thenReturn(mapOf("sub" to "user-id")) + 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) diff --git a/auth0_flutter/example/ios/Tests/Mfa/MfaGetAuthenticatorsMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/Mfa/MfaGetAuthenticatorsMethodHandlerTests.swift index 4533949e8..cc8a17bac 100644 --- a/auth0_flutter/example/ios/Tests/Mfa/MfaGetAuthenticatorsMethodHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/Mfa/MfaGetAuthenticatorsMethodHandlerTests.swift @@ -1,5 +1,5 @@ import XCTest -import Auth0 +@testable import Auth0 @testable import auth0_flutter @@ -44,8 +44,8 @@ class MfaGetAuthenticatorsMethodHandlerTests: XCTestCase { func testProducesAuthenticatorListOnSuccess() { let expectation = self.expectation(description: "Produced list") spy.getAuthenticatorsResult = .success([ - Authenticator(id: "sms|dev_1", authenticatorType: "oob", active: true, - oobChannel: "sms", name: "+1******90") + 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 { From 6e808667cc9fbb6a9f9b7f51aebba8068a492ca8 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Tue, 23 Jun 2026 11:31:52 +0530 Subject: [PATCH 05/11] feat(mfa): add Multi-Factor Authentication support for Flutter Web --- auth0_flutter/example/web/index.html | 5 +- auth0_flutter/lib/auth0_flutter_web.dart | 34 ++- .../src/web/auth0_flutter_plugin_real.dart | 94 +++++++++ .../web/auth0_flutter_web_platform_proxy.dart | 13 ++ .../src/web/extensions/mfa_extensions.dart | 59 ++++++ auth0_flutter/lib/src/web/js_interop.dart | 98 +++++++++ auth0_flutter/lib/src/web/mfa_web.dart | 140 +++++++++++++ auth0_flutter/test/web/mfa_web_test.dart | 196 ++++++++++++++++++ .../lib/src/auth0_flutter_web_platform.dart | 42 ++++ 9 files changed, 678 insertions(+), 3 deletions(-) create mode 100644 auth0_flutter/lib/src/web/extensions/mfa_extensions.dart create mode 100644 auth0_flutter/lib/src/web/mfa_web.dart create mode 100644 auth0_flutter/test/web/mfa_web_test.dart 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 @@ - + +