diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml
index cb2c90193..84d523dab 100644
--- a/.github/actions/setup-darwin/action.yml
+++ b/.github/actions/setup-darwin/action.yml
@@ -79,7 +79,7 @@ runs:
echo "::error::FlutterGeneratedPluginSwiftPackage/Package.swift not found at expected path"
exit 1
fi
- sed -i '' 's/\.iOS(".*")/.iOS("14.0")/g' "$PACKAGE_FILE"
+ sed -i '' 's/\.iOS(".*")/.iOS("15.0")/g' "$PACKAGE_FILE"
shell: bash
- name: Fix macOS minimum deployment target for SPM
@@ -90,7 +90,7 @@ runs:
echo "::error::FlutterGeneratedPluginSwiftPackage/Package.swift not found at expected path"
exit 1
fi
- sed -i '' 's/\.macOS(".*")/.macOS("11.0")/g' "$PACKAGE_FILE"
+ sed -i '' 's/\.macOS(".*")/.macOS("12.0")/g' "$PACKAGE_FILE"
shell: bash
- name: Set .env
diff --git a/CLAUDE.md b/CLAUDE.md
index f7571bd4b..93963b683 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,7 +26,7 @@ Apply these on every task in this repo — they keep changes correct, small, and
- **Language:** Dart 3.5.0+ (Flutter 3.24.0+), plus native Kotlin (Android), Swift (iOS/macOS via SPM/CocoaPods), and C++ (Windows)
- **Tech Stack:** Federated Flutter plugin — a Dart-facing package (`auth0_flutter`) and a platform-interface package (`auth0_flutter_platform_interface`) that dispatches to native implementations over `MethodChannel`; web uses `dart:js_interop` over `auth0-spa-js`
- **Package Manager:** `pub` (Dart/Flutter); native deps via CocoaPods/SPM (Apple), Gradle (Android), vcpkg (Windows)
-- **Minimum Platform Version:** Android API 21+, iOS 14+, macOS 11+, Windows 10+
+- **Minimum Platform Version:** Android API 26+, iOS 15+, macOS 12+, Windows 10+
- **Dependencies:** `auth0_flutter_platform_interface`, `plugin_platform_interface`, `web` · native: SimpleKeychain 1.3.0 (Apple) · test: `mockito`, `flutter_test`, `dart_jsonwebtoken` — see `auth0_flutter/pubspec.yaml` and `auth0_flutter_platform_interface/pubspec.yaml` for the full list
---
diff --git a/auth0_flutter/CHANGELOG.md b/auth0_flutter/CHANGELOG.md
index c63960926..083831968 100644
--- a/auth0_flutter/CHANGELOG.md
+++ b/auth0_flutter/CHANGELOG.md
@@ -12,6 +12,28 @@
- fix: bump Auth0.Android to 3.21.2 for biometric failure fix [\#968](https://github.com/auth0/auth0-flutter/pull/968) ([utkrishtsahu](https://github.com/utkrishtsahu))
- fix(android): bump Auth0.Android to 3.21.1 for Keystore2 INCOMPATIBLE_DEVICE fix [\#963](https://github.com/auth0/auth0-flutter/pull/963) ([utkrishtsahu](https://github.com/utkrishtsahu))
+## [af-v3.0.0-beta.0](https://github.com/auth0/auth0-flutter/tree/af-v3.0.0-beta.0) (2026-09-16)
+[Full Changelog](https://github.com/auth0/auth0-flutter/compare/af-v2.6.2...af-v3.0.0-beta.0)
+
+**Breaking Change**
+- feat: remove inline MFA methods from the Authentication API. [\#943](https://github.com/auth0/auth0-flutter/pull/943) ([utkrishtsahu](https://github.com/utkrishtsahu))
+- feat: reconcile WebAuthenticationException error codes across Android and iOS [\#935](https://github.com/auth0/auth0-flutter/pull/935) ([utkrishtsahu](https://github.com/utkrishtsahu))
+- feat: change SSOCredentials.expiresIn to expiresAt (Android + iOS) [\#931](https://github.com/auth0/auth0-flutter/pull/931) ([utkrishtsahu](https://github.com/utkrishtsahu))
+- feat: Upgrade Auth0.Android to v4.0.0. [\#918](https://github.com/auth0/auth0-flutter/pull/918) ([utkrishtsahu](https://github.com/utkrishtsahu))
+- feat: Upgrade Auth0.swift to v3.0.2 [\#909](https://github.com/auth0/auth0-flutter/pull/909) ([NandanPrabhu](https://github.com/NandanPrabhu))
+
+**Added**
+- feat: add nonce to IdTokenValidationConfig for Web Auth login [\#944](https://github.com/auth0/auth0-flutter/pull/944) ([utkrishtsahu](https://github.com/utkrishtsahu))
+- feat: dpop web auth init config [\#938](https://github.com/auth0/auth0-flutter/pull/938) ([utkrishtsahu](https://github.com/utkrishtsahu))
+- feat: add credentialsManager.clearAll() for full credential and key wipe [\#930](https://github.com/auth0/auth0-flutter/pull/930) ([utkrishtsahu](https://github.com/utkrishtsahu))
+
+**Changed**
+- feat: default credentialsManager minTtl to 60 seconds [\#929](https://github.com/auth0/auth0-flutter/pull/929) ([utkrishtsahu](https://github.com/utkrishtsahu))
+- feat: honor useEphemeralSession on Android web auth [\#927](https://github.com/auth0/auth0-flutter/pull/927) ([utkrishtsahu](https://github.com/utkrishtsahu))
+
+**Fixed**
+- fix: register Android web auth callbacks with the activity lifecycle [\#928](https://github.com/auth0/auth0-flutter/pull/928) ([utkrishtsahu](https://github.com/utkrishtsahu))
+
## [af-v2.6.2](https://github.com/auth0/auth0-flutter/tree/af-v2.6.2) (2026-09-15)
[Full Changelog](https://github.com/auth0/auth0-flutter/compare/af-v2.6.1...af-v2.6.2)
diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md
index fc5ca744b..74d5e3834 100644
--- a/auth0_flutter/EXAMPLES.md
+++ b/auth0_flutter/EXAMPLES.md
@@ -11,6 +11,7 @@
- [1. Configure a custom URL scheme](#1-configure-a-custom-url-scheme)
- [2. Capture the callback URL](#2-capture-the-callback-url)
- [Using Partial Custom Tabs (Android only)](#using-partial-custom-tabs-android-only)
+ - [Using Trusted Web Activity or Auth Tab (Android only)](#using-trusted-web-activity-or-auth-tab-android-only)
- [Errors](#errors)
- [Android: Custom schemes](#android-custom-schemes)
- [🪟 Windows Web Authentication](#-windows-web-authentication)
@@ -262,6 +263,16 @@ final credentials =
await auth0.webAuthentication().login(idTokenValidationConfig: config);
```
+You can also supply your own `nonce` to be validated against the `nonce` claim of the returned ID token. When omitted, the SDK generates and validates one automatically.
+
+```dart
+const config = IdTokenValidationConfig(nonce: 'a1b2c3d4');
+final credentials =
+ await auth0.webAuthentication().login(idTokenValidationConfig: config);
+```
+
+> ℹ️ `nonce` is not supported on Web, where it is always managed internally by auth0-spa-js.
+
@@ -395,6 +406,8 @@ The available options are:
| `sideSheetBreakpoint` | `int?` | Screen width threshold (dp) to switch between bottom sheet and side sheet. Defaults to the browser's built-in value (typically 840dp). |
| `backgroundInteractionEnabled` | `bool?` | Whether the user can interact with the app behind the partial tab. Defaults to `false`. |
| `allowedBrowsers` | `List` | Allowlist of browser packages for Custom Tabs. |
+| `useTrustedWebActivity` | `bool` | Launch the browser as a [Trusted Web Activity](#using-trusted-web-activity-or-auth-tab-android-only). Defaults to `false`. |
+| `useAuthTab` | `bool` | Launch the browser as an [Auth Tab](#using-trusted-web-activity-or-auth-tab-android-only). Defaults to `false`. |
You can also use `customTabsOptions` during logout:
@@ -406,6 +419,35 @@ await auth0.webAuthentication().logout(
));
```
+### Using Trusted Web Activity or Auth Tab (Android only)
+
+Both launch mechanisms are opt-in and are enabled through `CustomTabsOptions`. They apply to `login` and `logout`, and are ignored on iOS, macOS, Windows and web.
+
+- **Auth Tab** uses the Chrome Custom Tabs Activity Result API instead of inferring the result from the activity lifecycle. This avoids spurious `isUserCancelledException` errors when Chrome is minimized. It requires Chrome 137+; older browsers fall back to a standard Custom Tab.
+- **Trusted Web Activity (TWA)** opens the Auth0 domain without browser UI. Your app must be verified for the domain through [Digital Asset Links](https://developer.android.com/training/app-links/verify-android-applinks).
+
+```dart
+// Auth Tab
+await auth0.webAuthentication().login(
+ customTabsOptions: const CustomTabsOptions(useAuthTab: true));
+
+// Trusted Web Activity
+await auth0.webAuthentication().logout(
+ customTabsOptions: const CustomTabsOptions(useTrustedWebActivity: true));
+```
+
+The two are mutually exclusive. If both are set to `true`, Trusted Web Activity takes precedence and Auth Tab is not used.
+
+#### Ephemeral sessions
+
+`useEphemeralSession: true` on `login` is honored by Auth Tab: the tab runs in an isolated session when the browser supports it (Chrome 136+ or a compatible browser), and otherwise falls back to a regular Auth Tab. It has no effect with Trusted Web Activity, because a TWA shares the user's browser profile by design and cannot run in an isolated session.
+
+```dart
+await auth0.webAuthentication().login(
+ useEphemeralSession: true,
+ customTabsOptions: const CustomTabsOptions(useAuthTab: true));
+```
+
### Errors
@@ -418,15 +460,21 @@ try {
final credentials = await auth0.webAuthentication().login();
// ...
} on WebAuthenticationException catch (e) {
- if (e.isRetryable) {
+ if (e.isUserCancelledException) {
+ // User dismissed the browser — same on Android and iOS
+ } else if (e.isRetryable) {
// Transient error (e.g. network issue) — safe to retry
+ } else if (e.code == 'dpop_jkt_mismatch') {
+ // DPoP thumbprint mismatch returned by the Auth0 server (Android + iOS)
} else {
print(e);
}
}
```
-The `isRetryable` property indicates whether the error is transient (e.g. a network outage) and the operation can be retried.
+- `isUserCancelledException` — `true` when the user dismisses the browser without completing login (Android + iOS).
+- `isRetryable` — `true` when the error is transient (e.g. a network outage) and the operation can be retried.
+- For server-returned errors (e.g. `dpop_jkt_mismatch`, `access_denied`), `exception.code` contains the raw server error code directly.
@@ -710,6 +758,13 @@ final credentials = await auth0.credentialsManager.credentials();
> 💡 You do not need to call `credentialsManager.storeCredentials()` afterward. The Credentials Manager automatically persists the renewed credentials.
+> 💡 By default, `credentials()` (and `getApiCredentials()`) requires the access token to have at least **60 seconds** of remaining lifetime, matching Auth0.Android v4 and Auth0.swift v3. If the token would expire sooner, it is refreshed using the refresh token. Pass `minTtl: 0` to return the stored token regardless of how soon it expires, or a larger value to require more headroom:
+>
+> ```dart
+> // Return the stored token even if it is about to expire.
+> final credentials = await auth0.credentialsManager.credentials(minTtl: 0);
+> ```
+
### 🕒 Session expiry from an upstream IdP
> ⚠️ **Early Access:** upstream IdP session expiry (the IPSIE [`session_expiry`](https://openid.github.io/ipsie-openid-sl1/draft-openid-ipsie-sl1-profile.html) claim) is an **Early Access** feature. The API surface described here (the `sessionExpiry` field and the `isSessionExpired` error) may change before it is generally available.
@@ -792,6 +847,22 @@ await auth0.credentialsManager.clearApiCredentials(
>
> 💡 Stored API credentials are keyed by **both** audience and scope on every platform, so pass the same `scope` to `clearApiCredentials()` that you used when fetching them. The native APIs do not consistently report whether a matching entry existed, so this method returns `void` rather than a success flag.
+### Clear all credentials and encryption keys
+
+To wipe **everything** the Credentials Manager stores – all credentials, all cached API credentials, and the underlying encryption keys – use `clearAll()`:
+
+```dart
+await auth0.credentialsManager.clearAll();
+```
+
+> 💡 `clearAll()` is a more thorough wipe than `clearCredentials()` or `clearApiCredentials()`:
+>
+> - `clearCredentials()` removes the stored credential entries only. On iOS/macOS it deletes the credentials, DPoP thumbprint, and session-expiry entries; on Android it clears the credentials store.
+> - `clearApiCredentials(audience:)` removes only the cached API credentials for a specific audience (and scope), leaving the main credentials and the encryption keys intact.
+> - `clearAll()` additionally removes the cryptographic keys used to protect the stored data – on Android the crypto key pair and the DPoP key; on iOS/macOS every entry in the credentials store plus the DPoP key pair.
+>
+> ⚠️ Because `clearAll()` deletes *all* entries in the underlying store, avoid sharing the Credentials Manager's storage (for example, a custom `sharedPreferencesName` on Android or `storeKey`/`accessGroup` on iOS) with unrelated app data.
+
### Retrieve user profile
Fetch the user profile associated with the stored credentials. This method returns `null` if no credentials are present in storage.
@@ -933,7 +1004,7 @@ try {
print('Session Transfer Token: ${ssoCredentials.sessionTransferToken}');
print('Token Type: ${ssoCredentials.tokenType}');
- print('Expires In: ${ssoCredentials.expiresIn} seconds');
+ print('Expires At: ${ssoCredentials.expiresAt}');
} on CredentialsManagerException catch (e) {
print('Failed to get SSO credentials: ${e.message}');
}
@@ -946,7 +1017,7 @@ try {
final ssoCredentials = await auth0.api.ssoExchange(refreshToken: refreshToken);
print('Session Transfer Token: ${ssoCredentials.sessionTransferToken}');
- print('Expires In: ${ssoCredentials.expiresIn} seconds');
+ print('Expires At: ${ssoCredentials.expiresAt}');
} on ApiException catch (e) {
print('SSO Exchange failed: ${e.code} - ${e.message}');
}
@@ -1513,7 +1584,7 @@ final credentials = await auth0.passwordless.loginWithOtp(
> [!NOTE]
-> If the user has MFA configured, `loginWithOtp` fails with an [`ApiException`](#errors-2) whose `isMultifactorRequired` is `true`. Continue the flow using the existing MFA APIs (`auth0.api.multifactorChallenge` / `auth0.api.loginWithOtp`).
+> If the user has MFA configured, `loginWithOtp` fails with an [`ApiException`](#errors-2) whose `isMultifactorRequired` is `true`. Continue the flow using the MFA APIs (`auth0.mfa.challenge` / `auth0.mfa.verifyOtp`).
To receive DPoP-bound tokens from the token exchange when DPoP is enabled for your client, construct your `Auth0` instance with `useDPoP: true`:
@@ -1753,7 +1824,7 @@ try {
if (e.isVerificationRequired) {
final credentials = await auth0.webAuthentication().login(
scopes: scopes,
- useEphemeralSession: true, // Otherwise a session cookie will remain (iOS/macOS only)
+ useEphemeralSession: true, // Otherwise a session cookie will remain (see note below)
parameters: {
'connection': connection,
'login_hint': email // So the user doesn't have to type it again
@@ -1763,6 +1834,8 @@ try {
}
```
+> 💡 `useEphemeralSession` starts a private browser session so no session cookie is persisted. It is honored on iOS/macOS and, as of v3, on Android. On Android it depends on the device browser supporting ephemeral (private) Custom Tabs sessions — if the browser doesn't support it, the login falls back to a normal Custom Tabs session and is **not** ephemeral.
+
---
[Go up ⤴](#examples)
diff --git a/auth0_flutter/README.md b/auth0_flutter/README.md
index ededbb85f..e14e7e374 100644
--- a/auth0_flutter/README.md
+++ b/auth0_flutter/README.md
@@ -11,6 +11,8 @@
## What's New
+> 📖 **Migrating from auth0_flutter v2?** See the [v3 Migration Guide](V3_MIGRATION_GUIDE.md) for the raised platform requirements, changed defaults, and breaking changes.
+
**auth0_flutter** supports **Passkeys** on Android, iOS, and Web. On mobile, use `Auth0.api.passkeyLoginChallenge`/`passkeySignupChallenge`/`passkeyCredentialExchange`; on web, use the equivalent `Auth0Web.passkeyLoginChallenge`/`passkeySignupChallenge`/`getTokenByPasskey` (requires `auth0-spa-js` v2.24.0+). See the [Auth0 passkeys documentation](https://auth0.com/docs/authenticate/database-connections/passkeys) for setup instructions.
## Documentation
@@ -28,8 +30,8 @@
| Flutter | Android | iOS | macOS | Windows |
| :---------- | :-------------- | :---------------- | :---------------- | :------------------------------- |
-| SDK 3.24.0+ | Android API 21+ | iOS 14+ | macOS 11+ | Windows 10+ |
-| Dart 3.5.0+ | Java 8+ | Swift 5.9+ | Swift 5.9+ | C++ 17, Visual Studio 2022 |
+| SDK 3.24.0+ | Android API 26+ | iOS 15+ | macOS 12+ | Windows 10+ |
+| Dart 3.5.0+ | Java 17+ | Swift 5.9+ | Swift 5.9+ | C++ 17, Visual Studio 2022 |
| | | Xcode 15.x / 16.x | Xcode 15.x / 16.x | vcpkg (for dependencies) |
### Installation
@@ -368,7 +370,7 @@ final auth0Web = Auth0Web('YOUR_AUTH0_DOMAIN', 'YOUR_AUTH0_CLIENT_ID');
Finally, in your `index.html` add the following `
+
```
> ⚠️ If you use [Custom Token Exchange](EXAMPLES.md#custom-token-exchange) with delegation/impersonation (an actor token), you must reference `auth0-spa-js` **2.20.0 or later**. Actor-token support was added to the underlying SPA SDK in [2.20.0](https://github.com/auth0/auth0-spa-js/releases/tag/v2.20.0); older versions silently drop the actor token, so the returned ID token has no `act` claim and `credentials.user.actor` is `null`.
@@ -627,8 +629,6 @@ void dispose() {
#### API
- [login](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/login.html)
-- [loginWithOtp](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/loginWithOtp.html)
-- [multifactorChallenge](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/multifactorChallenge.html)
- [renewCredentials](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/renewCredentials.html)
- [resetPassword](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/resetPassword.html)
- [signup](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/signup.html)
diff --git a/auth0_flutter/V3_MIGRATION_GUIDE.md b/auth0_flutter/V3_MIGRATION_GUIDE.md
new file mode 100644
index 000000000..8ea82c730
--- /dev/null
+++ b/auth0_flutter/V3_MIGRATION_GUIDE.md
@@ -0,0 +1,350 @@
+# Migration Guide from auth0_flutter v2 to v3
+
+`auth0_flutter` v3 upgrades the wrapped native SDKs to their next major versions
+— **Auth0.Android v4** on Android and **Auth0.swift v3** on iOS/macOS. Those
+upgrades raise the minimum platform requirements, change some default values for
+better out-of-the-box behavior, and change some native behavior that surfaces
+through the Dart API. This guide documents the changes required when migrating
+from v2 to v3.
+
+---
+
+## Table of Contents
+
+- [**Requirements Changes**](#requirements-changes)
+- [**Breaking Changes**](#breaking-changes)
+ + [Inline MFA methods removed from the Authentication API](#inline-mfa-methods-removed-from-the-authentication-api)
+ + [`SSOCredentials.expiresIn` is now `expiresAt` (`DateTime`)](#ssocredentialsexpiresin-is-now-expiresat-datetime)
+ + [`WebAuthenticationException` error codes reconciled (Android + iOS)](#webauthenticationexception-error-codes-reconciled-android--ios)
+- [**Default Values Changed**](#default-values-changed)
+ + [Credentials manager `minTtl` now defaults to 60 seconds](#credentials-manager-minttl-now-defaults-to-60-seconds)
+- [**Behavior Changes**](#behavior-changes)
+ + [`credentialsManager.clearCredentials` now clears all stored data (Android)](#credentialsmanagerclearcredentials-now-clears-all-stored-data-android)
+ + [Web Auth `useEphemeralSession` is now honored on Android](#web-auth-useephemeralsession-is-now-honored-on-android)
+ + [Android Web Auth recovers login results across process death](#android-web-auth-recovers-login-results-across-process-death)
+- [**New APIs**](#new-apis)
+ + [`credentialsManager.clearAll()` — full credential and key cleanup](#credentialsmanagerclearall--full-credential-and-key-cleanup)
+- [Getting Help](#getting-help)
+
+---
+
+## Requirements Changes
+
+Because the underlying native SDKs raised their floors, `auth0_flutter` v3
+raises the Android, iOS, and macOS minimums:
+
+| Requirement | v2 | v3 |
+| --- | --- | --- |
+| iOS deployment target | 14.0 | **15.0** |
+| macOS deployment target | 11.0 | **12.0** |
+| Android `minSdkVersion` | 21 | **26** (Android 8.0) |
+| Android compile/target SDK | 34 | **36** |
+| JDK (to build the Android module) | 8 | **17** |
+| Kotlin | 1.9.x | **2.0.21** |
+| Android Gradle Plugin | 8.4.x | **8.10.1** |
+| Gradle | 8.7 | **8.11.1** |
+
+**Migration (iOS/macOS):** if your app's deployment target is below iOS 15.0 or
+macOS 12.0, raise it. Update the `platform` line in `ios/Podfile` (for example
+`platform :ios, '15.0'`) and `macos/Podfile` (`platform :osx, '12.0'`), and the
+deployment target of the Runner target in Xcode. These floors come from
+Auth0.swift v3.
+
+**Migration (Android):** if your app targets an Android `minSdkVersion` below 26, raise it
+to at least 26 in your app's `android/app/build.gradle`. Ensure your build
+environment uses JDK 17 (for example, set it in Android Studio under
+*Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK*,
+or via `org.gradle.java.home`).
+
+There are **no source changes required** to your Dart code for these
+requirement bumps.
+
+---
+
+## Breaking Changes
+
+### Inline MFA methods removed from the Authentication API
+
+**Change:** The inline MFA methods `api.loginWithOtp` and
+`api.multifactorChallenge` have been removed from the authentication client.
+This follows Auth0.Android v4 and Auth0.swift v3, which route MFA through a
+dedicated MFA client. Their functionality is covered by the MFA API
+(`auth0.mfa()`), which lists authenticators, requests a
+challenge, and verifies OTP/OOB/recovery-code factors. The `ChallengeType`
+enum, used only by `multifactorChallenge`, has also been removed.
+
+**Impact:** Code that calls `auth0.api.loginWithOtp` or
+`auth0.api.multifactorChallenge` no longer compiles.
+
+| Removed (`auth0.api`) | Replacement (`auth0.mfa`) |
+|-----------------------|---------------------------|
+| `loginWithOtp({otp, mfaToken})` | `verifyOtp({otp})` |
+| `multifactorChallenge({mfaToken, types, authenticatorId})` | `challenge({authenticatorId})` |
+
+**Migration:** Obtain an `MfaApi` instance by passing the `mfaToken` from the
+MFA-required `ApiException` to `auth0.mfa()`, then drive the challenge/verify
+flow through it. The `mfaToken` is supplied once rather than repeated on each
+call, and challenges target a specific `authenticatorId`:
+
+```dart
+try {
+ await auth0.api.login(
+ usernameOrEmail: email,
+ password: password,
+ connectionOrRealm: 'Username-Password-Authentication',
+ );
+} on ApiException catch (e) {
+ if (e.isMultifactorRequired && e.mfaToken != null) {
+ final mfa = auth0.mfa(mfaToken: e.mfaToken!);
+
+ // ❌ v2 — inline challenge + OTP login on the auth client
+ // await auth0.api.multifactorChallenge(mfaToken: e.mfaToken!, ...);
+ // final credentials =
+ // await auth0.api.loginWithOtp(otp: '123456', mfaToken: e.mfaToken!);
+
+ // ✅ v3 — challenge a specific authenticator, then verify the OTP
+ final authenticators =
+ await mfa.getAuthenticators(factorsAllowed: ['totp', 'phone']);
+ await mfa.challenge(authenticatorId: authenticators.first.id);
+ final credentials = await mfa.verifyOtp(otp: '123456');
+ }
+}
+```
+
+For TOTP factors you can skip the challenge request and call `mfa.verifyOtp`
+directly; use `mfa.challenge` for out-of-band factors (SMS, email, push).
+
+### `SSOCredentials.expiresIn` is now `expiresAt` (`DateTime`)
+
+**Change:** Auth0.Android v4 and Auth0.swift v3 changed the Native to Web SSO
+credential expiry from a relative `expiresIn` (seconds) to an absolute
+`expiresAt` (date). `SSOCredentials` now exposes `expiresAt` as a UTC `DateTime`
+instead of `expiresIn` as an `int`, matching `Credentials.expiresAt`. This
+affects both `credentialsManager.ssoCredentials()` and `api.ssoExchange()` on
+Android and iOS/macOS.
+
+**Impact:** Code that reads `ssoCredentials.expiresIn` no longer compiles.
+
+**Migration:** Read `expiresAt`, and derive the remaining lifetime yourself if
+you still need a relative value:
+
+```dart
+final sso = await auth0.credentialsManager.ssoCredentials();
+
+// ❌ v2 — relative seconds
+// final secondsLeft = sso.expiresIn;
+
+// ✅ v3 — absolute UTC timestamp
+final DateTime expiresAt = sso.expiresAt;
+final secondsLeft = expiresAt.difference(DateTime.now().toUtc()).inSeconds;
+```
+
+### `WebAuthenticationException` error codes reconciled (Android + iOS)
+
+**Change:** The error codes emitted by `WebAuthenticationException` are now
+consistent across Android and iOS, and the Dart class exposes typed boolean
+getters for each case.
+
+#### Android now emits `USER_CANCELLED` on user cancel
+
+Previously, when a user dismissed the browser on Android, the native SDK
+emitted the raw code `a0.authentication_canceled`. iOS already emitted
+`USER_CANCELLED`. Both platforms now emit `USER_CANCELLED`.
+
+**Migration:** Replace any check on the raw Android code:
+
+```dart
+// v2 — required platform-branching
+if (e.code == 'USER_CANCELLED' || e.code == 'a0.authentication_canceled') { ... }
+
+// v3 — single check, or use the typed getter
+if (e.isUserCancelledException) { ... }
+```
+
+#### New typed getters on `WebAuthenticationException`
+
+| Getter | Code | Platforms |
+|--------|------|-----------|
+| `isUserCancelledException` | `USER_CANCELLED` | Android + iOS |
+| `isAuthenticationFailed` | `AUTHENTICATION_FAILED` | iOS |
+| `isCodeExchangeFailed` | `CODE_EXCHANGE_FAILED` | iOS |
+| `isIdTokenValidationFailed` | `ID_TOKEN_VALIDATION_FAILED` | iOS |
+| `isTransactionActiveAlready` | `TRANSACTION_ACTIVE_ALREADY` | iOS |
+
+#### Server error codes surface through `authenticationFailed` / `codeExchangeFailed` (iOS)
+
+Auth0.swift v3 wraps server-returned errors (e.g. `dpop_jkt_mismatch`,
+`access_denied`, `invalid_grant`) inside `authenticationFailed` or
+`codeExchangeFailed` as the cause. The Flutter SDK now extracts the underlying
+server error code and surfaces it directly, so `exception.code` returns the
+actual server code rather than the generic wrapper case name.
+
+**Migration:** If you were matching on `AUTHENTICATION_FAILED` or
+`CODE_EXCHANGE_FAILED` and needed the underlying error detail, switch to
+checking `exception.code` directly:
+
+```dart
+try {
+ await auth0.webAuthentication().login();
+} on WebAuthenticationException catch (e) {
+ if (e.isUserCancelledException) {
+ // User dismissed the browser — same code on Android and iOS
+ } else if (e.code == 'dpop_jkt_mismatch') {
+ // DPoP thumbprint mismatch from the Auth0 server
+ } else if (e.isAuthenticationFailed) {
+ // authenticationFailed with no recognized server cause
+ }
+}
+```
+
+#### Removed iOS error codes (v2 only)
+
+The following iOS-only v2 error codes are no longer emitted (they mapped to
+removed `WebAuthError` cases in Auth0.swift v3):
+
+| Removed code | Replacement |
+|---|---|
+| `a0.no_bundle_identifier` | `UNKNOWN` |
+| `a0.pkce_not_allowed` | `UNKNOWN` |
+| `a0.no_authorization_code` | `UNKNOWN` — code-exchange failures now use `CODE_EXCHANGE_FAILED` |
+| `a0.invalid_invitation_url` | `UNKNOWN` |
+
+**Migration:** Remove any `catch` branches that match these codes.
+
+---
+
+## Default Values Changed
+
+### Credentials manager `minTtl` now defaults to 60 seconds
+
+**Change:** To match Auth0.Android v4 and Auth0.swift v3, the credential
+retrieval APIs now default `minTtl` to **60** seconds instead of `0`. This
+applies to `credentialsManager.credentials()` and
+`credentialsManager.getApiCredentials()` on both Android and iOS/macOS. A
+returned access token must have at least 60 seconds of remaining lifetime; if it
+would expire sooner, it is refreshed (when a refresh token is available) instead
+of being returned as-is.
+
+**Impact:** Calling these methods without an explicit `minTtl` now applies a
+60-second floor. Tokens within 60 seconds of expiration trigger a refresh, and
+if no refresh token is available the call fails instead of returning the
+near-expired token.
+
+**Migration:** No code change is required to adopt the new default. To keep the
+previous behavior, pass `minTtl: 0` explicitly:
+
+```dart
+await auth0.credentialsManager.credentials(minTtl: 0);
+await auth0.credentialsManager.getApiCredentials(audience: '...', minTtl: 0);
+```
+
+`hasValidCredentials` is intentionally left at `minTtl: 0`, matching the native
+`hasValid` behavior — it reports whether a credential is currently valid without
+applying the 60-second floor.
+
+**Reason:** A `minTtl` of `0` meant credentials were not renewed until already
+expired, which could deliver access tokens that expire immediately after
+retrieval and cause subsequent API requests to fail. A 60-second floor keeps the
+returned token valid for a reasonable period.
+
+---
+
+## Behavior Changes
+
+### `credentialsManager.clearCredentials` now clears all stored data (Android)
+
+**Change:** In Auth0.Android v4, `clearCredentials()` performs a full wipe of
+the underlying storage (`Storage.removeAll()`) rather than removing only the
+individual credential entries it wrote.
+
+**Impact:** If your app stored unrelated values in the same
+`SharedPreferences` instance that the credentials manager uses (for example, by
+supplying a custom `sharedPreferencesName` via
+`CredentialsManagerConfiguration` and reusing it elsewhere), calling
+`clearCredentials` now removes those values too.
+
+**Migration:** Do not share the credentials manager's storage with other data.
+Keep any app data you need to persist independently of Auth0 credentials in a
+separate store. The Dart API is unchanged:
+
+```dart
+// Same call as v2 — behavior on Android is now a full wipe of the store.
+await credentialsManager.clearCredentials();
+```
+
+This affects Android only. iOS/macOS behavior is unchanged.
+
+**Reason:** This simplifies credential cleanup, ensures no stale data remains in
+storage after logout, and aligns Android behavior with the iOS/macOS store.
+
+### Web Auth `useEphemeralSession` is now honored on Android
+
+**Change:** In v2 the Web Auth `useEphemeralSession` login option had no effect
+on Android (it was accepted but ignored, and documented as iOS/macOS only). In
+v3, Android honors it via Auth0.Android v4's ephemeral browsing, matching
+iOS/macOS.
+
+**Impact:** If your Android app already passed `useEphemeralSession: true`
+expecting it to be ignored, the login now runs in a private/ephemeral browser
+session: no session is shared with or persisted in the system browser, so the
+user is not silently signed in from an existing browser session.
+
+**Migration:** No code change is required. Review your use of
+`useEphemeralSession` on Android if you relied on the previous no-op behavior:
+
+```dart
+// v3: on Android this now starts an ephemeral browser session (as on iOS/macOS).
+await auth0.webAuthentication().login(useEphemeralSession: true);
+```
+
+> **Note**
+> Ephemeral sessions depend on the device browser supporting them. If the
+> browser that handles the login does not support ephemeral browsing, Android
+> falls back to a normal Custom Tabs login and the session is not ephemeral.
+
+### Android Web Auth recovers login results across process death
+
+**Change:** Android Web Auth now registers its login callback against the
+activity lifecycle (via Auth0.Android v4's `WebAuthProvider.registerCallbacks`)
+instead of the deprecated global callback. As a result, a login result is
+delivered even if the activity is recreated across a configuration change or the
+process is killed and restarted while the browser is in the foreground.
+
+**Impact:** This is an internal improvement. The Dart API is unchanged and no
+code change is required.
+
+---
+
+## New APIs
+
+### `credentialsManager.clearAll()` — full credential and key cleanup
+
+**Change (additive):** v3 adds `credentialsManager.clearAll()`, which removes all
+stored credentials and cached API credentials **and** the underlying encryption
+keys — on Android the crypto key pair and the DPoP key, on iOS/macOS every entry
+in the credentials store plus the DPoP key pair. It maps to Auth0.Android v4's
+`clearAll()` and Auth0.swift v3's `clearAll()`.
+
+**Impact:** This is a new, optional method, so existing code is unaffected. Use
+`clearCredentials()` to remove only the stored credential entries, or
+`clearAll()` when you also want to drop the encryption keys (for example a full
+sign-out/reset):
+
+```dart
+await auth0.credentialsManager.clearAll();
+```
+
+Because `clearAll()` deletes every entry in the underlying store, avoid sharing
+that store (a custom `sharedPreferencesName` on Android, or `storeKey` /
+`accessGroup` on iOS/macOS) with unrelated app data.
+
+---
+
+## Getting Help
+
+If you encounter issues during migration:
+
+- [GitHub Issues](https://github.com/auth0/auth0-flutter/issues) — report bugs
+ or ask questions, with details of the API you're migrating and the platform
+ affected.
+- [Auth0 Community](https://community.auth0.com/) — community support.
diff --git a/auth0_flutter/android/build.gradle b/auth0_flutter/android/build.gradle
index f462436e0..00f27a9ad 100644
--- a/auth0_flutter/android/build.gradle
+++ b/auth0_flutter/android/build.gradle
@@ -1,12 +1,12 @@
buildscript {
- ext.kotlin_version = '1.9.0'
+ ext.kotlin_version = '2.0.21'
repositories {
google()
mavenCentral()
}
dependencies {
- classpath "com.android.tools.build:gradle:8.4.0"
+ classpath "com.android.tools.build:gradle:8.10.1"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
@@ -39,14 +39,14 @@ rootProject.allprojects {
}
android {
- compileSdk 34
+ compileSdk 36
if (project.android.hasProperty("namespace")) {
namespace libApplicationId
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
}
sourceSets {
@@ -56,7 +56,7 @@ android {
}
defaultConfig {
- minSdkVersion 21
+ minSdkVersion 26
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [auth0Domain: "test-domain", auth0Scheme: "test"]
}
@@ -78,15 +78,15 @@ android {
plugins.withId("org.jetbrains.kotlin.android") {
kotlin {
compilerOptions {
- jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
- implementation 'com.auth0.android:auth0:3.21.3'
- implementation 'com.google.code.gson:gson:2.10.1'
+ implementation 'com.auth0.android:auth0:4.2.1'
+ implementation 'com.google.code.gson:gson:2.11.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"
@@ -96,4 +96,4 @@ dependencies {
testImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
testImplementation 'com.auth0:java-jwt:3.19.1'
-}
\ No newline at end of file
+}
diff --git a/auth0_flutter/android/gradle/wrapper/gradle-wrapper.properties b/auth0_flutter/android/gradle/wrapper/gradle-wrapper.properties
index d951fac2b..11581996b 100644
--- a/auth0_flutter/android/gradle/wrapper/gradle-wrapper.properties
+++ b/auth0_flutter/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
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 09e93c943..74656b029 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
@@ -1,6 +1,8 @@
package com.auth0.auth0_flutter
import androidx.annotation.NonNull
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.callback.Callback
import com.auth0.android.provider.WebAuthProvider
@@ -12,6 +14,7 @@ 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
+import io.flutter.embedding.engine.plugins.lifecycle.HiddenLifecycleReference
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
@@ -48,6 +51,7 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
SaveCredentialsRequestHandler(),
HasValidCredentialsRequestHandler(),
ClearCredentialsRequestHandler(),
+ ClearAllRequestHandler(),
GetCredentialsUserInfoRequestHandler()
))
private val dpopCallHandler = Auth0FlutterDPoPMethodCallHandler(listOf(
@@ -108,6 +112,11 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
}
}
+ private val processDeathLogoutCallback = object : Callback {
+ override fun onSuccess(result: Void?) {}
+ override fun onFailure(exception: AuthenticationException) {}
+ }
+
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
binding = flutterPluginBinding
val messenger = binding.binaryMessenger
@@ -123,8 +132,6 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
authCallHandler = Auth0FlutterAuthMethodCallHandler(
listOf(
LoginApiRequestHandler(),
- LoginWithOtpApiRequestHandler(),
- MultifactorChallengeApiRequestHandler(),
EmailPasswordlessApiRequestHandler(),
PhoneNumberPasswordlessApiRequestHandler(),
LoginWithEmailCodeApiRequestHandler(),
@@ -189,20 +196,30 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
webAuthCallHandler.activity = binding.activity
credentialsManagerCallHandler.activity = binding.activity
- WebAuthProvider.addCallback(processDeathCallback)
+ registerWebAuthCallbacks(binding)
}
override fun onDetachedFromActivityForConfigChanges() {
- WebAuthProvider.removeCallback(processDeathCallback)
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
webAuthCallHandler.activity = binding.activity
credentialsManagerCallHandler.activity = binding.activity
- WebAuthProvider.addCallback(processDeathCallback)
+ registerWebAuthCallbacks(binding)
}
override fun onDetachedFromActivity() {
- WebAuthProvider.removeCallback(processDeathCallback)
+ }
+
+ private fun registerWebAuthCallbacks(binding: ActivityPluginBinding) {
+ val activityLifecycle = (binding.lifecycle as HiddenLifecycleReference).lifecycle
+ val lifecycleOwner = object : LifecycleOwner {
+ override val lifecycle: Lifecycle = activityLifecycle
+ }
+ WebAuthProvider.registerCallbacks(
+ lifecycleOwner,
+ loginCallback = processDeathCallback,
+ logoutCallback = processDeathLogoutCallback
+ )
}
}
\ No newline at end of file
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt
index 8b5deadf6..3c516a20b 100644
--- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt
@@ -4,7 +4,6 @@ import android.app.Activity
import android.content.Context
import androidx.annotation.NonNull
import androidx.fragment.app.FragmentActivity
-import com.auth0.android.Auth0
import com.auth0.android.authentication.AuthenticationAPIClient
import com.auth0.android.authentication.storage.AuthenticationLevel
import com.auth0.android.authentication.storage.LocalAuthenticationOptions
@@ -114,11 +113,11 @@ class CredentialsManagerMethodCallHandler(private val requestHandlers: List("_isRetryable" to isNetworkError)
+ cause?.let {
+ details["cause"] = it.toString()
+ details["causeStackTrace"] = it.stackTraceToString()
+ }
+ val code = when {
+ isCanceled -> "USER_CANCELLED"
+ else -> getCode()
+ }
+ result.error(code, getDescription(), details)
+}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandler.kt
deleted file mode 100644
index 62e226681..000000000
--- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandler.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-package com.auth0.auth0_flutter.request_handlers.api
-
-import com.auth0.android.authentication.AuthenticationAPIClient
-import com.auth0.android.authentication.AuthenticationException
-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.toMap
-import com.auth0.auth0_flutter.utils.assertHasProperties
-import io.flutter.plugin.common.MethodChannel
-import java.util.*
-
-private const val AUTH_LOGIN_OTP_METHOD = "auth#loginOtp"
-
-class LoginWithOtpApiRequestHandler: ApiRequestHandler {
- override val method: String = AUTH_LOGIN_OTP_METHOD
-
- override fun handle(
- api: AuthenticationAPIClient,
- request: MethodCallRequest,
- result: MethodChannel.Result
- ) {
- val args = request.data
-
- assertHasProperties(listOf("mfaToken", "otp"), args)
-
- val loginBuilder = api
- .loginWithOTP(
- args["mfaToken"] as String,
- args["otp"] as String,
- )
-
- loginBuilder.start(object : Callback {
- override fun onFailure(exception: AuthenticationException) {
- result.error(
- exception.getCode(),
- exception.getDescription(),
- exception.toMap()
- )
- }
-
- override fun onSuccess(credentials: Credentials) {
- val scope = credentials.scope?.split(" ") ?: listOf()
- var formattedDate = credentials.expiresAt.toInstant().toString()
- result.success(
- mapOf(
- "accessToken" to credentials.accessToken,
- "idToken" to credentials.idToken,
- "refreshToken" to credentials.refreshToken,
- "userProfile" to credentials.user.toMap(),
- "expiresAt" to formattedDate,
- "scopes" to scope,
- "tokenType" to credentials.type
- )
- )
- }
- })
- }
-
-}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/MultifactorChallengeApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/MultifactorChallengeApiRequestHandler.kt
deleted file mode 100644
index 60d128684..000000000
--- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/MultifactorChallengeApiRequestHandler.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.auth0.auth0_flutter.request_handlers.api
-
-import com.auth0.android.authentication.AuthenticationAPIClient
-import com.auth0.android.authentication.AuthenticationException
-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.toMap
-import com.auth0.auth0_flutter.utils.assertHasProperties
-import io.flutter.plugin.common.MethodChannel
-
-private const val AUTH_MULTIFACTORCHALLENGE_METHOD = "auth#multifactorChallenge"
-
-class MultifactorChallengeApiRequestHandler : ApiRequestHandler {
- override val method: String = AUTH_MULTIFACTORCHALLENGE_METHOD
-
- override fun handle(
- api: AuthenticationAPIClient,
- request: MethodCallRequest,
- result: MethodChannel.Result
- ) {
- assertHasProperties(listOf("mfaToken"), request.data)
-
- val challengeTypes = request.data["types"] as ArrayList<*>?
-
- val builder = api.multifactorChallenge(
- mfaToken = request.data["mfaToken"] as String,
- challengeType = challengeTypes?.joinToString(separator = " "),
- authenticatorId = request.data["authenticatorId"] as String?
- )
-
- builder.start(object : Callback {
- override fun onFailure(exception: AuthenticationException) {
- result.error(
- exception.getCode(),
- exception.getDescription(),
- exception.toMap()
- )
- }
-
- override fun onSuccess(challenge: Challenge) {
- result.success(
- mapOf(
- "challengeType" to challenge.challengeType,
- "oobCode" to challenge.oobCode,
- "bindingMethod" to challenge.bindingMethod
- )
- )
- }
- })
- }
-}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/SSOExchangeApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/SSOExchangeApiRequestHandler.kt
index 491b35a7d..c39c56194 100644
--- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/SSOExchangeApiRequestHandler.kt
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/SSOExchangeApiRequestHandler.kt
@@ -46,7 +46,7 @@ class SSOExchangeApiRequestHandler : ApiRequestHandler {
val map = mutableMapOf(
"sessionTransferToken" to credentials.sessionTransferToken,
"tokenType" to credentials.issuedTokenType,
- "expiresIn" to credentials.expiresIn,
+ "expiresAt" to credentials.expiresAt.toInstant().toString(),
"idToken" to credentials.idToken
)
credentials.refreshToken?.let { map["refreshToken"] = it }
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/ClearAllRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/ClearAllRequestHandler.kt
new file mode 100644
index 000000000..8781463ab
--- /dev/null
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/ClearAllRequestHandler.kt
@@ -0,0 +1,24 @@
+package com.auth0.auth0_flutter.request_handlers.credentials_manager
+
+import android.content.Context
+import com.auth0.android.authentication.storage.SecureCredentialsManager
+import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import io.flutter.plugin.common.MethodChannel
+
+class ClearAllRequestHandler : CredentialsManagerRequestHandler {
+ override val method: String = "credentialsManager#clearAll"
+
+ override fun handle(
+ credentialsManager: SecureCredentialsManager,
+ context: Context,
+ request: MethodCallRequest,
+ result: MethodChannel.Result
+ ) {
+ try {
+ credentialsManager.clearAll()
+ result.success(null)
+ } catch (exception: Exception) {
+ result.error(exception.message ?: "UNKNOWN ERROR", exception.message, null)
+ }
+ }
+}
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetSSOCredentialsRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetSSOCredentialsRequestHandler.kt
index 25aed8597..ce946c4de 100644
--- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetSSOCredentialsRequestHandler.kt
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetSSOCredentialsRequestHandler.kt
@@ -30,7 +30,7 @@ class GetSSOCredentialsRequestHandler : CredentialsManagerRequestHandler {
val map = mutableMapOf(
"sessionTransferToken" to credentials.sessionTransferToken,
"tokenType" to credentials.issuedTokenType,
- "expiresIn" to credentials.expiresIn,
+ "expiresAt" to credentials.expiresAt.toInstant().toString(),
"idToken" to credentials.idToken
)
credentials.refreshToken?.let { map["refreshToken"] = it }
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt
index 41955842d..27b0f842a 100644
--- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt
@@ -7,6 +7,7 @@ import com.auth0.android.provider.WebAuthProvider
import com.auth0.android.result.Credentials
import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
import com.auth0.auth0_flutter.toMap
+import com.auth0.auth0_flutter.toWebAuthResult
import com.auth0.auth0_flutter.utils.buildCustomTabsOptions
import io.flutter.plugin.common.MethodChannel
import java.util.*
@@ -45,6 +46,17 @@ class LoginWebAuthRequestHandler(
buildCustomTabsOptions(args)?.let { builder.withCustomTabsOptions(it) }
+ // The flags are forwarded as-is. TWA and Auth Tab are mutually
+ // exclusive and Auth0.Android resolves the conflict: if both are set,
+ // TWA wins.
+ val customTabsOptions = args["customTabsOptions"] as? Map<*, *>
+ if (customTabsOptions?.get("useTrustedWebActivity") == true) {
+ builder.withTrustedWebActivity()
+ }
+ if (customTabsOptions?.get("useAuthTab") == true) {
+ builder.withAuthTab()
+ }
+
if (args["leeway"] is Int) {
builder.withIdTokenVerificationLeeway(args["leeway"] as Int)
}
@@ -57,6 +69,10 @@ class LoginWebAuthRequestHandler(
builder.withIdTokenVerificationIssuer(args["issuer"] as String)
}
+ if (args["nonce"] is String) {
+ builder.withNonce(args["nonce"] as String)
+ }
+
if (args["scheme"] is String) {
builder.withScheme(args["scheme"] as String)
}
@@ -65,18 +81,17 @@ class LoginWebAuthRequestHandler(
builder.withParameters(args["parameters"] as Map)
}
+ if (args["useEphemeralSession"] == true) {
+ builder.withEphemeralBrowsing()
+ }
+
if (args["useDPoP"] == true) {
- WebAuthProvider.useDPoP(context)
+ builder.useDPoP(context)
}
builder.start(context, object : Callback {
override fun onFailure(exception: AuthenticationException) {
- val details = mutableMapOf("_isRetryable" to exception.isNetworkError)
- exception.cause?.let {
- details["cause"] = it.toString()
- details["causeStackTrace"] = it.stackTraceToString()
- }
- result.error(exception.getCode(), exception.getDescription(), details)
+ exception.toWebAuthResult(result)
}
override fun onSuccess(credentials: Credentials) {
diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LogoutWebAuthRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LogoutWebAuthRequestHandler.kt
index c92fff691..92f2733bf 100644
--- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LogoutWebAuthRequestHandler.kt
+++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LogoutWebAuthRequestHandler.kt
@@ -5,6 +5,7 @@ import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.callback.Callback
import com.auth0.android.provider.WebAuthProvider
import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
+import com.auth0.auth0_flutter.toWebAuthResult
import com.auth0.auth0_flutter.utils.buildCustomTabsOptions
import io.flutter.plugin.common.MethodChannel
@@ -34,14 +35,20 @@ class LogoutWebAuthRequestHandler(private val builderResolver: (MethodCallReques
buildCustomTabsOptions(args)?.let { builder.withCustomTabsOptions(it) }
+ // The flags are forwarded as-is. TWA and Auth Tab are mutually
+ // exclusive and Auth0.Android resolves the conflict: if both are set,
+ // TWA wins.
+ val customTabsOptions = args["customTabsOptions"] as? Map<*, *>
+ if (customTabsOptions?.get("useTrustedWebActivity") == true) {
+ builder.withTrustedWebActivity()
+ }
+ if (customTabsOptions?.get("useAuthTab") == true) {
+ builder.withAuthTab()
+ }
+
builder.start(context, object : Callback {
override fun onFailure(exception: AuthenticationException) {
- val details = mutableMapOf("_isRetryable" to exception.isNetworkError)
- exception.cause?.let {
- details["cause"] = it.toString()
- details["causeStackTrace"] = it.stackTraceToString()
- }
- result.error(exception.getCode(), exception.getDescription(), details)
+ exception.toWebAuthResult(result)
}
override fun onSuccess(res: Void?) {
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 fe225affa..7b9a9af6b 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
@@ -2,8 +2,10 @@ package com.auth0.auth0_flutter
import android.app.Activity
import android.content.Context
+import androidx.lifecycle.Lifecycle
import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
+import io.flutter.embedding.engine.plugins.lifecycle.HiddenLifecycleReference
import io.flutter.plugin.common.MethodChannel
import org.junit.Test
import org.junit.runner.RunWith
@@ -95,6 +97,8 @@ class Auth0FlutterPluginTest {
val mockActivityBindings = mock()
val mockActivity = mock()
`when`(mockActivityBindings.activity).thenReturn(mockActivity)
+ `when`(mockActivityBindings.lifecycle)
+ .thenReturn(HiddenLifecycleReference(mock()))
plugin.onAttachedToActivity(mockActivityBindings)
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt
index 66d4e51f5..6428c20dc 100644
--- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt
@@ -69,6 +69,7 @@ class LoginWebAuthRequestHandlerTest {
verify(builder, never()).withIdTokenVerificationLeeway(any())
verify(builder, never()).withMaxAge(any())
verify(builder, never()).withIdTokenVerificationIssuer(any())
+ verify(builder, never()).withNonce(any())
verify(builder, never()).withScheme(any())
verify(builder, never()).withParameters(any())
}
@@ -275,6 +276,26 @@ class LoginWebAuthRequestHandlerTest {
}
}
+ @Test
+ fun `handler should set the nonce on the SDK when specified`() {
+ val args = hashMapOf(
+ "nonce" to "test-nonce"
+ )
+
+ runRequestHandler(args) { _, builder ->
+ verify(builder).withNonce("test-nonce")
+ }
+ }
+
+ @Test
+ fun `handler should not set the nonce on the SDK when not specified`() {
+ val args = hashMapOf()
+
+ runRequestHandler(args) { _, builder ->
+ verify(builder, never()).withNonce(anyOrNull())
+ }
+ }
+
@Test
fun `handler should set the scheme on the SDK when specified`() {
val args = hashMapOf(
@@ -317,6 +338,28 @@ class LoginWebAuthRequestHandlerTest {
}
}
+ @Test
+ fun `handler should enable ephemeral browsing when useEphemeralSession is true`() {
+ val args = hashMapOf(
+ "useEphemeralSession" to true
+ )
+
+ runRequestHandler(args) { _, builder ->
+ verify(builder).withEphemeralBrowsing()
+ }
+ }
+
+ @Test
+ fun `handler should not enable ephemeral browsing when useEphemeralSession is false or absent`() {
+ runRequestHandler(hashMapOf("useEphemeralSession" to false)) { _, builder ->
+ verify(builder, never()).withEphemeralBrowsing()
+ }
+
+ runRequestHandler(hashMapOf()) { _, builder ->
+ verify(builder, never()).withEphemeralBrowsing()
+ }
+ }
+
@Test
fun `returns the error when the builder fails`() {
val builder = mock()
@@ -338,6 +381,28 @@ class LoginWebAuthRequestHandlerTest {
verify(mockResult).error(eq("code"), eq("description"), eq(mapOf("_isRetryable" to false)))
}
+ @Test
+ fun `returns USER_CANCELLED when the user cancels`() {
+ val builder = mock()
+ val mockResult = mock()
+ val exception = mock()
+ whenever(exception.isCanceled).thenReturn(true)
+ whenever(exception.getDescription()).thenReturn("User cancelled")
+ whenever(exception.isNetworkError).thenReturn(false)
+
+ doAnswer { invocation ->
+ val cb = invocation.getArgument>(1)
+ cb.onFailure(exception)
+ }.`when`(builder).start(any(), any())
+
+ val handler = LoginWebAuthRequestHandler { _ -> builder }
+ val mockAccount = mock()
+ val mockRequest = MethodCallRequest(mockAccount, hashMapOf())
+ handler.handle(mock(), mockRequest, mockResult)
+
+ verify(mockResult).error(eq("USER_CANCELLED"), any(), any())
+ }
+
@Test
fun `returns cause and causeStackTrace in error details when cause is present`() {
val builder = mock()
@@ -492,4 +557,69 @@ class LoginWebAuthRequestHandlerTest {
}
}
+ @Test
+ fun `handler calls withTrustedWebActivity when useTrustedWebActivity is true`() {
+ val args = hashMapOf(
+ "customTabsOptions" to mapOf(
+ "useTrustedWebActivity" to true
+ )
+ )
+
+ runRequestHandler(args) { _, builder ->
+ verify(builder).withTrustedWebActivity()
+ verify(builder, never()).withAuthTab()
+ }
+ }
+
+ @Test
+ fun `handler calls withAuthTab when useAuthTab is true`() {
+ val args = hashMapOf(
+ "customTabsOptions" to mapOf(
+ "useAuthTab" to true
+ )
+ )
+
+ runRequestHandler(args) { _, builder ->
+ verify(builder).withAuthTab()
+ verify(builder, never()).withTrustedWebActivity()
+ }
+ }
+
+ @Test
+ fun `handler calls both withTrustedWebActivity and withAuthTab when both flags are true`() {
+ val args = hashMapOf(
+ "customTabsOptions" to mapOf(
+ "useTrustedWebActivity" to true,
+ "useAuthTab" to true
+ )
+ )
+
+ runRequestHandler(args) { _, builder ->
+ verify(builder).withTrustedWebActivity()
+ verify(builder).withAuthTab()
+ }
+ }
+
+ @Test
+ fun `handler does not call either when both flags are false`() {
+ val args = hashMapOf(
+ "customTabsOptions" to mapOf(
+ "useTrustedWebActivity" to false,
+ "useAuthTab" to false
+ )
+ )
+
+ runRequestHandler(args) { _, builder ->
+ verify(builder, never()).withTrustedWebActivity()
+ verify(builder, never()).withAuthTab()
+ }
+ }
+
+ @Test
+ fun `handler does not call withTrustedWebActivity or withAuthTab when customTabsOptions is absent`() {
+ runRequestHandler(hashMapOf()) { _, builder ->
+ verify(builder, never()).withTrustedWebActivity()
+ verify(builder, never()).withAuthTab()
+ }
+ }
}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt
index eca4d4fe9..a5b9aad37 100644
--- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt
+++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt
@@ -270,4 +270,70 @@ class LogoutWebAuthRequestHandlerTest {
verify(builder).withCustomTabsOptions(any())
}
}
+
+ @Test
+ fun `handler calls withTrustedWebActivity when useTrustedWebActivity is true`() {
+ val args = hashMapOf(
+ "customTabsOptions" to mapOf(
+ "useTrustedWebActivity" to true
+ )
+ )
+
+ runHandler(args) { _, builder ->
+ verify(builder).withTrustedWebActivity()
+ verify(builder, never()).withAuthTab()
+ }
+ }
+
+ @Test
+ fun `handler calls withAuthTab when useAuthTab is true`() {
+ val args = hashMapOf(
+ "customTabsOptions" to mapOf(
+ "useAuthTab" to true
+ )
+ )
+
+ runHandler(args) { _, builder ->
+ verify(builder).withAuthTab()
+ verify(builder, never()).withTrustedWebActivity()
+ }
+ }
+
+ @Test
+ fun `handler calls both withTrustedWebActivity and withAuthTab when both flags are true`() {
+ val args = hashMapOf(
+ "customTabsOptions" to mapOf(
+ "useTrustedWebActivity" to true,
+ "useAuthTab" to true
+ )
+ )
+
+ runHandler(args) { _, builder ->
+ verify(builder).withTrustedWebActivity()
+ verify(builder).withAuthTab()
+ }
+ }
+
+ @Test
+ fun `handler does not call either when both flags are false`() {
+ val args = hashMapOf(
+ "customTabsOptions" to mapOf(
+ "useTrustedWebActivity" to false,
+ "useAuthTab" to false
+ )
+ )
+
+ runHandler(args) { _, builder ->
+ verify(builder, never()).withTrustedWebActivity()
+ verify(builder, never()).withAuthTab()
+ }
+ }
+
+ @Test
+ fun `handler does not call withTrustedWebActivity or withAuthTab when customTabsOptions is absent`() {
+ runHandler(hashMapOf()) { _, builder ->
+ verify(builder, never()).withTrustedWebActivity()
+ verify(builder, never()).withAuthTab()
+ }
+ }
}
diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandlerTest.kt
deleted file mode 100644
index 38a6d8312..000000000
--- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandlerTest.kt
+++ /dev/null
@@ -1,167 +0,0 @@
-package com.auth0.auth0_flutter.request_handlers.api
-
-import com.auth0.android.Auth0
-import com.auth0.android.authentication.AuthenticationAPIClient
-import com.auth0.android.authentication.AuthenticationException
-import com.auth0.android.callback.Callback
-import com.auth0.android.request.AuthenticationRequest
-import com.auth0.android.result.Credentials
-import com.auth0.auth0_flutter.JwtTestUtils
-
-import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
-import io.flutter.plugin.common.MethodChannel.Result
-import org.hamcrest.CoreMatchers.equalTo
-import org.hamcrest.MatcherAssert.assertThat
-import org.junit.Assert
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.mockito.kotlin.*
-import org.robolectric.RobolectricTestRunner
-import java.text.SimpleDateFormat
-import java.util.*
-
-@RunWith(RobolectricTestRunner::class)
-class LoginWithOtpApiRequestHandlerTest {
- @Test
- fun `should throw when missing otp`() {
- val options = hashMapOf("mfaToken" to "test-mfaToken")
- val handler = LoginWithOtpApiRequestHandler()
- val mockApi = mock()
- val mockAccount = mock()
- val mockResult = mock()
- val request = MethodCallRequest(account = mockAccount, options)
-
- val exception = Assert.assertThrows(IllegalArgumentException::class.java) {
- handler.handle(
- mockApi,
- request,
- mockResult
- )
- }
-
- assertThat(
- exception.message,
- equalTo("Required property 'otp' is not provided.")
- )
- }
-
- @Test
- fun `should throw when missing mfaToken`() {
- val options = hashMapOf("otp" to "test-otp")
- val handler = LoginWithOtpApiRequestHandler()
- val mockApi = mock()
- val mockAccount = mock()
- val mockResult = mock()
- val request = MethodCallRequest(account = mockAccount, options)
-
- val exception = Assert.assertThrows(IllegalArgumentException::class.java) {
- handler.handle(
- mockApi,
- request,
- mockResult
- )
- }
-
- assertThat(
- exception.message,
- equalTo("Required property 'mfaToken' is not provided.")
- )
- }
-
- @Test
- fun `should call loginWithOTp with the correct parameters`() {
- val options = hashMapOf(
- "otp" to "test-otp",
- "mfaToken" to "test-mfaToken"
- )
- val handler = LoginWithOtpApiRequestHandler()
- val mockLoginBuilder = mock()
- val mockApi = mock()
- val mockAccount = mock()
- val mockResult = mock()
- val request = MethodCallRequest(account = mockAccount, options)
-
- doReturn(mockLoginBuilder).`when`(mockApi).loginWithOTP(any(), any())
-
- handler.handle(
- mockApi,
- request,
- mockResult
- )
-
- verify(mockApi).loginWithOTP("test-mfaToken", "test-otp")
- verify(mockLoginBuilder).start(any())
- }
-
- @Test
- fun `should call result error on failure`() {
- val options = hashMapOf(
- "otp" to "test-otp",
- "mfaToken" to "test-mfaToken"
- )
- val handler = LoginWithOtpApiRequestHandler()
- val mockLoginBuilder = mock()
- val mockApi = mock()
- val mockAccount = mock()
- val mockResult = mock()
- val request = MethodCallRequest(account = mockAccount, options)
- val exception =
- AuthenticationException(code = "test-code", description = "test-description")
-
- doReturn(mockLoginBuilder).`when`(mockApi).loginWithOTP(any(), any())
- doAnswer {
- val ob = it.getArgument>(0)
- ob.onFailure(exception)
- }.`when`(mockLoginBuilder).start(any())
-
- handler.handle(
- mockApi,
- request,
- mockResult
- )
-
- verify(mockResult).error(eq("test-code"), eq("test-description"), any())
- }
-
- @Test
- fun `should call result success on success`() {
- val options = hashMapOf(
- "otp" to "test-otp",
- "mfaToken" to "test-mfaToken"
- )
- val handler = LoginWithOtpApiRequestHandler()
- val mockLoginBuilder = mock()
- val mockApi = mock()
- val mockAccount = mock()
- val mockResult = mock()
- val request = MethodCallRequest(account = mockAccount, options)
- val idToken = JwtTestUtils.createJwt(claims = mapOf("name" to "John Doe"))
- val credentials = Credentials(idToken, "test", "", null, Date(), "scope1 scope2")
-
- whenever(mockApi.loginWithOTP(any(), any())).thenReturn(mockLoginBuilder)
- whenever(mockLoginBuilder.start(any())).thenAnswer {
- val callback = it.getArgument>(0)
- callback.onSuccess(credentials)
- }
-
- handler.handle(
- mockApi,
- request,
- mockResult
- )
-
- val captor = argumentCaptor