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