Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions auth0_flutter/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1642,3 +1648,128 @@ try {
---

[Go up ‴](#examples)

## πŸ“± Multi-Factor Authentication (MFA)

The MFA API lets you complete a multi-factor authentication flow using an `mfa_token` β€” Auth0's [flexible/expanded grant support](https://auth0.com/docs/secure/multi-factor-authentication). It is available on **mobile (Android/iOS) only**; Web and Windows are not supported.

Unlike the [My Account API](#-my-account-api) β€” which manages a signed-in user's authenticators β€” the MFA API is used **mid-login**, when a token request fails because MFA is required. You use the `mfa_token` from that failure to list, challenge, enroll, and verify a factor, and the successful verification returns the user's `Credentials`.

### Obtaining an `mfa_token`

When an authentication request (for example a database login or a credentials renewal) requires a second factor, the SDK throws an `ApiException` whose `isMultifactorRequired` flag is `true` and which carries an `mfaToken`. Pass that token to `auth0.mfa(...)` to start the flow.

```dart
final auth0 = Auth0('YOUR_DOMAIN', 'YOUR_CLIENT_ID');

try {
await auth0.api.login(
usernameOrEmail: 'user@example.com',
password: 'secret',
connectionOrRealm: 'Username-Password-Authentication',
scopes: {'openid', 'profile', 'email'},
);
} on ApiException catch (e) {
if (e.isMultifactorRequired && e.mfaToken != null) {
final mfa = auth0.mfa(mfaToken: e.mfaToken!);

// `mfaRequirements` (when present) tells you which factors the user can
// be challenged with, and which they can newly enroll.
final requirements = e.mfaRequirements;
print('Can challenge: '
'${requirements?.challenge.map((f) => f.type).toList()}');
print('Can enroll: '
'${requirements?.enroll.map((f) => f.type).toList()}');

// ...drive the challenge or enrollment flow with `mfa` (see below).
}
}
```

### Listing authenticators and challenging a factor

If the user already has authenticators enrolled, list them and trigger a challenge on the one they choose. For out-of-band factors (SMS, Voice, Email, Push) the challenge delivers the code and returns an `oobCode`; for TOTP you verify the code directly without challenging.

```dart
final authenticators = await mfa.getAuthenticators();

// Optionally narrow the results to specific factor types.
final oobOnly = await mfa.getAuthenticators(factorsAllowed: ['oob']);

final selected = authenticators.first;
final challenge = await mfa.challenge(authenticatorId: selected.id);

// `challenge.oobCode` is used to verify out-of-band factors (see below).
// When `challenge.bindingMethod == 'prompt'`, the user must also enter the
// code they received as the `bindingCode`.
```

### Enrolling a new factor

If the user has no suitable authenticator yet, enroll one. Each enrollment returns an `MfaEnrollmentChallenge`; which of its fields are populated depends on the factor.

```dart
// TOTP (authenticator app): render `barcodeUri` as a QR code (or show
// `totpSecret`), then verify with the OTP from the app.
final totp = await mfa.enrollTotp();
// totp.barcodeUri, totp.totpSecret, totp.recoveryCodes

// Phone (SMS by default, or Voice): an OOB code is sent to the number.
final phone = await mfa.enrollPhone(
phoneNumber: '+1234567890',
type: PhoneType.sms, // or PhoneType.voice
);

// Email: an OOB code is sent to the address.
final email = await mfa.enrollEmail(email: 'user@example.com');

// Push (Auth0 Guardian): render `barcodeUri` for the user to scan.
final push = await mfa.enrollPush();
```

### Verifying and exchanging for credentials

Verifying completes the flow and exchanges the `mfa_token` for the user's `Credentials`. Pick the method that matches the factor:

```dart
// TOTP β€” the code from the authenticator app.
final credentials = await mfa.verifyOtp(otp: '123456');

// Out-of-band (SMS, Voice, Email, Push) β€” the `oobCode` from the challenge
// (or enrollment). Provide `bindingCode` when the challenge's bindingMethod
// is `prompt` (the user enters the code they received).
final credentials = await mfa.verifyOob(
oobCode: challenge.oobCode!,
bindingCode: '123456',
);

// Recovery code β€” a one-time code the user saved during enrollment.
final credentials = await mfa.verifyRecoveryCode(recoveryCode: 'ABCD1234...');

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// On success, persist the credentials as usual.
await auth0.credentialsManager.storeCredentials(credentials);
```

### Errors

MFA API calls throw an `MfaException` on failure. It exposes convenience getters for the common cases so you can branch without inspecting raw error codes.

```dart
try {
final credentials = await mfa.verifyOtp(otp: '000000');
} on MfaException catch (e) {
if (e.isMfaTokenExpired) {
// The mfa_token is no longer valid β€” restart the login flow.
} else if (e.isInvalidCode) {
// The user entered a wrong/expired code β€” let them retry.
} else if (e.isNetworkError) {
// Transient β€” `e.isRetryable` is true.
} else {
print('${e.code}: ${e.message}');
}
}
```

---

[Go up ‴](#examples)
2 changes: 1 addition & 1 deletion auth0_flutter/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ android {

dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'com.auth0.android:auth0:3.18.0'
implementation 'com.auth0.android:auth0:3.19.0'
implementation 'com.google.code.gson:gson:2.10.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.hamcrest:java-hamcrest:2.0.0.0'
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MfaRequestHandler>
) : 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)
Comment on lines +21 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd Auth0FlutterMfaMethodCallHandler.kt auth0_flutter/android

Repository: auth0/auth0-flutter

Length of output: 161


🏁 Script executed:

cat -n auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterMfaMethodCallHandler.kt

Repository: auth0/auth0-flutter

Length of output: 1468


🏁 Script executed:

fd assertHasProperties auth0_flutter/android && cat -n auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/utils/assertHasProperties.kt

Repository: auth0/auth0-flutter

Length of output: 1033


🏁 Script executed:

rg "mfaToken" auth0_flutter/android --type kotlin -B 3 -A 3

Repository: auth0/auth0-flutter

Length of output: 40573


Use safe cast for mfaToken to prevent ClassCastException in MethodChannel handler.

Line 22 uses an unsafe force-cast that will crash if mfaToken is not a String. Replace with a safe cast (as? String) and return result.error() if the type is invalid, rather than allowing a crash to propagate.

Per coding guidelines: ClassCastException from unsafe casts in MethodChannel handlers has caused crashes in the past β€” treat any unchecked cast as a bug.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterMfaMethodCallHandler.kt`
around lines 21 - 23, The mfaToken extraction in
Auth0FlutterMfaMethodCallHandler uses an unsafe force cast that will crash if
the value is not a String. Replace the unsafe cast `as String` with a safe cast
`as? String` and add a null check to handle the case where mfaToken is not of
the expected type. When the safe cast fails and returns null, call
result.error() to properly report the type mismatch error instead of allowing a
ClassCastException to propagate.

Source: Coding guidelines


handler.handle(client, request, result)
} else {
result.notImplemented()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any?>? = null
Expand Down Expand Up @@ -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<Credentials, AuthenticationException> {
override fun onSuccess(credentials: Credentials) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.auth0.auth0_flutter

import com.auth0.android.authentication.mfa.MfaException
import com.auth0.android.result.Authenticator
import com.auth0.android.result.Challenge
import com.auth0.android.result.Credentials
import com.auth0.android.result.EnrollmentChallenge
import com.auth0.android.result.MfaEnrollmentChallenge
import com.auth0.android.result.OobEnrollmentChallenge
import com.auth0.android.result.RecoveryCodeEnrollmentChallenge
import com.auth0.android.result.TotpEnrollmentChallenge

fun Authenticator.toMfaMap(): Map<String, Any?> = 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<String, Any?> = buildMap {
put("challenge_type", challengeType)
put("oob_code", oobCode)
put("binding_method", bindingMethod)
}

fun EnrollmentChallenge.toMfaEnrollmentMap(): Map<String, Any?> = 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<String, Any?> {
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<String, Any> = buildMap {

@pmathew92 pmathew92 Jun 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All type of MFAExceptions has code and description too which is what actually carries the error . Add those two

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done. MfaException.toMfaMap() now surfaces both code and description (the actual error carried by the native SDK) in the details map, in addition to the top-level result.error code/message:

put("code", getCode())
put("description", getDescription())
I also mirrored this on iOS/macOS (MfaExtensions.swift) so the Dart layer gets the same code/description contract on every platform.

put("_statusCode", statusCode)
put(
"_errorFlags", mapOf(
"isNetworkError" to false
)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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

Comment on lines +23 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical

Use safe-cast for authenticatorId in MethodChannel payload parsing.

Line 24 force-casts to String. Since assertHasProperties only validates key presence (not type), invalid payload types will throw ClassCastException at runtime.

Proposed fix
-        assertHasProperties(listOf("authenticatorId"), request.data)
-        val authenticatorId = request.data["authenticatorId"] as String
+        assertHasProperties(listOf("authenticatorId"), request.data)
+        val authenticatorId = request.data["authenticatorId"] as? String
+            ?: throw IllegalArgumentException(
+                "authenticatorId must be a String"
+            )

Per coding guidelines, unchecked casts in Android MethodChannel handlers are treated as critical bugs due to prior crash incidents.

πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assertHasProperties(listOf("authenticatorId"), request.data)
val authenticatorId = request.data["authenticatorId"] as String
assertHasProperties(listOf("authenticatorId"), request.data)
val authenticatorId = request.data["authenticatorId"] as? String
?: throw IllegalArgumentException(
"authenticatorId must be a String"
)
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/ChallengeRequestHandler.kt`
around lines 23 - 25, The authenticatorId extraction in the
ChallengeRequestHandler uses an unsafe force cast operator that can throw
ClassCastException if the MethodChannel payload contains an invalid type.
Replace the force cast operator used on the authenticatorId assignment with the
safe cast operator, and add appropriate null-checking to handle cases where the
cast fails. This ensures type validation errors are handled gracefully instead
of crashing at runtime.

client.challenge(authenticatorId)
.start(object : Callback<Challenge, MfaChallengeException> {
override fun onFailure(exception: MfaChallengeException) {
result.error(
exception.getCode(),
exception.getDescription(),
exception.toMfaMap()
)
}

override fun onSuccess(res: Challenge) {
result.success(res.toMfaChallengeMap())
}
})
}
}
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +24 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Replace unchecked cast with guarded parsing and return result.error on invalid input.

Line 25 can throw at runtime (as String) for malformed channel args, which bypasses
Flutter error propagation. Validate type with as? and fail through result.error(...)
before returning.

Proposed fix
     ) {
-        assertHasProperties(listOf("email"), request.data)
-        val email = request.data["email"] as String
+        val email = request.data["email"] as? String
+        if (email.isNullOrBlank()) {
+            result.error(
+                "a0.sdk.invalid_argument",
+                "The 'email' option is required and must be a non-empty String.",
+                mapOf("field" to "email")
+            )
+            return
+        }
 
         client.enroll(MfaEnrollmentType.Email(email))
             .start(object : Callback<EnrollmentChallenge, MfaEnrollmentException> {

As per coding guidelines: "Avoid force-casts (as Type) β€” use safe casts (as? Type)"
and "Auth errors must be surfaced through result.error, never swallowed silently`.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/mfa/EnrollEmailRequestHandler.kt`
around lines 24 - 25, The unchecked cast `as String` on line 25 in
EnrollEmailRequestHandler can throw a ClassCastException at runtime for
malformed input, bypassing proper Flutter error propagation. Replace the force
cast with a safe cast using `as?` to validate the email type, then check if the
result is null and return `result.error(...)` with an appropriate error message
before proceeding with the email variable. This ensures invalid input is
properly surfaced through the Flutter channel rather than crashing.

Source: Coding guidelines


client.enroll(MfaEnrollmentType.Email(email))
.start(object : Callback<EnrollmentChallenge, MfaEnrollmentException> {
override fun onFailure(exception: MfaEnrollmentException) {
result.error(
exception.getCode(),
exception.getDescription(),
exception.toMfaMap()
)
}

override fun onSuccess(res: EnrollmentChallenge) {
result.success(res.toMfaEnrollmentMap())
}
})
}
}
Loading
Loading