Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
191 changes: 191 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,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.

<details>
<summary>Mobile (Android/iOS)</summary>

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).
}
}
```

</details>

<details>
<summary>Web</summary>

> **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).
}
}
```

</details>

### 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...');
```

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
3 changes: 3 additions & 0 deletions auth0_flutter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
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,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<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.

// `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")
)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading