Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
144 changes: 144 additions & 0 deletions auth0_flutter/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@
- [Log in to an organization](#log-in-to-an-organization)
- [Accept user invitations](#accept-user-invitations)
- [πŸ“± Bot detection](#-bot-detection)
- [πŸ“± My Account API](#-my-account-api)
- [Obtaining an access token for the My Account API](#obtaining-an-access-token-for-the-my-account-api)
- [Listing and managing authentication methods](#listing-and-managing-authentication-methods)
- [Enrolling a factor with OTP (phone, email, TOTP)](#enrolling-a-factor-with-otp-phone-email-totp)
- [Enrolling a factor without OTP (push, recovery code)](#enrolling-a-factor-without-otp-push-recovery-code)
- [Using DPoP](#using-dpop)
- [Errors](#errors-3)

## πŸ“± Web Authentication

Expand Down Expand Up @@ -1327,3 +1334,140 @@ try {
---

[Go up ‴](#examples)

## πŸ“± My Account API

The My Account API lets authenticated users manage their own multi-factor authentication (MFA) methods β€” enrolling, confirming, listing, updating, and deleting factors such as phone, email, TOTP, push notifications, and recovery codes. It is available on **mobile (Android/iOS) only**.

> πŸ’‘ The My Account API must be enabled for your tenant. If it is not yet available on your account, reach out to Auth0 support to get it enabled.

### Obtaining an access token for the My Account API

The My Account API requires an access token issued specifically for the `https://YOUR_DOMAIN/me/` audience, with the scopes for the operations you intend to perform.

The **recommended approach** is to log in **once** for your application with the `offline_access` scope (so a refresh token is stored), and then exchange that refresh token for a My Account–scoped access token β€” instead of launching a second interactive login. This is the same pattern the other Auth0 SDKs follow ([react-native-auth0](https://github.com/auth0/react-native-auth0/blob/master/EXAMPLES.md), [Auth0.swift](https://github.com/auth0/Auth0.swift/blob/master/EXAMPLES.md), and [Auth0.Android](https://github.com/auth0/Auth0.Android/blob/main/EXAMPLES.md)).

```dart
// 1. Log in once for your app, requesting offline_access to get a refresh token.
final credentials = await auth0.webAuthentication().login(
scopes: {'openid', 'profile', 'email', 'offline_access'},
);
await auth0.credentialsManager.storeCredentials(credentials);

// 2. Exchange the stored refresh token for a token scoped to the My Account API,
// by requesting the `https://YOUR_DOMAIN/me/` audience and the My Account scopes.
final myAccountCredentials = await auth0.credentialsManager.getApiCredentials(
audience: 'https://YOUR_DOMAIN/me/',
scope: {
'read:me:authentication_methods',
'create:me:authentication_methods',
'update:me:authentication_methods',
'delete:me:authentication_methods',
'read:me:factors',
},
);

// 3. Create the My Account client with the resulting access token.
final myAccount = auth0.myAccount(
accessToken: myAccountCredentials.accessToken,
);
```

> πŸ’‘ `getApiCredentials` returns a **separate**, audience-scoped token via a [Multi-Resource Refresh Token (MRRT)](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token) exchange; it does **not** replace the application credentials stored via `storeCredentials`. The token is cached per audience, so subsequent calls return the cached value until it expires.

> ⚠️ Exchanging the refresh token requires MRRT to be enabled for your tenant, and the application must have requested `offline_access` at login so that a refresh token is available.

### Listing and managing authentication methods

```dart
// List all enrolled MFA methods.
final methods = await myAccount.getAuthenticationMethods();

// Optionally filter by type.
final phones = await myAccount.getAuthenticationMethods(
type: AuthenticationMethodType.phone,
);

// Retrieve a single method by id.
final method = await myAccount.getAuthenticationMethod(id: 'method_id');

// List the factors available for enrollment on the tenant.
final factors = await myAccount.getFactors();

// Update a method's display name and/or preferred phone channel.
await myAccount.updateAuthenticationMethod(
id: 'method_id',
name: 'My personal phone',
preferredAuthenticationMethod: PhoneType.voice,
);

// Delete a method.
await myAccount.deleteAuthenticationMethod(id: 'method_id');
```

### Enrolling a factor with OTP (phone, email, TOTP)

Phone, email, and TOTP enrollments are completed by verifying a one-time password with `verifyOtp`. Pass the `factorType` of the factor you enrolled so the correct confirmation endpoint is used.

```dart
// Start phone enrollment (an OTP is sent via SMS).
final challenge = await myAccount.enrollPhone(
phoneNumber: '+1234567890',
type: PhoneType.sms,
);

// Confirm with the OTP the user received.
final method = await myAccount.verifyOtp(
id: challenge.id,
authSession: challenge.authSession,
otp: '123456',
factorType: 'phone', // 'phone' | 'email' | 'totp'
);
```

The same two-step flow applies to `enrollEmail` (`factorType: 'email'`) and `enrollTotp` (`factorType: 'totp'`).

### Enrolling a factor without OTP (push, recovery code)

Push notification and recovery code enrollments do not use an OTP β€” they are completed with `confirmEnrollment`.

```dart
// Start push enrollment.
final challenge = await myAccount.enrollPush();

// ...complete the out-of-band step (e.g. the user approves on their device), then:
final method = await myAccount.confirmEnrollment(
id: challenge.id,
authSession: challenge.authSession,
factorType: 'push-notification', // 'push-notification' | 'recovery-code'
);
```

The same flow applies to `enrollRecoveryCode` (`factorType: 'recovery-code'`).

### Using DPoP

To secure My Account API requests with [DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof-of-Possession) sender-constrained tokens, set `useDPoP` to `true` when creating the client. It defaults to `false`. The DPoP key pair is generated and stored securely on the device (Keychain on iOS, Keystore on Android).

```dart
final myAccount = auth0.myAccount(
accessToken: myAccountCredentials.accessToken,
useDPoP: true,
);
```

### Errors

My Account API calls throw a `MyAccountException` on failure.

```dart
try {
await myAccount.getAuthenticationMethods();
} on MyAccountException catch (e) {
print('${e.code}: ${e.message} (${e.statusCode})');
}
```

---

[Go up ‴](#examples)
102 changes: 102 additions & 0 deletions auth0_flutter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,108 @@ void dispose() {
- [getApiCredentials](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/DefaultCredentialsManager/getApiCredentials.html)
- [clearApiCredentials](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/DefaultCredentialsManager/clearApiCredentials.html)

#### My Account API

The My Account API allows users to manage their own multi-factor authentication (MFA) methods. Available on **mobile (Android/iOS) only**.

The My Account API requires an access token issued for the `https://{domain}/me/` audience. The recommended approach is to log in **once** with the `offline_access` scope, then exchange the stored refresh token for a My Account–scoped token β€” rather than launching a second interactive login. This mirrors the other Auth0 SDKs.

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.

Are you able to authenticate with the https://YOUR_DOMAIN/me/ audience ? Ideally the recommended approach is use MRRT to get the token for my account audience and not get them while authenticating. check how RN has done
https://github.com/auth0/react-native-auth0/blob/master/EXAMPLES.md#my-account-api

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.

Thanks, good point. The example authenticates against the me audience directly just to keep the sample minimal. I'll add a note to the README clarifying that the recommended production approach is to obtain a My Account–audience token via MRRT (Multi-Resource Refresh Token) rather than during the initial login, mirroring how react-native-auth0 documents it.

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.

@utkrishtsahu I don't see this note added. I owuld still recommend to follow the proper appraoch in the examples file

```dart
// 1. Log in once for your app, requesting offline_access to get a refresh token.
final credentials = await auth0.webAuthentication().login(
scopes: {'openid', 'profile', 'email', 'offline_access'},
);
await auth0.credentialsManager.storeCredentials(credentials);

// 2. Exchange the stored refresh token for a token scoped to the My Account API.
final myAccountCredentials = await auth0.credentialsManager.getApiCredentials(
audience: 'https://YOUR_DOMAIN/me/',
scope: {
'read:me:authentication_methods',
'create:me:authentication_methods',
'update:me:authentication_methods',
'delete:me:authentication_methods',
'read:me:factors',
},
);
```

> `getApiCredentials` returns a **separate**, audience-scoped token; it does **not** replace the application credentials stored via `storeCredentials`. Exchanging the refresh token requires [Multi-Resource Refresh Tokens (MRRT)](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token) to be enabled for your tenant.

Then create the My Account client and use it:

```dart
final myAccount = auth0.myAccount(accessToken: myAccountCredentials.accessToken);

// List enrolled MFA methods
final methods = await myAccount.getAuthenticationMethods();

// Optionally filter by type
final phones = await myAccount.getAuthenticationMethods(
type: AuthenticationMethodType.phone,
);

// List available factors
final factors = await myAccount.getFactors();

// Enroll a new phone factor
final challenge = await myAccount.enrollPhone(
phoneNumber: '+1234567890',
type: PhoneType.sms,
);

// Verify enrollment with OTP (phone, email, TOTP)
await myAccount.verifyOtp(
id: challenge.id,
authSession: challenge.authSession,
otp: '123456',
);

// Update an existing method (e.g. rename, change preferred channel)
await myAccount.updateAuthenticationMethod(
id: 'method_id',
name: 'My personal phone',
preferredAuthenticationMethod: PhoneType.voice,
);

// Delete a method
await myAccount.deleteAuthenticationMethod(id: 'method_id');
```

Other enrollment methods: `enrollEmail`, `enrollTotp`, `enrollPush`, `enrollRecoveryCode`.

Push notification and recovery code enrollments are confirmed without an OTP using `confirmEnrollment`:

```dart
final challenge = await myAccount.enrollPush();
// ...complete the out-of-band step, then:
await myAccount.confirmEnrollment(
id: challenge.id,
authSession: challenge.authSession,
);
```

##### DPoP

To secure My Account API requests with [DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof-of-Possession) sender-constrained tokens, set `useDPoP` to `true` when creating the client. It defaults to `false`. The DPoP key pair is generated and stored securely on the device (Keychain on iOS, Keystore on Android).

```dart
final myAccount = auth0.myAccount(
accessToken: credentials.accessToken,
useDPoP: true,
);
```

Error handling:

```dart
try {
await myAccount.getAuthenticationMethods();
} on MyAccountException catch (e) {
print('${e.code}: ${e.message} (${e.statusCode})');
}
```

### 🌐 Web

- [loginWithRedirect](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter_web/Auth0Web/loginWithRedirect.html)
Expand Down
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.16.0'
implementation 'com.auth0.android:auth0:3.18.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.hamcrest:java-hamcrest:2.0.0.0'
testImplementation "org.mockito.kotlin:mockito-kotlin:4.1.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.auth0.auth0_flutter

import android.content.Context
import androidx.annotation.NonNull
import com.auth0.android.myaccount.MyAccountAPIClient
import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
import com.auth0.auth0_flutter.request_handlers.my_account.MyAccountRequestHandler
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 Auth0FlutterMyAccountMethodCallHandler(
private val myAccountRequestHandlers: List<MyAccountRequestHandler>
) : MethodCallHandler {
lateinit var context: Context

override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
val request = MethodCallRequest.fromCall(call)

val handler = myAccountRequestHandlers.find { it.method == call.method }
if (handler != null) {
assertHasProperties(listOf("accessToken"), request.data)
val accessToken = request.data["accessToken"] as String
val useDPoP = request.data["useDPoP"] as? Boolean ?: false
val client = MyAccountAPIClient(request.account, accessToken).apply {
if (useDPoP) {
useDPoP(context)
}
}

handler.handle(client, request, result)
} else {
result.notImplemented()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.auth0.android.result.Credentials
import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
import com.auth0.auth0_flutter.request_handlers.api.*
import com.auth0.auth0_flutter.request_handlers.credentials_manager.*
import com.auth0.auth0_flutter.request_handlers.my_account.*
import com.auth0.auth0_flutter.request_handlers.web_auth.LoginWebAuthRequestHandler
import com.auth0.auth0_flutter.request_handlers.web_auth.LogoutWebAuthRequestHandler
import io.flutter.embedding.engine.plugins.FlutterPlugin
Expand All @@ -27,6 +28,7 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
private lateinit var authMethodChannel : MethodChannel
private lateinit var credentialsManagerMethodChannel : MethodChannel
private lateinit var dpopMethodChannel : MethodChannel
private lateinit var myAccountMethodChannel : MethodChannel
private lateinit var binding: FlutterPlugin.FlutterPluginBinding
private lateinit var authCallHandler: Auth0FlutterAuthMethodCallHandler
private var pendingRecoveredCredentials: Map<String, Any?>? = null
Expand All @@ -51,6 +53,20 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
GetDPoPHeadersApiRequestHandler(),
ClearDPoPKeyApiRequestHandler()
))
private val myAccountCallHandler = Auth0FlutterMyAccountMethodCallHandler(listOf(
GetAuthenticationMethodsRequestHandler(),
GetAuthenticationMethodRequestHandler(),
DeleteAuthenticationMethodRequestHandler(),
GetFactorsRequestHandler(),
EnrollPhoneRequestHandler(),
EnrollEmailRequestHandler(),
EnrollTotpRequestHandler(),
EnrollPushRequestHandler(),
EnrollRecoveryCodeRequestHandler(),
VerifyOtpRequestHandler(),
ConfirmEnrollmentRequestHandler(),
UpdateAuthenticationMethodRequestHandler()
))

private val processDeathCallback = object : Callback<Credentials, AuthenticationException> {
override fun onSuccess(credentials: Credentials) {
Expand Down Expand Up @@ -127,6 +143,10 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {

dpopMethodChannel = MethodChannel(messenger, "auth0.com/auth0_flutter/dpop")
dpopMethodChannel.setMethodCallHandler(dpopCallHandler)

myAccountMethodChannel = MethodChannel(messenger, "auth0.com/auth0_flutter/my_account")
myAccountMethodChannel.setMethodCallHandler(myAccountCallHandler)
myAccountCallHandler.context = context
}

override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: MethodChannel.Result) {
Expand All @@ -152,6 +172,7 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
authMethodChannel.setMethodCallHandler(null)
credentialsManagerMethodChannel.setMethodCallHandler(null)
dpopMethodChannel.setMethodCallHandler(null)
myAccountMethodChannel.setMethodCallHandler(null)
}

override fun onAttachedToActivity(binding: ActivityPluginBinding) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.auth0.auth0_flutter

import com.auth0.android.myaccount.MyAccountException

fun MyAccountException.toMyAccountMap(): Map<String, Any> {
val exception = this
return buildMap {
put("_statusCode", exception.statusCode)
put("_title", exception.getCode())
put("_detail", exception.getDescription())
put("_errorFlags", mapOf(
"isNetworkError" to exception.isNetworkError,
))

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.

The MyAccountException returns more info which actually represents what went wrong like title, detail etc. These help the customer know what went wrong and also help us debug when an issue arises. Currently the above map captures only the status code, which in itself is not helpful. Add the other properties too

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.

Fixed. Added title and detail to the error map on both Android and iOS. Also exposed them as first-class properties on the Dart MyAccountException class (exception.title, exception.detail).

}
}
Loading
Loading