From 002b8d699868482954d21e74a60bd9bb41672692 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Wed, 25 Jun 2025 10:16:15 +0530 Subject: [PATCH 01/42] SDK-6070 flutter changes (cherry picked from commit 1cb05d808b39ef82b7cac6e042bc7cf30ba6e33a) --- .../auth0/auth0_flutter/Auth0FlutterPlugin.kt | 3 +- .../GetIdTokenContentRequestHandler.kt | 47 +++++++ .../lib/src/mobile/credentials_manager.dart | 8 +- .../credentials_manager_platform.dart | 7 + .../method_channel_credentials_manager.dart | 9 ++ .../lib/src/user_info.dart | 132 ++++++++++++++++++ 6 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt create mode 100644 auth0_flutter_platform_interface/lib/src/user_info.dart 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 6526066e6..96ba87a55 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 @@ -45,7 +45,8 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { GetCredentialsRequestHandler(), SaveCredentialsRequestHandler(), HasValidCredentialsRequestHandler(), - ClearCredentialsRequestHandler() + ClearCredentialsRequestHandler(), + GetIdTokenContentRequestHandler() )) override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt new file mode 100644 index 000000000..ca7dd6f44 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt @@ -0,0 +1,47 @@ +package com.auth0.auth0_flutter.request_handlers.credentials_manager + +import android.content.Context +import com.auth0.android.authentication.storage.CredentialsManagerException +import com.auth0.android.authentication.storage.SecureCredentialsManager +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel +import java.io.Serializable +import java.lang.Exception + + +class GetIdTokenContentRequestHandler: CredentialsManagerRequestHandler { + override val method: String = "credentialsManager#getIDTokenContent" + override fun handle( + credentialsManager: SecureCredentialsManager, + context: Context, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + result.success( + mapOf( + "id" to credentialsManager.userProfile?.getId(), + "name" to credentialsManager.userProfile?.name, + "nickname" to credentialsManager.userProfile?.nickname, + "pictureURL" to credentialsManager.userProfile?.pictureURL, + "email" to credentialsManager.userProfile?.email, + "isEmailVerified" to credentialsManager.userProfile?.isEmailVerified, + "familyName" to credentialsManager.userProfile?.familyName, + "createdAt" to credentialsManager.userProfile?.createdAt, + "identities" to credentialsManager.userProfile?.getIdentities()?.map { + mapOf( + "provider" to it.provider, + "id" to it.connection, + "isSocial" to it.isSocial, + "accessToken" to it.accessToken, + "accessTokenSecret" to it.accessTokenSecret, + "profileInfo" to it.getProfileInfo() + ) + }, + "extraInfo" to credentialsManager.userProfile?.getExtraInfo(), + "userMetadata" to credentialsManager.userProfile?.getUserMetadata(), + "appMetadata" to credentialsManager.userProfile?.getAppMetadata(), + "givenName" to credentialsManager.userProfile?.givenName + ) + ) + } +} diff --git a/auth0_flutter/lib/src/mobile/credentials_manager.dart b/auth0_flutter/lib/src/mobile/credentials_manager.dart index 3b46ed659..ed093403f 100644 --- a/auth0_flutter/lib/src/mobile/credentials_manager.dart +++ b/auth0_flutter/lib/src/mobile/credentials_manager.dart @@ -9,7 +9,9 @@ abstract class CredentialsManager { final Map parameters = const {}, }); - Future storeCredentials(final Credentials credentials); + Future getIDTokenContents(); + + Future storeCredentials(final Credentials credentials); Future hasValidCredentials({ final int minTtl = 0, @@ -55,6 +57,10 @@ class DefaultCredentialsManager extends CredentialsManager { parameters: parameters, ))); + @override + Future getIDTokenContents() => + CredentialsManagerPlatform.instance.getIDTokenContents(_createApiRequest(null)); + /// Stores the given credentials in the storage. Must have an `access_token` /// or `id_token` and a `expires_in` value. @override diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart index 6a891e58b..e4de4d382 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart @@ -1,6 +1,8 @@ // coverage:ignore-file import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import '../../auth0_flutter_platform_interface.dart'; +import '../user_info.dart'; import '../credentials.dart'; import '../request/request.dart'; import 'method_channel_credentials_manager.dart'; @@ -36,6 +38,11 @@ abstract class CredentialsManagerPlatform extends PlatformInterface { throw UnimplementedError('getCredentials() has not been implemented'); } + /// Retrieves the credentials from the native storage. + Future getIDTokenContents(final CredentialsManagerRequest request) { + throw UnimplementedError('getIDTokenContents() has not been implemented'); + } + /// Removes the credentials from the native storage if present. Future clearCredentials(final CredentialsManagerRequest request) { throw UnimplementedError('clearCredentials() has not been implemented'); diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart index 77cd4dfeb..50c2782a8 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart @@ -1,5 +1,6 @@ import 'package:flutter/services.dart'; +import '../user_info.dart'; import '../credentials.dart'; import '../request/request.dart'; import '../request/request_options.dart'; @@ -15,6 +16,8 @@ const String credentialsManagerSaveCredentialsMethod = 'credentialsManager#saveCredentials'; const String credentialsManagerGetCredentialsMethod = 'credentialsManager#getCredentials'; +const String credentialsManagerGetUserProfileMethod = +'credentialsManager#getIDTokenContent'; const String credentialsManagerClearCredentialsMethod = 'credentialsManager#clearCredentials'; const String credentialsManagerHasValidCredentialsMethod = @@ -48,6 +51,12 @@ class MethodChannelCredentialsManager extends CredentialsManagerPlatform { return result ?? true; } + @override + Future getIDTokenContents(final CredentialsManagerRequest request) async { + final Map result = await _invokeMapRequest(method: credentialsManagerGetUserProfileMethod, request: request); + return UserInfo.fromJson(result); + } + /// Removes the credentials from the native storage if present. /// /// Uses the [MethodChannel] to communicate with the Native platforms. diff --git a/auth0_flutter_platform_interface/lib/src/user_info.dart b/auth0_flutter_platform_interface/lib/src/user_info.dart new file mode 100644 index 000000000..f2f2d2129 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/user_info.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:ffi'; // For jsonEncode, jsonDecode (if you implement them) + +// Assuming UserIdentity is also a class you'll define in Dart +// If UserIdentity needs to be serializable, it should also have toJson/fromJson. +class UserIdentity { + // Example properties, adjust based on your actual UserIdentity structure + final String id; + final String connection; + final String provider; + final bool? isSocial; + final String? accessToken; + final String? accessTokenSecret; + final Map? _profileInfo; + + UserIdentity({ + required this.id, + required this.connection, + required this.provider, + this.isSocial, + this.accessToken, + this.accessTokenSecret, + Map? profileInfo}) + : _profileInfo = profileInfo ; + factory UserIdentity.fromJson(final Map json) + => UserIdentity( + connection: json['connection'] as String, + id: json['id'] as String, + isSocial: json['isSocial'] as bool?, + provider: json['provider'] as String, + accessToken: json['accessToken'] as String?, + accessTokenSecret: json['accessTokenSecret'] as String?, + profileInfo: json['profileInfo'] as Map? + ); +} + + +class UserInfo { + // Private fields (using _ prefix) + final String? _id; + final List? _identities; + final Map? _extraInfo; // Using dynamic for Any type + final Map? _userMetadata; + final Map? _appMetadata; + + // Public fields (no special prefix, directly accessible) + final String? name; + final String? nickname; + final String? pictureURL; + final String? email; + final bool? isEmailVerified; + final String? familyName; + final DateTime? createdAt; // Using DateTime for Date + final String? givenName; + + + UserInfo({ + final String? id, // Private fields can be passed through constructor + this.name, + this.nickname, + this.pictureURL, + this.email, + this.isEmailVerified, + this.familyName, + this.createdAt, + final List? identities, + final Map? extraInfo, + final Map? userMetadata, + final Map? appMetadata, + this.givenName, + }) + : _id = id, + _identities = identities, + _extraInfo = extraInfo, + _userMetadata = userMetadata, + _appMetadata = appMetadata; + + /// Getter for the unique Identifier of the user. If this represents a Full User Profile (Management API) the 'id' field will be returned. + /// If the value is not present, it will be considered a User Information and the id will be obtained from the 'sub' claim. + String? getId() { + if (_id != null) { + return _id; + } + // Using null-aware operator and type checking for 'sub' + return (_extraInfo != null && _extraInfo.containsKey('sub')) + ? _extraInfo['sub'] as String? + : null; + } + + Map getUserMetadata() => + _userMetadata ?? {}; // Return empty map if null + + Map getAppMetadata() => + _appMetadata ?? {}; // Return empty map if null + + List getIdentities() => + _identities ?? []; // Return empty list if null + + /// Returns extra information of the profile that is not part of the normalized profile + /// + /// @return a map with user's extra information found in the profile + Map getExtraInfo() => + // Assuming _extraInfo is already a Map + // If it needed a .toMap() like Kotlin, you'd implement conversion here. + _extraInfo ?? {}; // Return empty map if null + + // --- Convenience methods for serialization and immutability --- + + // Factory constructor for creating a UserProfile from a JSON map + factory UserInfo.fromJson(final Map json) => + UserInfo( + id: json['id'] as String?, + name: json['name'] as String?, + nickname: json['nickname'] as String?, + pictureURL: json['pictureURL'] as String?, + email: json['email'] as String?, + isEmailVerified: json['isEmailVerified'] as bool?, + familyName: json['familyName'] as String?, + // Handle date parsing + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + // Handle list of UserIdentity + identities: (json['identities'] as List?) + ?.map((e) => UserIdentity.fromJson(e as Map)) + .toList(), + extraInfo: json['extraInfo'] as Map?, + userMetadata: json['userMetadata'] as Map?, + appMetadata: json['appMetadata'] as Map?, + givenName: json['givenName'] as String?, + ); +} From ec84b5983c93098f62b935496966fc099210da95 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Wed, 25 Jun 2025 21:35:47 +0530 Subject: [PATCH 02/42] method channel for accessing user info (cherry picked from commit 1ebc64c4992680006d6aabe45b7e4428b255a89b) --- .../GetIdTokenContentRequestHandler.kt | 2 +- .../CredentialsManagerHandler.swift | 10 +- ...dentialsManagerUserInfoMethodHandler.swift | 16 +++ ...dentialsManagerUserInfoMethodHandler.swift | 16 +++ .../lib/auth0_flutter_platform_interface.dart | 1 + .../credentials_manager_platform.dart | 5 +- .../method_channel_credentials_manager.dart | 5 +- .../lib/src/user_info.dart | 132 ------------------ .../lib/src/user_info_credentials.dart | 111 +++++++++++++++ 9 files changed, 154 insertions(+), 144 deletions(-) create mode 100644 auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift create mode 100644 auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift delete mode 100644 auth0_flutter_platform_interface/lib/src/user_info.dart create mode 100644 auth0_flutter_platform_interface/lib/src/user_info_credentials.dart diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt index ca7dd6f44..729d53e80 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt @@ -10,7 +10,7 @@ import java.lang.Exception class GetIdTokenContentRequestHandler: CredentialsManagerRequestHandler { - override val method: String = "credentialsManager#getIDTokenContent" + override val method: String = "credentialsManager#getUserInfo" override fun handle( credentialsManager: SecureCredentialsManager, context: Context, diff --git a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift index 17022669c..de4201566 100644 --- a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift +++ b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift @@ -22,6 +22,7 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { case hasValid = "credentialsManager#hasValidCredentials" case get = "credentialsManager#getCredentials" case clear = "credentialsManager#clearCredentials" + case userInfo = "credentialsManager#getUserInfo" } private static let channelName = "auth0.com/auth0_flutter/credentials_manager" @@ -40,7 +41,7 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(handler, channel: channel) } - + func createCredentialManager(_ apiClient: Authentication, _ arguments: [String: Any]) -> CredentialsManager { if let configuration = arguments["credentialsManagerConfiguration"] as? [String: Any], let iosConfiguration = configuration["ios"] as? [String: String] { @@ -65,10 +66,10 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { client.using(inLibrary: userAgent.name, version: userAgent.version) return client } - + lazy var credentialsManagerProvider: CredentialsManagerProvider = { apiClient, arguments in - + var instance = CredentialsManagerHandler.credentialsManager ?? self.createCredentialManager(apiClient,arguments) @@ -89,6 +90,7 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { case .hasValid: return CredentialsManagerHasValidMethodHandler(credentialsManager: credentialsManager) case .get: return CredentialsManagerGetMethodHandler(credentialsManager: credentialsManager) case .clear: return CredentialsManagerClearMethodHandler(credentialsManager: credentialsManager) + case .userInfo: return CredentialsManagerUserInfoMethodHandler(credentialsManager: credentialsManager) } } @@ -113,5 +115,5 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { let methodHandler = methodHandlerProvider(method, credentialsManager) methodHandler.handle(with: arguments, callback: result) } - + } diff --git a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift new file mode 100644 index 000000000..aa18955c8 --- /dev/null +++ b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift @@ -0,0 +1,16 @@ + +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct CredentialsManagerUserInfoMethodHandler: MethodHandler { + let credentialsManager: CredentialsManager + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + callback(credentialsManager.user) + } +} diff --git a/auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift b/auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift new file mode 100644 index 000000000..aa18955c8 --- /dev/null +++ b/auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift @@ -0,0 +1,16 @@ + +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct CredentialsManagerUserInfoMethodHandler: MethodHandler { + let credentialsManager: CredentialsManager + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + callback(credentialsManager.user) + } +} diff --git a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart index adccf3e86..1f40b864d 100644 --- a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart +++ b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart @@ -25,6 +25,7 @@ export 'src/credentials-manager/options/has_valid_credentials_options.dart'; export 'src/credentials-manager/options/local_authentication.dart'; export 'src/credentials-manager/options/save_credentials_options.dart'; export 'src/credentials.dart'; +export 'src/user_info_credentials.dart'; export 'src/database_user.dart'; export 'src/id_token_validation_config.dart'; export 'src/login_options.dart'; diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart index e4de4d382..3cd2ef351 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart @@ -1,10 +1,7 @@ // coverage:ignore-file import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import '../../auth0_flutter_platform_interface.dart'; -import '../user_info.dart'; -import '../credentials.dart'; -import '../request/request.dart'; +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; import 'method_channel_credentials_manager.dart'; import 'options/get_credentials_options.dart'; import 'options/has_valid_credentials_options.dart'; diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart index 50c2782a8..9118084dd 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart @@ -1,7 +1,6 @@ import 'package:flutter/services.dart'; -import '../user_info.dart'; -import '../credentials.dart'; +import '../../auth0_flutter_platform_interface.dart'; import '../request/request.dart'; import '../request/request_options.dart'; import 'credentials_manager_exception.dart'; @@ -17,7 +16,7 @@ const String credentialsManagerSaveCredentialsMethod = const String credentialsManagerGetCredentialsMethod = 'credentialsManager#getCredentials'; const String credentialsManagerGetUserProfileMethod = -'credentialsManager#getIDTokenContent'; +'credentialsManager#getUserInfo'; const String credentialsManagerClearCredentialsMethod = 'credentialsManager#clearCredentials'; const String credentialsManagerHasValidCredentialsMethod = diff --git a/auth0_flutter_platform_interface/lib/src/user_info.dart b/auth0_flutter_platform_interface/lib/src/user_info.dart deleted file mode 100644 index f2f2d2129..000000000 --- a/auth0_flutter_platform_interface/lib/src/user_info.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'dart:convert'; -import 'dart:ffi'; // For jsonEncode, jsonDecode (if you implement them) - -// Assuming UserIdentity is also a class you'll define in Dart -// If UserIdentity needs to be serializable, it should also have toJson/fromJson. -class UserIdentity { - // Example properties, adjust based on your actual UserIdentity structure - final String id; - final String connection; - final String provider; - final bool? isSocial; - final String? accessToken; - final String? accessTokenSecret; - final Map? _profileInfo; - - UserIdentity({ - required this.id, - required this.connection, - required this.provider, - this.isSocial, - this.accessToken, - this.accessTokenSecret, - Map? profileInfo}) - : _profileInfo = profileInfo ; - factory UserIdentity.fromJson(final Map json) - => UserIdentity( - connection: json['connection'] as String, - id: json['id'] as String, - isSocial: json['isSocial'] as bool?, - provider: json['provider'] as String, - accessToken: json['accessToken'] as String?, - accessTokenSecret: json['accessTokenSecret'] as String?, - profileInfo: json['profileInfo'] as Map? - ); -} - - -class UserInfo { - // Private fields (using _ prefix) - final String? _id; - final List? _identities; - final Map? _extraInfo; // Using dynamic for Any type - final Map? _userMetadata; - final Map? _appMetadata; - - // Public fields (no special prefix, directly accessible) - final String? name; - final String? nickname; - final String? pictureURL; - final String? email; - final bool? isEmailVerified; - final String? familyName; - final DateTime? createdAt; // Using DateTime for Date - final String? givenName; - - - UserInfo({ - final String? id, // Private fields can be passed through constructor - this.name, - this.nickname, - this.pictureURL, - this.email, - this.isEmailVerified, - this.familyName, - this.createdAt, - final List? identities, - final Map? extraInfo, - final Map? userMetadata, - final Map? appMetadata, - this.givenName, - }) - : _id = id, - _identities = identities, - _extraInfo = extraInfo, - _userMetadata = userMetadata, - _appMetadata = appMetadata; - - /// Getter for the unique Identifier of the user. If this represents a Full User Profile (Management API) the 'id' field will be returned. - /// If the value is not present, it will be considered a User Information and the id will be obtained from the 'sub' claim. - String? getId() { - if (_id != null) { - return _id; - } - // Using null-aware operator and type checking for 'sub' - return (_extraInfo != null && _extraInfo.containsKey('sub')) - ? _extraInfo['sub'] as String? - : null; - } - - Map getUserMetadata() => - _userMetadata ?? {}; // Return empty map if null - - Map getAppMetadata() => - _appMetadata ?? {}; // Return empty map if null - - List getIdentities() => - _identities ?? []; // Return empty list if null - - /// Returns extra information of the profile that is not part of the normalized profile - /// - /// @return a map with user's extra information found in the profile - Map getExtraInfo() => - // Assuming _extraInfo is already a Map - // If it needed a .toMap() like Kotlin, you'd implement conversion here. - _extraInfo ?? {}; // Return empty map if null - - // --- Convenience methods for serialization and immutability --- - - // Factory constructor for creating a UserProfile from a JSON map - factory UserInfo.fromJson(final Map json) => - UserInfo( - id: json['id'] as String?, - name: json['name'] as String?, - nickname: json['nickname'] as String?, - pictureURL: json['pictureURL'] as String?, - email: json['email'] as String?, - isEmailVerified: json['isEmailVerified'] as bool?, - familyName: json['familyName'] as String?, - // Handle date parsing - createdAt: json['createdAt'] != null - ? DateTime.parse(json['createdAt'] as String) - : null, - // Handle list of UserIdentity - identities: (json['identities'] as List?) - ?.map((e) => UserIdentity.fromJson(e as Map)) - .toList(), - extraInfo: json['extraInfo'] as Map?, - userMetadata: json['userMetadata'] as Map?, - appMetadata: json['appMetadata'] as Map?, - givenName: json['givenName'] as String?, - ); -} diff --git a/auth0_flutter_platform_interface/lib/src/user_info_credentials.dart b/auth0_flutter_platform_interface/lib/src/user_info_credentials.dart new file mode 100644 index 000000000..417c7a836 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/user_info_credentials.dart @@ -0,0 +1,111 @@ +class UserIdentity { +final String id; +final String connection; +final String provider; +final bool? isSocial; +final String? accessToken; +final String? accessTokenSecret; +final Map? _profileInfo; + +UserIdentity({ +required this.id, +required this.connection, +required this.provider, +this.isSocial, +this.accessToken, +this.accessTokenSecret, +Map? profileInfo}) + : _profileInfo = profileInfo ; +factory UserIdentity.fromJson(final Map json) +=> UserIdentity( +connection: json['connection'] as String, +id: json['id'] as String, +isSocial: json['isSocial'] as bool?, +provider: json['provider'] as String, +accessToken: json['accessToken'] as String?, +accessTokenSecret: json['accessTokenSecret'] as String?, +profileInfo: json['profileInfo'] as Map? +); +} + +class UserInfo { +final String? _id; +final List? _identities; +final Map? _extraInfo; +final Map? _userMetadata; +final Map? _appMetadata; + +final String? name; +final String? nickname; +final String? pictureURL; +final String? email; +final bool? isEmailVerified; +final String? familyName; +final DateTime? createdAt; +final String? givenName; + + +UserInfo({ +final String? id, +this.name, +this.nickname, +this.pictureURL, +this.email, +this.isEmailVerified, +this.familyName, +this.createdAt, +final List? identities, +final Map? extraInfo, +final Map? userMetadata, +final Map? appMetadata, +this.givenName, +}) + : _id = id, +_identities = identities, +_extraInfo = extraInfo, +_userMetadata = userMetadata, +_appMetadata = appMetadata; + +String? getId() { +if (_id != null) { +return _id; +} +return (_extraInfo != null && _extraInfo.containsKey('sub')) +? _extraInfo['sub'] as String? + : null; +} + +Map getUserMetadata() => +_userMetadata ?? {}; +Map getAppMetadata() => +_appMetadata ?? {}; + +List getIdentities() => +_identities ?? []; + +Map getExtraInfo() => +_extraInfo ?? {}; + +factory UserInfo.fromJson(final Map json) => +UserInfo( +id: json['id'] as String?, +name: json['name'] as String?, +nickname: json['nickname'] as String?, +pictureURL: json['pictureURL'] as String?, +email: json['email'] as String?, +isEmailVerified: json['isEmailVerified'] as bool?, +familyName: json['familyName'] as String?, +// Handle date parsing +createdAt: json['createdAt'] != null +? DateTime.parse(json['createdAt'] as String) + : null, +// Handle list of UserIdentity +identities: (json['identities'] as List?) + ?.map((e) => UserIdentity.fromJson(e as Map)) + .toList(), +extraInfo: Map.from(json['extraInfo'] as Map), +userMetadata: Map.from(json['userMetadata'] as Map), +appMetadata: Map.from(json['appMetadata'] as Map), +givenName: json['givenName'] as String?, +); +} From e4acf09fa66d6950b1d68a6ebe81f0062f9f2f86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:36:54 +0530 Subject: [PATCH 03/42] build(deps): bump ruby/setup-ruby from 1.273.0 to 1.274.0 in /.github/actions/setup-darwin (#709) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/setup-darwin/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index 5bf78eec5..b0dbd2a47 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # pin@v1.273.0 + uses: ruby/setup-ruby@ed55d55e820a01da7d3e4863a8c51a61d73c3228 # pin@v1.274.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true From bf858a5b8b32297d9a74d3d330892ddb2654b397 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 10:17:44 +0530 Subject: [PATCH 04/42] build(deps): bump ruby/setup-ruby from 1.274.0 to 1.275.0 in /.github/actions/setup-darwin (#710) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/setup-darwin/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index b0dbd2a47..7f6e1663c 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@ed55d55e820a01da7d3e4863a8c51a61d73c3228 # pin@v1.274.0 + uses: ruby/setup-ruby@d354de180d0c9e813cfddfcbdc079945d4be589b # pin@v1.275.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true From 1970f428e3cc8c86ec4621c0075c982fff13183a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 20:14:06 +0530 Subject: [PATCH 05/42] build(deps): bump ruby/setup-ruby from 1.275.0 to 1.278.0 in /.github/actions/setup-darwin (#713) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/setup-darwin/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index 7f6e1663c..b688a9c6b 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@d354de180d0c9e813cfddfcbdc079945d4be589b # pin@v1.275.0 + uses: ruby/setup-ruby@4c24fa5ec04b2e79eb40571b1cee2a0d2b705771 # pin@v1.278.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true From e7c7db675668a6d77da8bdc2aa89d2d9c527f3d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:00:34 +0530 Subject: [PATCH 06/42] build(deps): bump ruby/setup-ruby from 1.278.0 to 1.279.0 in /.github/actions/setup-darwin (#716) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/setup-darwin/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index b688a9c6b..feb486e15 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@4c24fa5ec04b2e79eb40571b1cee2a0d2b705771 # pin@v1.278.0 + uses: ruby/setup-ruby@b90be12699fdfcbee4440c2bba85f6f460446bb0 # pin@v1.279.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true From d2db273192103a41048b1eda1dddc78dbc9f5375 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:45:04 +0530 Subject: [PATCH 07/42] build(deps): bump ruby/setup-ruby from 1.279.0 to 1.281.0 in /.github/actions/setup-darwin (#719) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/setup-darwin/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index feb486e15..12012815e 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@b90be12699fdfcbee4440c2bba85f6f460446bb0 # pin@v1.279.0 + uses: ruby/setup-ruby@675dd7ba1b06c8786a1480d89c384f5620a42647 # pin@v1.281.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true From 8238bfdb1ccead0b296ee5f444adfa78b9697e37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:44:22 +0000 Subject: [PATCH 08/42] build(deps): bump ruby/setup-ruby in /.github/actions/setup-darwin Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.281.0 to 1.282.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/675dd7ba1b06c8786a1480d89c384f5620a42647...4fc31e1c823882afd7ef55985266a526c589de90) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-version: 1.282.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/actions/setup-darwin/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index 12012815e..5e2b1959f 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@675dd7ba1b06c8786a1480d89c384f5620a42647 # pin@v1.281.0 + uses: ruby/setup-ruby@4fc31e1c823882afd7ef55985266a526c589de90 # pin@v1.282.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true From ae53ba3d25abc64d8eed2a6df01529f22f73c2b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:36:47 +0530 Subject: [PATCH 09/42] build(deps): bump ruby/setup-ruby from 1.282.0 to 1.283.0 in /.github/actions/setup-darwin (#722) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/setup-darwin/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index 5e2b1959f..0e4082267 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@4fc31e1c823882afd7ef55985266a526c589de90 # pin@v1.282.0 + uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # pin@v1.283.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true From 738539419bb4f5ce45f67aa11a2e51fa921fe94a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:39:56 +0530 Subject: [PATCH 10/42] build(deps): bump ruby/setup-ruby from 1.283.0 to 1.285.0 in /.github/actions/setup-darwin (#725) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/setup-darwin/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index 0e4082267..07014a980 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # pin@v1.283.0 + uses: ruby/setup-ruby@e69dcf3ded5967f30d7ef595704928d91cdae930 # pin@v1.285.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true From 5a97d0abe9ff5dc56d3cf30046f10f6ee2dfa085 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:08:12 +0530 Subject: [PATCH 11/42] feat: Add allowedBrowsers parameter to logout API [SDK-724] (#726) --- .../web_auth/LogoutWebAuthRequestHandler.kt | 24 ++++++++-- .../LogoutWebAuthRequestHandlerTest.kt | 35 ++++++++++++++ .../example/android/app/build.gradle | 4 +- .../src/main/res/values/strings.xml.example | 4 +- .../lib/src/mobile/web_authentication.dart | 12 ++++- .../test/mobile/web_authentication_test.dart | 48 +++++++++++++++++++ .../src/web-auth/web_auth_logout_options.dart | 7 ++- ...d_channel_auth0_flutter_web_auth_test.dart | 22 ++++++++- 8 files changed, 145 insertions(+), 11 deletions(-) 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 b7b9df27e..3e8f90a61 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 @@ -3,14 +3,21 @@ package com.auth0.auth0_flutter.request_handlers.web_auth import android.content.Context import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.provider.BrowserPicker +import com.auth0.android.provider.CustomTabsOptions import com.auth0.android.provider.WebAuthProvider import com.auth0.auth0_flutter.request_handlers.MethodCallRequest import io.flutter.plugin.common.MethodChannel -class LogoutWebAuthRequestHandler(private val builderResolver: (MethodCallRequest) -> WebAuthProvider.LogoutBuilder) : WebAuthRequestHandler { +class LogoutWebAuthRequestHandler(private val builderResolver: (MethodCallRequest) -> WebAuthProvider.LogoutBuilder) : + WebAuthRequestHandler { override val method: String = "webAuth#logout" - override fun handle(context: Context, request: MethodCallRequest, result: MethodChannel.Result) { + override fun handle( + context: Context, + request: MethodCallRequest, + result: MethodChannel.Result + ) { val builder = builderResolver(request) val args = request.data @@ -26,7 +33,18 @@ class LogoutWebAuthRequestHandler(private val builderResolver: (MethodCallReques builder.withFederated() } - builder.start(context, object: Callback { + val allowedBrowsers = + (args["allowedBrowsers"] as? List<*>)?.filterIsInstance().orEmpty() + if (allowedBrowsers.isNotEmpty()) { + builder.withCustomTabsOptions( + CustomTabsOptions.newBuilder().withBrowserPicker( + BrowserPicker.newBuilder() + .withAllowedPackages(allowedBrowsers).build() + ).build() + ) + } + + builder.start(context, object : Callback { override fun onFailure(exception: AuthenticationException) { result.error(exception.getCode(), exception.getDescription(), exception) } 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 d3c0f445e..e0efb40a0 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 @@ -3,6 +3,8 @@ package com.auth0.auth0_flutter import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.provider.BrowserPicker +import com.auth0.android.provider.CustomTabsOptions import com.auth0.android.provider.WebAuthProvider import com.auth0.auth0_flutter.request_handlers.MethodCallRequest import com.auth0.auth0_flutter.request_handlers.web_auth.LogoutWebAuthRequestHandler @@ -138,4 +140,37 @@ class LogoutWebAuthRequestHandlerTest { verify(mockResult).success(null) } + + @Test + fun `handler should add allowedPackages when given a non-empty array`() { + val args = hashMapOf( + "allowedBrowsers" to listOf("com.android.chrome", "org.mozilla.firefox") + ) + + runHandler(args) { _, builder -> + verify(builder).withCustomTabsOptions(argThat { options -> + options != null + }) + } + } + + @Test + fun `handler should not add an empty allowedPackages when given an empty array`() { + val args = hashMapOf( + "allowedBrowsers" to listOf() + ) + + runHandler(args) { _, builder -> + verify(builder, never()).withCustomTabsOptions(any()) + } + } + + @Test + fun `handler should not add allowedPackages when not specified`() { + val args = hashMapOf() + + runHandler(args) { _, builder -> + verify(builder, never()).withCustomTabsOptions(any()) + } + } } diff --git a/auth0_flutter/example/android/app/build.gradle b/auth0_flutter/example/android/app/build.gradle index bdf518cd8..040fc730b 100644 --- a/auth0_flutter/example/android/app/build.gradle +++ b/auth0_flutter/example/android/app/build.gradle @@ -52,8 +52,8 @@ android { versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - manifestPlaceholders += [auth0Domain: "YOUR_DOMAIN", - auth0Scheme: "YOUR_SCHEME"] + manifestPlaceholders += [auth0Domain: "@string/com_auth0_domain", + auth0Scheme: "@string/com_auth0_scheme"] testOptions { unitTests.returnDefaultValues = true diff --git a/auth0_flutter/example/android/app/src/main/res/values/strings.xml.example b/auth0_flutter/example/android/app/src/main/res/values/strings.xml.example index ebf5164fd..ccd4e6838 100644 --- a/auth0_flutter/example/android/app/src/main/res/values/strings.xml.example +++ b/auth0_flutter/example/android/app/src/main/res/values/strings.xml.example @@ -1,5 +1,5 @@ - YOUR_AUTH0_DOMAIN - demo + {DOMAIN} + {SCHEME} diff --git a/auth0_flutter/lib/src/mobile/web_authentication.dart b/auth0_flutter/lib/src/mobile/web_authentication.dart index 349ccd6d2..d5f68133a 100644 --- a/auth0_flutter/lib/src/mobile/web_authentication.dart +++ b/auth0_flutter/lib/src/mobile/web_authentication.dart @@ -135,16 +135,26 @@ class WebAuthentication { /// versions of iOS and macOS. Requires an Associated Domain configured with /// the `webcredentials` service type, set to your Auth0 domain –or custom /// domain, if you have one. + /// * (android only): [allowedBrowsers] Defines an allowlist of browser + /// packages + /// When the user's default browser is in the allowlist, it uses the default + /// browser + /// When the user's default browser is not in the allowlist, but the user has + /// another allowed browser installed, the allowed browser is used instead + /// When the user's default browser is not in the allowlist, and the user has + /// no other allowed browser installed, an error is returned Future logout( {final String? returnTo, final bool useHTTPS = false, + final List allowedBrowsers = const [], final bool federated = false}) async { await Auth0FlutterWebAuthPlatform.instance.logout(_createWebAuthRequest( WebAuthLogoutOptions( returnTo: returnTo, scheme: _scheme, useHTTPS: useHTTPS, - federated: federated), + federated: federated, + allowedBrowsers: allowedBrowsers), )); await _credentialsManager?.clearCredentials(); } diff --git a/auth0_flutter/test/mobile/web_authentication_test.dart b/auth0_flutter/test/mobile/web_authentication_test.dart index dd8f48854..c15db2f55 100644 --- a/auth0_flutter/test/mobile/web_authentication_test.dart +++ b/auth0_flutter/test/mobile/web_authentication_test.dart @@ -283,6 +283,54 @@ void main() { verify(mockCm.clearCredentials()).called(1); }); + + test('passes allowedBrowsers to the platform when specified', () async { + when(mockedPlatform.logout(any)).thenAnswer((final _) async => {}); + when(mockedCMPlatform.clearCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId').webAuthentication().logout( + allowedBrowsers: ['com.android.chrome', 'org.mozilla.firefox']); + + final verificationResult = verify(mockedPlatform.logout(captureAny)) + .captured + .single as WebAuthRequest; + expect(verificationResult.options.allowedBrowsers, + ['com.android.chrome', 'org.mozilla.firefox']); + }); + + test('defaults allowedBrowsers to empty list when not specified', () async { + when(mockedPlatform.logout(any)).thenAnswer((final _) async => {}); + when(mockedCMPlatform.clearCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId').webAuthentication().logout(); + + final verificationResult = verify(mockedPlatform.logout(captureAny)) + .captured + .single as WebAuthRequest; + expect(verificationResult.options.allowedBrowsers, isEmpty); + }); + + test('passes allowedBrowsers with other logout parameters', () async { + when(mockedPlatform.logout(any)).thenAnswer((final _) async => {}); + when(mockedCMPlatform.clearCredentials(any)) + .thenAnswer((final _) async => true); + + await Auth0('test-domain', 'test-clientId').webAuthentication().logout( + useHTTPS: true, + returnTo: 'https://example.com/logout', + federated: true, + allowedBrowsers: ['com.android.chrome']); + + final verificationResult = verify(mockedPlatform.logout(captureAny)) + .captured + .single as WebAuthRequest; + expect(verificationResult.options.useHTTPS, true); + expect(verificationResult.options.returnTo, 'https://example.com/logout'); + expect(verificationResult.options.federated, true); + expect(verificationResult.options.allowedBrowsers, ['com.android.chrome']); + }); }); group('DPoP Authentication', () { diff --git a/auth0_flutter_platform_interface/lib/src/web-auth/web_auth_logout_options.dart b/auth0_flutter_platform_interface/lib/src/web-auth/web_auth_logout_options.dart index 7d1772f14..1455ef6e7 100644 --- a/auth0_flutter_platform_interface/lib/src/web-auth/web_auth_logout_options.dart +++ b/auth0_flutter_platform_interface/lib/src/web-auth/web_auth_logout_options.dart @@ -5,18 +5,21 @@ class WebAuthLogoutOptions implements RequestOptions { final String? returnTo; final String? scheme; final bool federated; + final List allowedBrowsers; WebAuthLogoutOptions( {this.useHTTPS = false, this.returnTo, this.scheme, - this.federated = false}); + this.federated = false, + this.allowedBrowsers = const []}); @override Map toMap() => { 'useHTTPS': useHTTPS, 'returnTo': returnTo, 'scheme': scheme, - 'federated': federated + 'federated': federated, + 'allowedBrowsers': allowedBrowsers }; } diff --git a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_web_auth_test.dart b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_web_auth_test.dart index 8fa5c5394..0d3774569 100644 --- a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_web_auth_test.dart +++ b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_web_auth_test.dart @@ -282,7 +282,8 @@ void main() { options: WebAuthLogoutOptions( useHTTPS: true, returnTo: 'http://localhost:1234', - scheme: 'test-scheme'))); + scheme: 'test-scheme', + allowedBrowsers: ['com.android.chrome', 'org.mozilla.firefox']))); final verificationResult = verify(mocked.methodCallHandler(captureAny)).captured.single; @@ -295,6 +296,24 @@ void main() { expect(verificationResult.arguments['useHTTPS'], true); expect(verificationResult.arguments['returnTo'], 'http://localhost:1234'); expect(verificationResult.arguments['scheme'], 'test-scheme'); + expect(verificationResult.arguments['allowedBrowsers'], + ['com.android.chrome', 'org.mozilla.firefox']); + }); + + test('correctly maps allowedBrowsers when specified', () async { + when(mocked.methodCallHandler(any)).thenAnswer((final _) async => null); + + await MethodChannelAuth0FlutterWebAuth().logout( + WebAuthRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: WebAuthLogoutOptions( + allowedBrowsers: ['com.android.chrome']))); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect( + verificationResult.arguments['allowedBrowsers'], ['com.android.chrome']); }); test( @@ -313,6 +332,7 @@ void main() { expect(verificationResult.arguments['useHTTPS'], false); expect(verificationResult.arguments['returnTo'], isNull); expect(verificationResult.arguments['scheme'], isNull); + expect(verificationResult.arguments['allowedBrowsers'], isEmpty); }); test( From 7df2b958043e9a44f5a5208df5c449854a094acd Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Wed, 14 Jan 2026 09:10:55 +0530 Subject: [PATCH 12/42] feat: add custom token exchange support across all platforms --- auth0_flutter/EXAMPLES.md | 46 +++ auth0_flutter/android/build.gradle | 2 +- .../auth0/auth0_flutter/Auth0FlutterPlugin.kt | 1 + .../CustomTokenExchangeApiRequestHandler.kt | 72 ++++ ...ustomTokenExchangeApiRequestHandlerTest.kt | 344 ++++++++++++++++++ ...hAPICustomTokenExchangeMethodHandler.swift | 53 +++ .../Classes/AuthAPI/AuthAPIHandler.swift | 2 + auth0_flutter/darwin/auth0_flutter.podspec | 2 +- ...ustomTokenExchangeMethodHandlerTests.swift | 192 ++++++++++ .../Tests/AuthAPI/AuthAPIHandlerTests.swift | 1 + auth0_flutter/ios/auth0_flutter.podspec | 2 +- auth0_flutter/lib/auth0_flutter_web.dart | 92 +++++ .../lib/src/mobile/authentication_api.dart | 58 +++ .../src/web/auth0_flutter_plugin_real.dart | 14 + .../web/auth0_flutter_web_platform_proxy.dart | 3 + .../exchange_token_options_extension.dart | 23 ++ auth0_flutter/lib/src/web/js_interop.dart | 21 ++ auth0_flutter/macos/auth0_flutter.podspec | 2 +- .../test/web/auth0_flutter_web_test.dart | 172 +++++++++ ...exchange_token_options_extension_test.dart | 71 ++++ .../lib/auth0_flutter_platform_interface.dart | 2 + .../auth_custom_token_exchange_options.dart | 29 ++ .../lib/src/auth0_flutter_auth_platform.dart | 6 + .../lib/src/auth0_flutter_web_platform.dart | 4 + .../method_channel_auth0_flutter_auth.dart | 13 + .../lib/src/web/exchange_token_options.dart | 17 + ...th_custom_token_exchange_options_test.dart | 107 ++++++ ...ethod_channel_auth0_flutter_auth_test.dart | 156 ++++++++ 28 files changed, 1503 insertions(+), 4 deletions(-) create mode 100644 auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt create mode 100644 auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift create mode 100644 auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift create mode 100644 auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart create mode 100644 auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart create mode 100644 auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart create mode 100644 auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart create mode 100644 auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index c15c1e187..cfee4a71f 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -29,6 +29,7 @@ - [Passwordless Login](#passwordless-login) - [Retrieve user information](#retrieve-user-information) - [Renew credentials](#renew-credentials) + - [Custom Token Exchange](#custom-token-exchange) - [Errors](#errors-2) - [πŸŒπŸ“± Organizations](#-organizations) - [Log in to an organization](#log-in-to-an-organization) @@ -700,6 +701,51 @@ final didStore = > πŸ’‘ To obtain a refresh token, make sure your Auth0 application has the **refresh token** [grant enabled](https://auth0.com/docs/get-started/applications/update-grant-types). If you are also specifying an audience value, make sure that the corresponding Auth0 API has the **Allow Offline Access** [setting enabled](https://auth0.com/docs/get-started/apis/api-settings#access-settings). +### Custom Token Exchange + +[Custom Token Exchange](https://auth0.com/docs/authenticate/custom-token-exchange) allows you to exchange tokens from external identity providers for Auth0 tokens. This is useful for migrating users from legacy systems or integrating with third-party identity providers. + +> **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. + +
+ Mobile (Android/iOS) + +```dart +final credentials = await auth0.api.customTokenExchange( + subjectToken: 'external-idp-token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', + audience: 'https://api.example.com', + scopes: {'openid', 'profile', 'email'}, + organization: 'org_abc123', // Optional + parameters: {'custom_param': 'value'} // Optional +); +``` + +
+ +
+ Web + +```dart +final credentials = await auth0Web.customTokenExchange( + subjectToken: 'external-idp-token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', + audience: 'https://api.example.com', + scopes: {'openid', 'profile', 'email'}, + organizationId: 'org_abc123', // Optional + parameters: {'custom_param': 'value'} // Optional +); +``` + +
+ +**Required setup:** +1. Configure a Custom Token Exchange profile in your Auth0 Dashboard +2. Implement validation logic in an Auth0 Action to verify the external token +3. Grant your Auth0 application the `urn:auth0:oauth2:grant-type:token-exchange` permission + +> πŸ’‘ For more information, see the [Custom Token Exchange documentation](https://auth0.com/docs/authenticate/custom-token-exchange) and [RFC 8693](https://tools.ietf.org/html/rfc8693). + ### Errors The Authentication API client will only throw `ApiException` exceptions. You can find more information in the `details` property of the exception. Check the [API documentation](https://pub.dev/documentation/auth0_flutter_platform_interface/latest/auth0_flutter_platform_interface/ApiException-class.html) to learn more about the available `ApiException` properties. diff --git a/auth0_flutter/android/build.gradle b/auth0_flutter/android/build.gradle index 4584749d0..18b983028 100644 --- a/auth0_flutter/android/build.gradle +++ b/auth0_flutter/android/build.gradle @@ -73,7 +73,7 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation 'com.auth0.android:auth0:3.11.0' + implementation 'com.auth0.android:auth0:3.12.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" 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 ba7ae31bc..7ab335f6f 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 @@ -67,6 +67,7 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { SignupApiRequestHandler(), UserInfoApiRequestHandler(), RenewApiRequestHandler(), + CustomTokenExchangeApiRequestHandler(), ResetPasswordApiRequestHandler() ) ) diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt new file mode 100644 index 000000000..6b9652442 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt @@ -0,0 +1,72 @@ +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.ArrayList + +private const val AUTH_CUSTOM_TOKEN_EXCHANGE_METHOD = "auth#customTokenExchange" + +class CustomTokenExchangeApiRequestHandler : ApiRequestHandler { + override val method: String = AUTH_CUSTOM_TOKEN_EXCHANGE_METHOD + + override fun handle( + api: AuthenticationAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + val args = request.data + assertHasProperties(listOf("subjectToken", "subjectTokenType"), args) + + val organization = if (args["organization"] is String) args["organization"] as String else null + + val builder = api.customTokenExchange( + args["subjectTokenType"] as String, + args["subjectToken"] as String, + organization + ).apply { + val scopes = (args["scopes"] ?: arrayListOf()) as ArrayList<*> + if (scopes.isNotEmpty()) { + setScope(scopes.joinToString(separator = " ")) + } + if (args["audience"] is String) { + setAudience(args["audience"] as String) + } + if (args["parameters"] is HashMap<*, *>) { + addParameters(args["parameters"] as Map) + } + validateClaims() + } + + builder.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() + val 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/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt new file mode 100644 index 000000000..fbd1f1b9d --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt @@ -0,0 +1,344 @@ +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 CustomTokenExchangeApiRequestHandlerTest { + @Test + fun `should throw when missing subjectToken`() { + val options = hashMapOf("subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt") + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockApi, request, mockResult) + } + } + + @Test + fun `should throw when missing subjectTokenType`() { + val options = hashMapOf("subjectToken" to "external-token-123") + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockApi, request, mockResult) + } + } + + @Test + fun `should call success with required parameters only`() { + val options = hashMapOf( + "subjectToken" to "external-token-123", + "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt" + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val accessToken = JwtTestUtils.createJwt("openid") + val idToken = JwtTestUtils.createJwt("openid") + val refreshToken = "refresh-token" + val expiresAt = Date() + + val credentials = Credentials( + idToken, + accessToken, + "Bearer", + refreshToken, + expiresAt, + "openid profile email" + ) + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onSuccess(credentials) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockApi).customTokenExchange( + "urn:ietf:params:oauth:token-type:jwt", + "external-token-123", + null + ) + verify(mockRequest).validateClaims() + verify(mockRequest).start(any()) + verify(mockResult).success(any()) + } + + @Test + fun `should handle error callback`() { + val options = hashMapOf( + "subjectToken" to "invalid-token", + "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt" + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val exception = mock { + on { getCode() } doReturn "invalid_token" + on { getDescription() } doReturn "Token validation failed" + } + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onFailure(exception) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockResult).error(eq("invalid_token"), eq("Token validation failed"), any()) + } + + @Test + fun `should include audience when provided`() { + val options = hashMapOf( + "subjectToken" to "external-token-456", + "subjectTokenType" to "urn:example:custom-token", + "audience" to "https://myapi.example.com" + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val credentials = Credentials( + JwtTestUtils.createJwt("openid"), + JwtTestUtils.createJwt("openid"), + "Bearer", + "refresh-token", + Date(), + "openid" + ) + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.setAudience(any())).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onSuccess(credentials) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockRequest).setAudience("https://myapi.example.com") + verify(mockResult).success(any()) + } + + @Test + fun `should include scopes when provided`() { + val options = hashMapOf( + "subjectToken" to "external-token-789", + "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt", + "scopes" to arrayListOf("openid", "profile", "email", "read:data") + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val credentials = Credentials( + JwtTestUtils.createJwt("openid"), + JwtTestUtils.createJwt("openid"), + "Bearer", + "refresh-token", + Date(), + "openid profile email read:data" + ) + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.setScope(any())).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onSuccess(credentials) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockRequest).setScope("openid profile email read:data") + verify(mockResult).success(any()) + } + + @Test + fun `should include custom parameters when provided`() { + val customParams = hashMapOf("custom_param" to "value", "another_param" to "test") + val options = hashMapOf( + "subjectToken" to "external-token-abc", + "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt", + "parameters" to customParams + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val credentials = Credentials( + JwtTestUtils.createJwt("openid"), + JwtTestUtils.createJwt("openid"), + "Bearer", + null, + Date(), + "openid" + ) + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.addParameters(any())).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onSuccess(credentials) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockRequest).addParameters(customParams) + verify(mockResult).success(any()) + } + + @Test + fun `should include all optional parameters when provided`() { + val customParams = hashMapOf("org_id" to "org_123") + val options = hashMapOf( + "subjectToken" to "external-token-full", + "subjectTokenType" to "urn:example:full-token", + "audience" to "https://api.example.com", + "scopes" to arrayListOf("openid", "profile", "email"), + "parameters" to customParams + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val credentials = Credentials( + JwtTestUtils.createJwt("openid"), + JwtTestUtils.createJwt("openid"), + "Bearer", + "refresh-token", + Date(), + "openid profile email" + ) + + whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) + whenever(mockRequest.setAudience(any())).thenReturn(mockRequest) + whenever(mockRequest.setScope(any())).thenReturn(mockRequest) + whenever(mockRequest.addParameters(any())).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onSuccess(credentials) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockRequest).setAudience("https://api.example.com") + verify(mockRequest).setScope("openid profile email") + verify(mockRequest).addParameters(customParams) + verify(mockRequest).validateClaims() + verify(mockResult).success(any()) + } + + @Test + fun `should include organization when provided`() { + val options = hashMapOf( + "subjectToken" to "external-token-org", + "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt", + "organization" to "org_abc123" + ) + val handler = CustomTokenExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val mockRequest = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val credentials = Credentials( + JwtTestUtils.createJwt("openid"), + JwtTestUtils.createJwt("openid"), + "Bearer", + "refresh-token", + Date(), + "openid" + ) + + whenever(mockApi.customTokenExchange(any(), any(), eq("org_abc123"))).thenReturn(mockRequest) + whenever(mockRequest.validateClaims()).thenReturn(mockRequest) + + doAnswer { + val callback = it.arguments[0] as Callback + callback.onSuccess(credentials) + null + }.whenever(mockRequest).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockApi).customTokenExchange( + "urn:ietf:params:oauth:token-type:jwt", + "external-token-org", + "org_abc123" + ) + verify(mockResult).success(any()) + } + + @Test + fun `should return correct method name`() { + val handler = CustomTokenExchangeApiRequestHandler() + assertThat(handler.method, equalTo("auth#customTokenExchange")) + } +} diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift new file mode 100644 index 000000000..a496812af --- /dev/null +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -0,0 +1,53 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { + enum Argument: String { + case subjectToken + case subjectTokenType + case audience + case scopes + case parameters + case organization + } + + let client: Authentication + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + guard let subjectToken = arguments[Argument.subjectToken] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.subjectToken.rawValue))) + } + guard let subjectTokenType = arguments[Argument.subjectTokenType] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.subjectTokenType.rawValue))) + } + guard let scopes = arguments[Argument.scopes] as? [String] else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.scopes.rawValue))) + } + guard let parameters = arguments[Argument.parameters] as? [String: Any] else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.parameters.rawValue))) + } + + let audience = arguments[Argument.audience] as? String + let organization = arguments[Argument.organization] as? String + let scope = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString + + client + .customTokenExchange(subjectToken: subjectToken, + subjectTokenType: subjectTokenType, + audience: audience, + scope: scope, + organization: organization) + .parameters(parameters) + .start { + switch $0 { + case .success(let credentials): callback(self.result(from: credentials)) + case .failure(let error): callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift index e2380a1f4..3e1f4c832 100644 --- a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift @@ -21,6 +21,7 @@ public class AuthAPIHandler: NSObject, FlutterPlugin { case signup = "auth#signUp" case userInfo = "auth#userInfo" case renew = "auth#renew" + case customTokenExchange = "auth#customTokenExchange" case resetPassword = "auth#resetPassword" case passwordlessWithEmail = "auth#passwordlessWithEmail" case passwordlessWithPhoneNumber = "auth#passwordlessWithPhoneNumber" @@ -64,6 +65,7 @@ public class AuthAPIHandler: NSObject, FlutterPlugin { case .signup: return AuthAPISignupMethodHandler(client: client) case .userInfo: return AuthAPIUserInfoMethodHandler(client: client) case .renew: return AuthAPIRenewMethodHandler(client: client) + case .customTokenExchange: return AuthAPICustomTokenExchangeMethodHandler(client: client) case .resetPassword: return AuthAPIResetPasswordMethodHandler(client: client) case .passwordlessWithEmail: return AuthAPIPasswordlessEmailMethodHandler(client: client) case .passwordlessWithPhoneNumber: return AuthAPIPasswordlessPhoneNumberMethodHandler(client: client) diff --git a/auth0_flutter/darwin/auth0_flutter.podspec b/auth0_flutter/darwin/auth0_flutter.podspec index 16aadb281..169f8f050 100644 --- a/auth0_flutter/darwin/auth0_flutter.podspec +++ b/auth0_flutter/darwin/auth0_flutter.podspec @@ -19,7 +19,7 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.14.0' + s.dependency 'Auth0', '2.16.2' s.dependency 'JWTDecode', '3.3.0' s.dependency 'SimpleKeychain', '1.3.0' diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift new file mode 100644 index 000000000..924dce056 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift @@ -0,0 +1,192 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +fileprivate typealias Argument = AuthAPICustomTokenExchangeMethodHandler.Argument + +class AuthAPICustomTokenExchangeMethodHandlerTests: XCTestCase { + var spy: SpyAuthentication! + var sut: AuthAPICustomTokenExchangeMethodHandler! + + override func setUpWithError() throws { + spy = SpyAuthentication() + sut = AuthAPICustomTokenExchangeMethodHandler(client: spy) + } +} + +// MARK: - Required Arguments Error + +extension AuthAPICustomTokenExchangeMethodHandlerTests { + func testProducesErrorWhenRequiredArgumentsAreMissing() { + let keys: [Argument] = [.subjectToken, .subjectTokenType, .scopes, .parameters] + let expectations = keys.map { expectation(description: "\($0.rawValue) is missing") } + for (argument, currentExpectation) in zip(keys, expectations) { + sut.handle(with: arguments(without: argument)) { result in + assert(result: result, isError: .requiredArgumentMissing(argument.rawValue)) + currentExpectation.fulfill() + } + } + wait(for: expectations) + } +} + +// MARK: - Successful Result + +extension AuthAPICustomTokenExchangeMethodHandlerTests { + func testCallsCustomTokenExchange() { + let expectation = self.expectation(description: "Called customTokenExchange") + spy.onCustomTokenExchange = { subjectToken, subjectTokenType, audience, scope, organization in + XCTAssertEqual(subjectToken, "existing-token") + XCTAssertEqual(subjectTokenType, "http://acme.com/legacy-token") + XCTAssertEqual(audience, "https://example.com/api") + XCTAssertEqual(scope, "openid profile email") + XCTAssertNil(organization) + expectation.fulfill() + } + sut.handle(with: arguments()) { _ in } + wait(for: [expectation]) + } + + func testReturnsCredentialsOnSuccess() { + let expectation = self.expectation(description: "Returned credentials") + let credentials = Credentials( + accessToken: "access-token", + tokenType: "bearer", + idToken: "id-token", + refreshToken: "refresh-token", + expiresIn: Date(timeIntervalSinceNow: 3600), + scope: "openid profile email" + ) + spy.onCustomTokenExchange = { _, _, _, _ in + return self.spy.request(returning: credentials) + } + sut.handle(with: arguments()) { result in + let values = result as? [String: Any] + XCTAssertNotNil(values) + XCTAssertEqual(values?[CredentialsProperty.accessToken.rawValue] as? String, "access-token") + XCTAssertEqual(values?[CredentialsProperty.idToken.rawValue] as? String, "id-token") + XCTAssertEqual(values?[CredentialsProperty.refreshToken.rawValue] as? String, "refresh-token") + XCTAssertEqual(values?[CredentialsProperty.tokenType.rawValue] as? String, "bearer") + expectation.fulfill() + } + wait(for: [expectation]) + } +} + +// MARK: - Optional Parameters + +extension AuthAPICustomTokenExchangeMethodHandlerTests { + func testWorksWithoutAudience() { + let expectation = self.expectation(description: "Called without audience") + spy.onCustomTokenExchange = { _, _, audience, _, _ in + XCTAssertNil(audience) + expectation.fulfill() + } + sut.handle(with: arguments(without: .audience)) { _ in } + wait(for: [expectation]) + } + + func testWorksWithEmptyScopes() { + let expectation = self.expectation(description: "Called with empty scopes") + spy.onCustomTokenExchange = { _, _, _, scope, _ in + XCTAssertNil(scope) + expectation.fulfill() + } + var args = arguments() + args[Argument.scopes.rawValue] = [] + sut.handle(with: args) { _ in } + wait(for: [expectation]) + } + + func testWorksWithoutOrganization() { + let expectation = self.expectation(description: "Called without organization") + spy.onCustomTokenExchange = { _, _, _, _, organization in + XCTAssertNil(organization) + expectation.fulfill() + } + sut.handle(with: arguments(without: .organization)) { _ in } + wait(for: [expectation]) + } + + func testIncludesOrganizationWhenProvided() { + let expectation = self.expectation(description: "Called with organization") + spy.onCustomTokenExchange = { subjectToken, subjectTokenType, audience, scope, organization in + XCTAssertEqual(subjectToken, "existing-token") + XCTAssertEqual(subjectTokenType, "http://acme.com/legacy-token") + XCTAssertEqual(audience, "https://example.com/api") + XCTAssertEqual(scope, "openid profile email") + XCTAssertEqual(organization, "org_abc123") + expectation.fulfill() + } + var args = arguments() + args[Argument.organization.rawValue] = "org_abc123" + sut.handle(with: args) { _ in } + wait(for: [expectation]) + } +} + +// MARK: - Additional Parameters + +extension AuthAPICustomTokenExchangeMethodHandlerTests { + func testCallsParametersWithCustomParameters() { + let expectation = self.expectation(description: "Called parameters") + spy.onParameters = { parameters in + XCTAssertTrue(parameters["test"] as? String == "test-123") + expectation.fulfill() + } + sut.handle(with: arguments()) { _ in } + wait(for: [expectation]) + } +} + +// MARK: - Error + +extension AuthAPICustomTokenExchangeMethodHandlerTests { + func testReturnsAuthenticationErrorOnFailure() { + let expectation = self.expectation(description: "Returned error") + let authError = AuthenticationError( + info: ["error": "invalid_grant", "error_description": "Invalid token"] + ) + spy.onCustomTokenExchange = { _, _, _, _ in + return self.spy.request(failing: authError) + } + sut.handle(with: arguments()) { result in + assert(result: result, isAuthenticationError: authError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} + +// MARK: - Helpers + +fileprivate extension AuthAPICustomTokenExchangeMethodHandlerTests { + func arguments(without key: Argument? = nil) -> [String: Any] { + var args: [String: Any] = [ + Argument.subjectToken.rawValue: "existing-token", + Argument.subjectTokenType.rawValue: "http://acme.com/legacy-token", + Argument.audience.rawValue: "https://example.com/api", + Argument.scopes.rawValue: ["openid", "profile", "email"], + Argument.parameters.rawValue: ["test": "test-123"] + ] + if let key = key { + args.removeValue(forKey: key.rawValue) + } + return args + } +} +// MARK: - Spy Extension + +fileprivate extension SpyAuthentication { + var onCustomTokenExchange: ((String, String, String?, String?, String?) -> Request)? + + func customTokenExchange(subjectToken: String, + subjectTokenType: String, + audience: String?, + scope: String?, + organization: String?) -> Request { + return onCustomTokenExchange?(subjectToken, subjectTokenType, audience, scope, organization) ?? request() + } +} } +} diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift index d6119e0e9..fece4af5a 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift @@ -115,6 +115,7 @@ extension AuthAPIHandlerTests { .signup: AuthAPISignupMethodHandler.self, .userInfo: AuthAPIUserInfoMethodHandler.self, .renew: AuthAPIRenewMethodHandler.self, + .customTokenExchange: AuthAPICustomTokenExchangeMethodHandler.self, .resetPassword: AuthAPIResetPasswordMethodHandler.self ] methodHandlers.forEach { method, methodHandler in diff --git a/auth0_flutter/ios/auth0_flutter.podspec b/auth0_flutter/ios/auth0_flutter.podspec index 16aadb281..169f8f050 100644 --- a/auth0_flutter/ios/auth0_flutter.podspec +++ b/auth0_flutter/ios/auth0_flutter.podspec @@ -19,7 +19,7 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.14.0' + s.dependency 'Auth0', '2.16.2' s.dependency 'JWTDecode', '3.3.0' s.dependency 'SimpleKeychain', '1.3.0' diff --git a/auth0_flutter/lib/auth0_flutter_web.dart b/auth0_flutter/lib/auth0_flutter_web.dart index 792ea4926..ef50ba0bb 100644 --- a/auth0_flutter/lib/auth0_flutter_web.dart +++ b/auth0_flutter/lib/auth0_flutter_web.dart @@ -274,6 +274,98 @@ class Auth0Web { cacheMode: cacheMode, parameters: parameters)); + /// Exchanges an external subject token for Auth0 access and ID tokens using + /// RFC 8693 Token Exchange. + /// + /// This method implements the OAuth 2.0 Token Exchange flow, allowing you to + /// exchange a token from an external provider for Auth0 tokens. This is useful + /// when integrating with external identity providers or custom authentication + /// systems. + /// + /// **Parameters:** + /// + /// * [subjectToken] (required) - The token being exchanged from the external + /// provider. For example, this might be a JWT from your custom authentication + /// system or another identity provider. + /// + /// * [subjectTokenType] (required) - A URI identifying the type of the + /// subject token according to RFC 8693. Common examples: + /// - `urn:ietf:params:oauth:token-type:jwt` for JWT tokens + /// - `urn:ietf:params:oauth:token-type:id_token` for OIDC ID tokens + /// - `urn:ietf:params:oauth:token-type:access_token` for OAuth access tokens + /// - Custom URNs like `urn:example:external-token` for custom token types + /// + /// * [audience] - Optional API identifier for which you want to receive an + /// access token. If not specified, uses the audience from [onLoad] configuration + /// or the default audience configured in your Auth0 application. + /// + /// * [scopes] - Optional set of scopes to request. Defaults to + /// `{'openid', 'profile', 'email'}`. These scopes determine what information + /// and permissions the resulting tokens will have. + /// + /// * [organizationId] - Optional organization ID or name to associate the + /// token exchange with a specific organization context. + /// + /// * [parameters] - Additional custom parameters to include in the token + /// exchange request. These can be processed by Auth0 Actions or Rules. + /// + /// **Returns** a [Credentials] object containing: + /// * `accessToken` - The new Auth0 access token + /// * `idToken` - The Auth0 ID token with user information + /// * `expiresAt` - When the access token expires + /// * `scopes` - The granted scopes + /// * `refreshToken` - Optional refresh token (if offline_access scope was requested) + /// + /// **Requirements:** + /// + /// 1. Configure a Token Exchange profile in your Auth0 Dashboard + /// 2. Implement validation logic in an Auth0 Action to verify the external token + /// 3. Grant your Auth0 application the `urn:auth0:oauth2:grant-type:token-exchange` permission + /// + /// **Example:** + /// + /// ```dart + /// try { + /// final credentials = await auth0Web.customTokenExchange( + /// subjectToken: externalToken, + /// subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', + /// audience: 'https://myapi.example.com', + /// scopes: {'openid', 'profile', 'email', 'read:data'}, + /// ); + /// print('Access token: ${credentials.accessToken}'); + /// } catch (e) { + /// print('Token exchange failed: $e'); + /// } + /// ``` + /// + /// **Throws** a [WebException] if: + /// * The subject token is invalid or expired + /// * Token exchange is not properly configured in Auth0 + /// * The external token fails validation in your Auth0 Action + /// * Network issues prevent the exchange request + /// + /// See also: + /// * [Token Exchange Documentation](https://auth0.com/docs/authenticate/login/token-exchange) + /// * [RFC 8693 Specification](https://tools.ietf.org/html/rfc8693) + Future customTokenExchange({ + required final String subjectToken, + required final String subjectTokenType, + final String? audience, + final Set? scopes, + final String? organizationId, + final Map parameters = const {}, + }) => + Auth0FlutterWebPlatform.instance.customTokenExchange( + ExchangeTokenOptions( + subjectToken: subjectToken, + subjectTokenType: subjectTokenType, + audience: audience, + scopes: scopes ?? {'openid', 'profile', 'email'}, + organizationId: organizationId, + parameters: parameters, + ), + ); + /// Indicates whether a user is currently authenticated. Future hasValidCredentials() => Auth0FlutterWebPlatform.instance.hasValidCredentials(); diff --git a/auth0_flutter/lib/src/mobile/authentication_api.dart b/auth0_flutter/lib/src/mobile/authentication_api.dart index 9e2a639fe..420067b00 100644 --- a/auth0_flutter/lib/src/mobile/authentication_api.dart +++ b/auth0_flutter/lib/src/mobile/authentication_api.dart @@ -349,6 +349,64 @@ class AuthenticationApi { scopes: scopes, parameters: parameters))); + /// Performs a custom token exchange to obtain Auth0 credentials using an + /// existing identity provider token. + /// + /// This method allows you to exchange tokens from external identity providers + /// for Auth0 tokens, enabling seamless integration with existing authentication + /// systems. + /// + /// ## Endpoint + /// https://auth0.com/docs/api/authentication#token-exchange + /// + /// ## Notes + /// + /// * [subjectToken] is the token obtained from the external identity provider. + /// * [subjectTokenType] specifies the format of the subject token (e.g., + /// 'http://acme.com/legacy-token'). + /// * [audience] relates to the API Identifier you want to reference in your + /// access tokens. See [API settings](https://auth0.com/docs/get-started/apis/api-settings) + /// to learn more. + /// * [scopes] defaults to `openid profile email`. You can override this to + /// specify a different set of scopes. + /// * Arbitrary [parameters] can be specified and then picked up in a custom + /// Auth0 [Action](https://auth0.com/docs/customize/actions) or + /// [Rule](https://auth0.com/docs/customize/rules). + /// + /// ## Usage example + /// + /// ```dart + /// final result = await auth0.api.customTokenExchange( + /// subjectToken: 'existing-identity-provider-token', + /// subjectTokenType: 'http://acme.com/legacy-token', + /// audience: 'https://my-api.example.com', + /// scopes: {'openid', 'profile', 'email'}, + /// organization: 'org_abc123' + /// ); + /// + /// final accessToken = result.accessToken; + /// ``` + /// + /// ## Further reading + /// + /// * [Custom Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange) + Future customTokenExchange({ + required final String subjectToken, + required final String subjectTokenType, + final String? audience, + final Set scopes = const {'openid', 'profile', 'email'}, + final String? organization, + final Map parameters = const {}, + }) => + Auth0FlutterAuthPlatform.instance.customTokenExchange(_createApiRequest( + AuthCustomTokenExchangeOptions( + subjectToken: subjectToken, + subjectTokenType: subjectTokenType, + audience: audience, + scopes: scopes, + organization: organization, + parameters: parameters))); + /// Initiates a reset of password of the user with the specific [email] /// address in the specific [connection]. /// diff --git a/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart b/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart index f556b1c7c..7b3d9492f 100644 --- a/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart +++ b/auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart @@ -9,6 +9,7 @@ import 'auth0_flutter_web_platform_proxy.dart'; import 'extensions/client_options_extensions.dart'; import 'extensions/credentials_extension.dart'; import 'extensions/credentials_options_extension.dart'; +import 'extensions/exchange_token_options_extension.dart'; import 'extensions/logout_options.extension.dart'; import 'extensions/web_exception_extensions.dart'; import 'js_interop.dart' as interop; @@ -159,6 +160,19 @@ class Auth0FlutterPlugin extends Auth0FlutterWebPlatform { } } + @override + Future customTokenExchange( + final ExchangeTokenOptions options) async { + final client = _ensureClient(); + try { + final result = + await client.exchangeToken(options.toInteropExchangeTokenOptions()); + return CredentialsExtension.fromWeb(result); + } catch (e) { + throw WebExceptionExtension.fromJsObject(JSObject.fromInteropObject(e)); + } + } + @override Future hasValidCredentials() => clientProxy!.isAuthenticated(); diff --git a/auth0_flutter/lib/src/web/auth0_flutter_web_platform_proxy.dart b/auth0_flutter/lib/src/web/auth0_flutter_web_platform_proxy.dart index 55d993094..06f109cef 100644 --- a/auth0_flutter/lib/src/web/auth0_flutter_web_platform_proxy.dart +++ b/auth0_flutter/lib/src/web/auth0_flutter_web_platform_proxy.dart @@ -21,6 +21,9 @@ class Auth0FlutterWebClientProxy { [final GetTokenSilentlyOptions? options]) => JSPromiseToFuture(client.getTokenSilently(options)).toDart; + Future exchangeToken(final ExchangeTokenOptions options) => + JSPromiseToFuture(client.exchangeToken(options)).toDart; + Future handleRedirectCallback([final String? url]) { // Omit the url if it is not provided, so that the default argument is used. if (url == null) { diff --git a/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart b/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart new file mode 100644 index 000000000..647a58c62 --- /dev/null +++ b/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart @@ -0,0 +1,23 @@ +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; +import '../js_interop.dart' as interop; +import '../js_interop_utils.dart'; + +extension ExchangeTokenOptionsExtension on ExchangeTokenOptions { + interop.ExchangeTokenOptions toInteropExchangeTokenOptions() { + final scopeString = scopes.isNotEmpty ? scopes.join(' ') : null; + + final options = JsInteropUtils.stripNulls(interop.ExchangeTokenOptions( + subject_token: subjectToken, + subject_token_type: subjectTokenType, + audience: audience, + scope: scopeString, + organization: organizationId, + )); + + // Add custom parameters if provided + if (parameters.isNotEmpty) { + JsInteropUtils.addCustomParams(options, parameters); + } + return options; + } +} diff --git a/auth0_flutter/lib/src/web/js_interop.dart b/auth0_flutter/lib/src/web/js_interop.dart index d1adc671c..915c98eed 100644 --- a/auth0_flutter/lib/src/web/js_interop.dart +++ b/auth0_flutter/lib/src/web/js_interop.dart @@ -186,6 +186,24 @@ extension type PopupLoginOptions._(JSObject _) implements JSObject { }); } +@JS() +@anonymous +extension type ExchangeTokenOptions._(JSObject _) implements JSObject { + external String get subject_token; + external String get subject_token_type; + external String? get audience; + external String? get scope; + external String? get organization; + + external factory ExchangeTokenOptions({ + required final String subject_token, + required final String subject_token_type, + final String? audience, + final String? scope, + final String? organization, + }); +} + @JS() @anonymous extension type PopupConfigOptions._(JSObject _) implements JSObject { @@ -215,6 +233,9 @@ extension type Auth0Client._(JSObject _) implements JSObject { external JSPromise getTokenSilently([ final GetTokenSilentlyOptions? options, ]); + external JSPromise exchangeToken( + final ExchangeTokenOptions options, + ); external JSPromise isAuthenticated(); external JSPromise logout([final LogoutOptions? logoutParams]); } diff --git a/auth0_flutter/macos/auth0_flutter.podspec b/auth0_flutter/macos/auth0_flutter.podspec index 16aadb281..169f8f050 100644 --- a/auth0_flutter/macos/auth0_flutter.podspec +++ b/auth0_flutter/macos/auth0_flutter.podspec @@ -19,7 +19,7 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.14.0' + s.dependency 'Auth0', '2.16.2' s.dependency 'JWTDecode', '3.3.0' s.dependency 'SimpleKeychain', '1.3.0' diff --git a/auth0_flutter/test/web/auth0_flutter_web_test.dart b/auth0_flutter/test/web/auth0_flutter_web_test.dart index 69d2ab9b8..f8492e647 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.dart @@ -413,6 +413,178 @@ void main() { e.message == 'test exception'))); }); + group('customTokenExchange', () { + test('customTokenExchange is called with required parameters and succeeds', + () async { + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + final result = await auth0.customTokenExchange( + subjectToken: 'external-token-123', + subjectTokenType: 'urn:example:external-token'); + + expect(result.accessToken, jwt); + expect(result.idToken, jwt); + expect(result.refreshToken, jwt); + expect(result.user.sub, jwtPayload['sub']); + expect(result.scopes, {'openid', 'read_messages'}); + + final options = + verify(mockClientProxy.exchangeToken(captureAny)).captured.first; + expect(options.subject_token, 'external-token-123'); + expect(options.subject_token_type, 'urn:example:external-token'); + expect(options.audience, null); + expect(options.scope, null); + expect(options.organization, null); + }); + + test('customTokenExchange is called with all optional parameters', + () async { + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + await auth0.customTokenExchange( + subjectToken: 'external-token-456', + subjectTokenType: 'urn:example:custom-token', + audience: 'https://api.example.com', + scopes: {'openid', 'profile', 'email'}, + organizationId: 'org_abc123'); + + final options = + verify(mockClientProxy.exchangeToken(captureAny)).captured.first; + expect(options.subject_token, 'external-token-456'); + expect(options.subject_token_type, 'urn:example:custom-token'); + expect(options.audience, 'https://api.example.com'); + expect(options.scope, 'openid profile email'); + expect(options.organization, 'org_abc123'); + }); + + test('customTokenExchange is called with custom parameters', () async { + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + await auth0.customTokenExchange( + subjectToken: 'external-token-789', + subjectTokenType: 'urn:example:custom-token', + parameters: {'custom_param': 'value', 'device_id': 'mobile-123'}); + + final options = + verify(mockClientProxy.exchangeToken(captureAny)).captured.first; + expect(options.subject_token, 'external-token-789'); + expect(options.subject_token_type, 'urn:example:custom-token'); + // Verify custom parameters are added to the JS object + final jsObject = JSObject.fromInteropObject(options); + expect(jsObject.getProperty('custom_param'.toJS).toString(), 'value'); + expect(jsObject.getProperty('device_id'.toJS).toString(), 'mobile-123'); + }); + + test('customTokenExchange handles empty scopes correctly', () async { + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + await auth0.customTokenExchange( + subjectToken: 'token', + subjectTokenType: 'urn:example:token', + scopes: {}); + + final options = + verify(mockClientProxy.exchangeToken(captureAny)).captured.first; + expect(options.scope, null); + }); + + test('customTokenExchange throws WebException on error', () async { + when(mockClientProxy.exchangeToken(any)) + .thenThrow(createJsException('invalid_token', 'Token is invalid')); + + expect( + () async => auth0.customTokenExchange( + subjectToken: 'invalid-token', + subjectTokenType: 'urn:example:token'), + throwsA(predicate((final e) => + e is WebException && + e.code == 'invalid_token' && + e.message == 'Token is invalid'))); + }); + + test('customTokenExchange throws WebException with specific error codes', + () async { + final errorCases = [ + {'code': 'invalid_grant', 'message': 'Invalid grant type'}, + {'code': 'unauthorized_client', 'message': 'Client not authorized'}, + {'code': 'access_denied', 'message': 'Access denied'}, + ]; + + for (final errorCase in errorCases) { + when(mockClientProxy.exchangeToken(any)) + .thenThrow(createJsException(errorCase['code']!, errorCase['message']!)); + + expect( + () async => auth0.customTokenExchange( + subjectToken: 'token', subjectTokenType: 'urn:example:token'), + throwsA(predicate((final e) => + e is WebException && + e.code == errorCase['code'] && + e.message == errorCase['message']))); + + reset(mockClientProxy); + } + }); + + test('customTokenExchange returns credentials with correct scopes', + () async { + final customScopeCredentials = interop.WebCredentials( + access_token: jwt, + id_token: jwt, + refresh_token: jwt, + scope: 'openid profile email read:data write:data', + expires_in: 0.toJS); + + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(customScopeCredentials)); + + final result = await auth0.customTokenExchange( + subjectToken: 'token', + subjectTokenType: 'urn:example:token', + scopes: {'openid', 'profile', 'email', 'read:data', 'write:data'}); + + expect(result.scopes, + {'openid', 'profile', 'email', 'read:data', 'write:data'}); + }); + + test('customTokenExchange works without refresh token', () async { + final credentialsNoRefresh = interop.WebCredentials( + access_token: jwt, + id_token: jwt, + scope: 'openid', + expires_in: 0.toJS); + + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(credentialsNoRefresh)); + + final result = await auth0.customTokenExchange( + subjectToken: 'token', subjectTokenType: 'urn:example:token'); + + expect(result.accessToken, jwt); + expect(result.idToken, jwt); + expect(result.refreshToken, null); + }); + + test('customTokenExchange converts JS credentials to Dart Credentials', + () async { + when(mockClientProxy.exchangeToken(any)) + .thenAnswer((final _) => Future.value(webCredentials)); + + final result = await auth0.customTokenExchange( + subjectToken: 'token', subjectTokenType: 'urn:example:token'); + + expect(result, isA()); + expect(result.accessToken, isNotEmpty); + expect(result.idToken, isNotEmpty); + expect(result.user, isA()); + expect(result.expiresAt, isA()); + }); + }); + group('invitationUrl handling', () { const fullInvitationUrl = 'https://my-tenant.auth0.com/login/invitation?invitation=abc-123&organization=org_xyz'; diff --git a/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart b/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart new file mode 100644 index 000000000..abca4d55a --- /dev/null +++ b/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart @@ -0,0 +1,71 @@ +@Tags(['browser']) + +import 'package:auth0_flutter/src/web/extensions/exchange_token_options_extension.dart'; +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ExchangeTokenOptionsExtension', () { + test('converts ExchangeTokenOptions with required fields only', () { + final options = ExchangeTokenOptions( + subjectToken: 'external-token-123', + subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', + ); + + final result = options.toInteropExchangeTokenOptions(); + + expect(result.subject_token, 'external-token-123'); + expect(result.subject_token_type, + 'urn:ietf:params:oauth:token-type:jwt'); + expect(result.audience, isNull); + expect(result.scope, isNull); + expect(result.organization, isNull); + }); + + test('converts ExchangeTokenOptions with all fields', () { + final options = ExchangeTokenOptions( + subjectToken: 'external-token-456', + subjectTokenType: 'urn:example:custom-token', + audience: 'https://myapi.example.com', + scopes: {'openid', 'profile', 'email'}, + organizationId: 'org_abc123', + ); + + final result = options.toInteropExchangeTokenOptions(); + + expect(result.subject_token, 'external-token-456'); + expect(result.subject_token_type, 'urn:example:custom-token'); + expect(result.audience, 'https://myapi.example.com'); + expect(result.scope, 'openid profile email'); + expect(result.organization, 'org_abc123'); + }); + + test('converts empty scopes to null', () { + final options = ExchangeTokenOptions( + subjectToken: 'token', + subjectTokenType: 'type', + scopes: {}, + ); + + final result = options.toInteropExchangeTokenOptions(); + + expect(result.scope, isNull); + }); + + test('joins multiple scopes with spaces', () { + final options = ExchangeTokenOptions( + subjectToken: 'token', + subjectTokenType: 'type', + scopes: {'read:data', 'write:data', 'delete:data'}, + ); + + final result = options.toInteropExchangeTokenOptions(); + + // Set order is not guaranteed, but all should be present + expect(result.scope, contains('read:data')); + expect(result.scope, contains('write:data')); + expect(result.scope, contains('delete:data')); + expect(result.scope?.split(' ').length, 3); + }); + }); +} diff --git a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart index 6d900feb9..e9f79fa8f 100644 --- a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart +++ b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart @@ -1,5 +1,6 @@ export 'src/account.dart'; export 'src/auth/api_exception.dart'; +export 'src/auth/auth_custom_token_exchange_options.dart'; export 'src/auth/auth_dpop_headers_options.dart'; export 'src/auth/auth_login_code_options.dart'; export 'src/auth/auth_login_options.dart'; @@ -50,6 +51,7 @@ export 'src/web/cache_location.dart'; export 'src/web/cache_mode.dart'; export 'src/web/client_options.dart'; export 'src/web/credentials_options.dart'; +export 'src/web/exchange_token_options.dart'; export 'src/web/logout_options.dart'; export 'src/web/popup_login_options.dart'; export 'src/web/web_exception.dart'; diff --git a/auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart b/auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart new file mode 100644 index 000000000..8e3ebca1f --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart @@ -0,0 +1,29 @@ +import '../request/request_options.dart'; + +class AuthCustomTokenExchangeOptions implements RequestOptions { + final String subjectToken; + final String subjectTokenType; + final String? audience; + final Set scopes; + final String? organization; + final Map parameters; + + const AuthCustomTokenExchangeOptions({ + required this.subjectToken, + required this.subjectTokenType, + this.audience, + this.scopes = const {}, + this.organization, + this.parameters = const {}, + }); + + @override + Map toMap() => { + 'subjectToken': subjectToken, + 'subjectTokenType': subjectTokenType, + if (audience != null) 'audience': audience, + 'scopes': scopes.toList(), + if (organization != null) 'organization': organization, + 'parameters': parameters + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart b/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart index 0d0c913e2..155e2d7d7 100644 --- a/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart @@ -1,6 +1,7 @@ // coverage:ignore-file import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'auth/auth_custom_token_exchange_options.dart'; import 'auth/auth_login_code_options.dart'; import 'auth/auth_login_options.dart'; import 'auth/auth_login_with_otp_options.dart'; @@ -76,6 +77,11 @@ abstract class Auth0FlutterAuthPlatform extends PlatformInterface { throw UnimplementedError('authRenewCredentials() has not been implemented'); } + Future customTokenExchange( + final ApiRequest request) { + throw UnimplementedError('customTokenExchange() has not been implemented'); + } + Future resetPassword( final ApiRequest request) { throw UnimplementedError('authResetPassword() has not been implemented'); diff --git a/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart b/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart index 43d7b0170..f2a4d2e46 100644 --- a/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart @@ -40,6 +40,10 @@ abstract class Auth0FlutterWebPlatform extends PlatformInterface { throw UnimplementedError('web.credentials has not been implemented'); } + Future customTokenExchange(final ExchangeTokenOptions options) { + throw UnimplementedError('web.customTokenExchange has not been implemented'); + } + Future hasValidCredentials() { throw UnimplementedError( 'web.hasValidCredentials has not been implemented', diff --git a/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart b/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart index 38e335591..d81c1d9f7 100644 --- a/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart +++ b/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart @@ -1,6 +1,7 @@ import 'package:flutter/services.dart'; import 'auth/api_exception.dart'; +import 'auth/auth_custom_token_exchange_options.dart'; import 'auth/auth_login_code_options.dart'; import 'auth/auth_login_options.dart'; import 'auth/auth_login_with_otp_options.dart'; @@ -32,6 +33,7 @@ const String authLoginWithSmsCodeMethod = 'auth#loginWithPhoneNumber'; const String authUserInfoMethod = 'auth#userInfo'; const String authSignUpMethod = 'auth#signUp'; const String authRenewMethod = 'auth#renew'; +const String authCustomTokenExchangeMethod = 'auth#customTokenExchange'; const String authResetPasswordMethod = 'auth#resetPassword'; class MethodChannelAuth0FlutterAuth extends Auth0FlutterAuthPlatform { @@ -123,6 +125,17 @@ class MethodChannelAuth0FlutterAuth extends Auth0FlutterAuthPlatform { return Credentials.fromMap(result); } + @override + Future customTokenExchange( + final ApiRequest request) async { + final Map result = await invokeRequest( + method: authCustomTokenExchangeMethod, + request: request, + ); + + return Credentials.fromMap(result); + } + @override Future resetPassword( final ApiRequest request) async { diff --git a/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart new file mode 100644 index 000000000..98e339412 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart @@ -0,0 +1,17 @@ +class ExchangeTokenOptions { + final String subjectToken; + final String subjectTokenType; + final String? audience; + final Set scopes; + final String? organizationId; + final Map parameters; + + ExchangeTokenOptions({ + required this.subjectToken, + required this.subjectTokenType, + this.audience, + this.scopes = const {}, + this.organizationId, + this.parameters = const {}, + }); +} diff --git a/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart b/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart new file mode 100644 index 000000000..63b9497a4 --- /dev/null +++ b/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart @@ -0,0 +1,107 @@ +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AuthCustomTokenExchangeOptions', () { + test('creates options with required parameters', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + ); + + expect(options.subjectToken, 'existing-token'); + expect(options.subjectTokenType, 'http://acme.com/legacy-token'); + expect(options.audience, isNull); + expect(options.scopes, isEmpty); + expect(options.organization, isNull); + expect(options.parameters, isEmpty); + }); + + test('creates options with all parameters', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + audience: 'https://example.com/api', + scopes: {'openid', 'profile', 'email'}, + organization: 'org_abc123', + parameters: {'test': 'test-123'}, + ); + + expect(options.subjectToken, 'existing-token'); + expect(options.subjectTokenType, 'http://acme.com/legacy-token'); + expect(options.audience, 'https://example.com/api'); + expect(options.scopes, {'openid', 'profile', 'email'}); + expect(options.organization, 'org_abc123'); + expect(options.parameters, {'test': 'test-123'}); + test('toMap includes all properties', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + audience: 'https://example.com/api', + scopes: {'openid', 'profile', 'email'}, + organization: 'org_abc123', + parameters: {'test': 'test-123'}, + ); + + final map = options.toMap(); + + expect(map['subjectToken'], 'existing-token'); + expect(map['subjectTokenType'], 'http://acme.com/legacy-token'); + expect(map['audience'], 'https://example.com/api'); + expect(map['scopes'], ['openid', 'profile', 'email']); + expect(map['organization'], 'org_abc123'); + expect(map['parameters'], {'test': 'test-123'}); + test('toMap excludes null audience', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + ); + + final map = options.toMap(); + + expect(map.containsKey('audience'), isFalse); + }); + + test('toMap excludes null organization', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + ); + + final map = options.toMap(); + + expect(map.containsKey('organization'), isFalse); + }); + + test('toMap includes organization when provided', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + organization: 'org_abc123', + ); + + final map = options.toMap(); + + expect(map['organization'], 'org_abc123'); + expect(map.containsKey('organization'), isTrue); + }); + + test('toMap includes empty scopes and parameters', () { + expect(map.containsKey('audience'), isFalse); + }); + + test('toMap includes empty scopes and parameters', () { + final options = AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + scopes: {}, + parameters: {}, + ); + + final map = options.toMap(); + + expect(map['scopes'], isEmpty); + expect(map['parameters'], isEmpty); + }); + }); +} diff --git a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart index 3b46f34c8..cebf9c65c 100644 --- a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart +++ b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart @@ -870,6 +870,162 @@ void main() { }); }); + group('customTokenExchange', () { + test('calls the correct MethodChannel method', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.renewResult); + + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); + + expect( + verify(mocked.methodCallHandler(captureAny)).captured.single.method, + 'auth#customTokenExchange'); + }); + + test('correctly maps all properties to the method channel', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.renewResult); + + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + audience: 'https://example.com/api', + scopes: {'openid', 'profile', 'email'}, + parameters: {'test': 'test-123'}))); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['_account']['domain'], 'test-domain'); + expect(verificationResult.arguments['_account']['clientId'], + 'test-clientId'); + expect(verificationResult.arguments['_userAgent']['name'], 'test-name'); + expect(verificationResult.arguments['_userAgent']['version'], + 'test-version'); + expect( + verificationResult.arguments['subjectToken'], 'existing-token'); + expect(verificationResult.arguments['subjectTokenType'], + 'http://acme.com/legacy-token'); + expect(verificationResult.arguments['audience'], 'https://example.com/api'); + expect(verificationResult.arguments['scopes'], ['openid', 'profile', 'email']); + expect(verificationResult.arguments['parameters']['test'], 'test-123'); + }); + + test( + 'correctly assigns default values to all non-required properties when missing', + () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.renewResult); + + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: '', version: ''), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['scopes'], isEmpty); + expect(verificationResult.arguments['parameters'], isEmpty); + expect(verificationResult.arguments.containsKey('audience'), isFalse); + expect(verificationResult.arguments.containsKey('organization'), isFalse); + }); + + test('correctly maps organization parameter when provided', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.renewResult); + + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token', + organization: 'org_abc123'))); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['organization'], 'org_abc123'); + }); + + test('correctly returns the response from the Method Channel', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.renewResult); + + final result = await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); + + expect(result.accessToken, MethodCallHandler.renewResult['accessToken']); + expect(result.idToken, MethodCallHandler.renewResult['idToken']); + expect( + result.refreshToken, MethodCallHandler.renewResult['refreshToken']); + expect(result.scopes, MethodCallHandler.renewResult['scopes']); + expect(result.expiresAt, + DateTime.parse(MethodCallHandler.renewResult['expiresAt'] as String)); + expect(result.user.name, + MethodCallHandler.renewResult['userProfile']['name']); + }); + + test('throws an ApiException when method channel returns null', () async { + when(mocked.methodCallHandler(any)).thenAnswer((final _) async => null); + + Future actual() async { + final result = + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: + UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); + + return result; + } + + await expectLater(actual, throwsA(isA())); + }); + + test( + 'throws an ApiException when method channel throws a PlatformException', + () async { + when(mocked.methodCallHandler(any)) + .thenThrow(PlatformException(code: '123')); + + Future actual() async { + final result = + await MethodChannelAuth0FlutterAuth().customTokenExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: + UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); + + return result; + } + + await expectLater(actual, throwsA(isA())); + }); + }); + group('userInfo', () { test('calls the correct MethodChannel method', () async { when(mocked.methodCallHandler(any)) From 1051510a3316ac924c9d0295951ee257fb3a06c2 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Wed, 14 Jan 2026 09:46:25 +0530 Subject: [PATCH 13/42] fixed test cases --- .../test/web/auth0_flutter_web_test.dart | 18 +++++++++--------- ...uth_custom_token_exchange_options_test.dart | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/auth0_flutter/test/web/auth0_flutter_web_test.dart b/auth0_flutter/test/web/auth0_flutter_web_test.dart index f8492e647..40885682f 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.dart @@ -416,7 +416,7 @@ void main() { group('customTokenExchange', () { test('customTokenExchange is called with required parameters and succeeds', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( @@ -440,7 +440,7 @@ void main() { test('customTokenExchange is called with all optional parameters', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -460,7 +460,7 @@ void main() { }); test('customTokenExchange is called with custom parameters', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -479,7 +479,7 @@ void main() { }); test('customTokenExchange handles empty scopes correctly', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -493,7 +493,7 @@ void main() { }); test('customTokenExchange throws WebException on error', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenThrow(createJsException('invalid_token', 'Token is invalid')); expect( @@ -515,7 +515,7 @@ void main() { ]; for (final errorCase in errorCases) { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenThrow(createJsException(errorCase['code']!, errorCase['message']!)); expect( @@ -539,7 +539,7 @@ void main() { scope: 'openid profile email read:data write:data', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(customScopeCredentials)); final result = await auth0.customTokenExchange( @@ -558,7 +558,7 @@ void main() { scope: 'openid', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(credentialsNoRefresh)); final result = await auth0.customTokenExchange( @@ -571,7 +571,7 @@ void main() { test('customTokenExchange converts JS credentials to Dart Credentials', () async { - when(mockClientProxy.exchangeToken(any)) + when(mockClientProxy.exchangeToken(any())) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( diff --git a/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart b/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart index 63b9497a4..9dd056623 100644 --- a/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart +++ b/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart @@ -33,6 +33,8 @@ void main() { expect(options.scopes, {'openid', 'profile', 'email'}); expect(options.organization, 'org_abc123'); expect(options.parameters, {'test': 'test-123'}); + }); + test('toMap includes all properties', () { final options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', @@ -51,6 +53,8 @@ void main() { expect(map['scopes'], ['openid', 'profile', 'email']); expect(map['organization'], 'org_abc123'); expect(map['parameters'], {'test': 'test-123'}); + }); + test('toMap excludes null audience', () { final options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', @@ -86,10 +90,6 @@ void main() { expect(map.containsKey('organization'), isTrue); }); - test('toMap includes empty scopes and parameters', () { - expect(map.containsKey('audience'), isFalse); - }); - test('toMap includes empty scopes and parameters', () { final options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', From 6fcec6989a69a5ce747ffc87266b8ad9d3df52ea Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Wed, 14 Jan 2026 10:34:59 +0530 Subject: [PATCH 14/42] fixed test cases --- .../test/web/auth0_flutter_web_test.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/auth0_flutter/test/web/auth0_flutter_web_test.dart b/auth0_flutter/test/web/auth0_flutter_web_test.dart index 40885682f..b1d1b9dd3 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.dart @@ -416,7 +416,7 @@ void main() { group('customTokenExchange', () { test('customTokenExchange is called with required parameters and succeeds', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( @@ -440,7 +440,7 @@ void main() { test('customTokenExchange is called with all optional parameters', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -460,7 +460,7 @@ void main() { }); test('customTokenExchange is called with custom parameters', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -479,7 +479,7 @@ void main() { }); test('customTokenExchange handles empty scopes correctly', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -493,7 +493,7 @@ void main() { }); test('customTokenExchange throws WebException on error', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenThrow(createJsException('invalid_token', 'Token is invalid')); expect( @@ -515,7 +515,7 @@ void main() { ]; for (final errorCase in errorCases) { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenThrow(createJsException(errorCase['code']!, errorCase['message']!)); expect( @@ -539,7 +539,7 @@ void main() { scope: 'openid profile email read:data write:data', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(customScopeCredentials)); final result = await auth0.customTokenExchange( @@ -558,7 +558,7 @@ void main() { scope: 'openid', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(credentialsNoRefresh)); final result = await auth0.customTokenExchange( @@ -571,7 +571,7 @@ void main() { test('customTokenExchange converts JS credentials to Dart Credentials', () async { - when(mockClientProxy.exchangeToken(any())) + when(mockClientProxy.exchangeToken(argThat(anything))) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( From a967065e49c443ae8af3b0f8d0465a1c498b5079 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Wed, 14 Jan 2026 15:39:41 +0530 Subject: [PATCH 15/42] fix test cases and code refactoring --- auth0_flutter/EXAMPLES.md | 10 ++-- .../CustomTokenExchangeApiRequestHandler.kt | 3 -- ...ustomTokenExchangeApiRequestHandlerTest.kt | 46 +------------------ ...hAPICustomTokenExchangeMethodHandler.swift | 9 +--- ...hAPICustomTokenExchangeMethodHandler.swift | 1 + auth0_flutter/lib/auth0_flutter_web.dart | 9 ++-- .../lib/src/mobile/authentication_api.dart | 7 +-- .../exchange_token_options_extension.dart | 10 +--- ...hAPICustomTokenExchangeMethodHandler.swift | 1 + .../test/web/auth0_flutter_web_test.dart | 39 ++++------------ .../web/auth0_flutter_web_test.mocks.dart | 17 +++++++ .../auth_custom_token_exchange_options.dart | 3 -- .../lib/src/web/exchange_token_options.dart | 6 +-- ...th_custom_token_exchange_options_test.dart | 9 +--- ...ethod_channel_auth0_flutter_auth_test.dart | 5 +- 15 files changed, 46 insertions(+), 129 deletions(-) create mode 120000 auth0_flutter/ios/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index cfee4a71f..81bfab1f4 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -703,7 +703,10 @@ final didStore = ### Custom Token Exchange -[Custom Token Exchange](https://auth0.com/docs/authenticate/custom-token-exchange) allows you to exchange tokens from external identity providers for Auth0 tokens. This is useful for migrating users from legacy systems or integrating with third-party identity providers. +[Custom Token Exchange](https://auth0.com/docs/authenticate/custom-token-exchange) allows you to enable applications to exchange their existing tokens for Auth0 tokens when calling the /oauth/token endpoint. This is useful for advanced integration use cases, such as: +- Get Auth0 tokens for another audience +- Integrate an external identity provider +- Migrate to Auth0 > **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. @@ -739,11 +742,6 @@ final credentials = await auth0Web.customTokenExchange( -**Required setup:** -1. Configure a Custom Token Exchange profile in your Auth0 Dashboard -2. Implement validation logic in an Auth0 Action to verify the external token -3. Grant your Auth0 application the `urn:auth0:oauth2:grant-type:token-exchange` permission - > πŸ’‘ For more information, see the [Custom Token Exchange documentation](https://auth0.com/docs/authenticate/custom-token-exchange) and [RFC 8693](https://tools.ietf.org/html/rfc8693). ### Errors diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt index 6b9652442..41888725f 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandler.kt @@ -37,9 +37,6 @@ class CustomTokenExchangeApiRequestHandler : ApiRequestHandler { if (args["audience"] is String) { setAudience(args["audience"] as String) } - if (args["parameters"] is HashMap<*, *>) { - addParameters(args["parameters"] as Map) - } validateClaims() } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt index fbd1f1b9d..8eadab55e 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt @@ -207,55 +207,13 @@ class CustomTokenExchangeApiRequestHandlerTest { verify(mockResult).success(any()) } - @Test - fun `should include custom parameters when provided`() { - val customParams = hashMapOf("custom_param" to "value", "another_param" to "test") - val options = hashMapOf( - "subjectToken" to "external-token-abc", - "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt", - "parameters" to customParams - ) - val handler = CustomTokenExchangeApiRequestHandler() - val mockApi = mock() - val mockAccount = mock() - val mockResult = mock() - val mockRequest = mock() - val request = MethodCallRequest(account = mockAccount, options) - - val credentials = Credentials( - JwtTestUtils.createJwt("openid"), - JwtTestUtils.createJwt("openid"), - "Bearer", - null, - Date(), - "openid" - ) - - whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) - whenever(mockRequest.addParameters(any())).thenReturn(mockRequest) - whenever(mockRequest.validateClaims()).thenReturn(mockRequest) - - doAnswer { - val callback = it.arguments[0] as Callback - callback.onSuccess(credentials) - null - }.whenever(mockRequest).start(any()) - - handler.handle(mockApi, request, mockResult) - - verify(mockRequest).addParameters(customParams) - verify(mockResult).success(any()) - } - @Test fun `should include all optional parameters when provided`() { - val customParams = hashMapOf("org_id" to "org_123") val options = hashMapOf( "subjectToken" to "external-token-full", "subjectTokenType" to "urn:example:full-token", "audience" to "https://api.example.com", - "scopes" to arrayListOf("openid", "profile", "email"), - "parameters" to customParams + "scopes" to arrayListOf("openid", "profile", "email") ) val handler = CustomTokenExchangeApiRequestHandler() val mockApi = mock() @@ -276,7 +234,6 @@ class CustomTokenExchangeApiRequestHandlerTest { whenever(mockApi.customTokenExchange(any(), any(), isNull())).thenReturn(mockRequest) whenever(mockRequest.setAudience(any())).thenReturn(mockRequest) whenever(mockRequest.setScope(any())).thenReturn(mockRequest) - whenever(mockRequest.addParameters(any())).thenReturn(mockRequest) whenever(mockRequest.validateClaims()).thenReturn(mockRequest) doAnswer { @@ -289,7 +246,6 @@ class CustomTokenExchangeApiRequestHandlerTest { verify(mockRequest).setAudience("https://api.example.com") verify(mockRequest).setScope("openid profile email") - verify(mockRequest).addParameters(customParams) verify(mockRequest).validateClaims() verify(mockResult).success(any()) } diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift index a496812af..e1c05227d 100644 --- a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -12,7 +12,6 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { case subjectTokenType case audience case scopes - case parameters case organization } @@ -28,13 +27,10 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { guard let scopes = arguments[Argument.scopes] as? [String] else { return callback(FlutterError(from: .requiredArgumentMissing(Argument.scopes.rawValue))) } - guard let parameters = arguments[Argument.parameters] as? [String: Any] else { - return callback(FlutterError(from: .requiredArgumentMissing(Argument.parameters.rawValue))) - } let audience = arguments[Argument.audience] as? String let organization = arguments[Argument.organization] as? String - let scope = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString + let scope: String = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString client .customTokenExchange(subjectToken: subjectToken, @@ -42,7 +38,6 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { audience: audience, scope: scope, organization: organization) - .parameters(parameters) .start { switch $0 { case .success(let credentials): callback(self.result(from: credentials)) @@ -50,4 +45,4 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { } } } -} +} \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/ios/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift new file mode 120000 index 000000000..3bb2c2d3d --- /dev/null +++ b/auth0_flutter/ios/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/lib/auth0_flutter_web.dart b/auth0_flutter/lib/auth0_flutter_web.dart index ef50ba0bb..22fb6b801 100644 --- a/auth0_flutter/lib/auth0_flutter_web.dart +++ b/auth0_flutter/lib/auth0_flutter_web.dart @@ -299,9 +299,8 @@ class Auth0Web { /// access token. If not specified, uses the audience from [onLoad] configuration /// or the default audience configured in your Auth0 application. /// - /// * [scopes] - Optional set of scopes to request. Defaults to - /// `{'openid', 'profile', 'email'}`. These scopes determine what information - /// and permissions the resulting tokens will have. + /// * [scopes] - Optional set of scopes to request. + /// These scopes determine what information and permissions the resulting tokens will have. /// /// * [organizationId] - Optional organization ID or name to associate the /// token exchange with a specific organization context. @@ -353,16 +352,14 @@ class Auth0Web { final String? audience, final Set? scopes, final String? organizationId, - final Map parameters = const {}, }) => Auth0FlutterWebPlatform.instance.customTokenExchange( ExchangeTokenOptions( subjectToken: subjectToken, subjectTokenType: subjectTokenType, audience: audience, - scopes: scopes ?? {'openid', 'profile', 'email'}, + scopes: scopes, organizationId: organizationId, - parameters: parameters, ), ); diff --git a/auth0_flutter/lib/src/mobile/authentication_api.dart b/auth0_flutter/lib/src/mobile/authentication_api.dart index 420067b00..15599d083 100644 --- a/auth0_flutter/lib/src/mobile/authentication_api.dart +++ b/auth0_flutter/lib/src/mobile/authentication_api.dart @@ -369,9 +369,6 @@ class AuthenticationApi { /// to learn more. /// * [scopes] defaults to `openid profile email`. You can override this to /// specify a different set of scopes. - /// * Arbitrary [parameters] can be specified and then picked up in a custom - /// Auth0 [Action](https://auth0.com/docs/customize/actions) or - /// [Rule](https://auth0.com/docs/customize/rules). /// /// ## Usage example /// @@ -396,7 +393,6 @@ class AuthenticationApi { final String? audience, final Set scopes = const {'openid', 'profile', 'email'}, final String? organization, - final Map parameters = const {}, }) => Auth0FlutterAuthPlatform.instance.customTokenExchange(_createApiRequest( AuthCustomTokenExchangeOptions( @@ -404,8 +400,7 @@ class AuthenticationApi { subjectTokenType: subjectTokenType, audience: audience, scopes: scopes, - organization: organization, - parameters: parameters))); + organization: organization))); /// Initiates a reset of password of the user with the specific [email] /// address in the specific [connection]. diff --git a/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart b/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart index 647a58c62..025e54b04 100644 --- a/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart +++ b/auth0_flutter/lib/src/web/extensions/exchange_token_options_extension.dart @@ -4,20 +4,14 @@ import '../js_interop_utils.dart'; extension ExchangeTokenOptionsExtension on ExchangeTokenOptions { interop.ExchangeTokenOptions toInteropExchangeTokenOptions() { - final scopeString = scopes.isNotEmpty ? scopes.join(' ') : null; + final scopeString = scopes?.isNotEmpty == true ? scopes!.join(' ') : null; - final options = JsInteropUtils.stripNulls(interop.ExchangeTokenOptions( + return JsInteropUtils.stripNulls(interop.ExchangeTokenOptions( subject_token: subjectToken, subject_token_type: subjectTokenType, audience: audience, scope: scopeString, organization: organizationId, )); - - // Add custom parameters if provided - if (parameters.isNotEmpty) { - JsInteropUtils.addCustomParams(options, parameters); - } - return options; } } diff --git a/auth0_flutter/macos/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/macos/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift new file mode 120000 index 000000000..3bb2c2d3d --- /dev/null +++ b/auth0_flutter/macos/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/test/web/auth0_flutter_web_test.dart b/auth0_flutter/test/web/auth0_flutter_web_test.dart index b1d1b9dd3..477da1084 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.dart @@ -416,7 +416,7 @@ void main() { group('customTokenExchange', () { test('customTokenExchange is called with required parameters and succeeds', () async { - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( @@ -459,27 +459,8 @@ void main() { expect(options.organization, 'org_abc123'); }); - test('customTokenExchange is called with custom parameters', () async { - when(mockClientProxy.exchangeToken(argThat(anything))) - .thenAnswer((final _) => Future.value(webCredentials)); - - await auth0.customTokenExchange( - subjectToken: 'external-token-789', - subjectTokenType: 'urn:example:custom-token', - parameters: {'custom_param': 'value', 'device_id': 'mobile-123'}); - - final options = - verify(mockClientProxy.exchangeToken(captureAny)).captured.first; - expect(options.subject_token, 'external-token-789'); - expect(options.subject_token_type, 'urn:example:custom-token'); - // Verify custom parameters are added to the JS object - final jsObject = JSObject.fromInteropObject(options); - expect(jsObject.getProperty('custom_param'.toJS).toString(), 'value'); - expect(jsObject.getProperty('device_id'.toJS).toString(), 'mobile-123'); - }); - test('customTokenExchange handles empty scopes correctly', () async { - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenAnswer((final _) => Future.value(webCredentials)); await auth0.customTokenExchange( @@ -493,7 +474,7 @@ void main() { }); test('customTokenExchange throws WebException on error', () async { - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenThrow(createJsException('invalid_token', 'Token is invalid')); expect( @@ -515,15 +496,15 @@ void main() { ]; for (final errorCase in errorCases) { - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenThrow(createJsException(errorCase['code']!, errorCase['message']!)); - expect( - () async => auth0.customTokenExchange( + await expectLater( + auth0.customTokenExchange( subjectToken: 'token', subjectTokenType: 'urn:example:token'), throwsA(predicate((final e) => e is WebException && - e.code == errorCase['code'] && + e.code == 'AUTHENTICATION_ERROR' && e.message == errorCase['message']))); reset(mockClientProxy); @@ -539,7 +520,7 @@ void main() { scope: 'openid profile email read:data write:data', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenAnswer((final _) => Future.value(customScopeCredentials)); final result = await auth0.customTokenExchange( @@ -558,7 +539,7 @@ void main() { scope: 'openid', expires_in: 0.toJS); - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenAnswer((final _) => Future.value(credentialsNoRefresh)); final result = await auth0.customTokenExchange( @@ -571,7 +552,7 @@ void main() { test('customTokenExchange converts JS credentials to Dart Credentials', () async { - when(mockClientProxy.exchangeToken(argThat(anything))) + when(mockClientProxy.exchangeToken(any)) .thenAnswer((final _) => Future.value(webCredentials)); final result = await auth0.customTokenExchange( diff --git a/auth0_flutter/test/web/auth0_flutter_web_test.mocks.dart b/auth0_flutter/test/web/auth0_flutter_web_test.mocks.dart index 64c2514ca..8b7ba1742 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.mocks.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.mocks.dart @@ -135,6 +135,23 @@ class MockAuth0FlutterWebClientProxy extends _i1.Mock )) as _i2.WebCredentials), ) as _i4.Future<_i2.WebCredentials>); + @override + _i4.Future<_i2.WebCredentials> exchangeToken(_i2.ExchangeTokenOptions? options) => + (super.noSuchMethod( + Invocation.method( + #exchangeToken, + [options], + ), + returnValue: _i4.Future<_i2.WebCredentials>.value( + createJSInteropWrapper<_FakeWebCredentials_1>(_FakeWebCredentials_1( + this, + Invocation.method( + #exchangeToken, + [options], + ), + )) as _i2.WebCredentials), + ) as _i4.Future<_i2.WebCredentials>); + @override _i4.Future<_i2.RedirectLoginResult> handleRedirectCallback([String? url]) => (super.noSuchMethod( diff --git a/auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart b/auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart index 8e3ebca1f..174eee47e 100644 --- a/auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart +++ b/auth0_flutter_platform_interface/lib/src/auth/auth_custom_token_exchange_options.dart @@ -6,7 +6,6 @@ class AuthCustomTokenExchangeOptions implements RequestOptions { final String? audience; final Set scopes; final String? organization; - final Map parameters; const AuthCustomTokenExchangeOptions({ required this.subjectToken, @@ -14,7 +13,6 @@ class AuthCustomTokenExchangeOptions implements RequestOptions { this.audience, this.scopes = const {}, this.organization, - this.parameters = const {}, }); @override @@ -24,6 +22,5 @@ class AuthCustomTokenExchangeOptions implements RequestOptions { if (audience != null) 'audience': audience, 'scopes': scopes.toList(), if (organization != null) 'organization': organization, - 'parameters': parameters }; } diff --git a/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart index 98e339412..f3c2b95d2 100644 --- a/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart +++ b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart @@ -2,16 +2,14 @@ class ExchangeTokenOptions { final String subjectToken; final String subjectTokenType; final String? audience; - final Set scopes; + final Set? scopes; final String? organizationId; - final Map parameters; ExchangeTokenOptions({ required this.subjectToken, required this.subjectTokenType, this.audience, - this.scopes = const {}, + this.scopes, this.organizationId, - this.parameters = const {}, }); } diff --git a/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart b/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart index 9dd056623..a871f1fb3 100644 --- a/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart +++ b/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart @@ -14,7 +14,6 @@ void main() { expect(options.audience, isNull); expect(options.scopes, isEmpty); expect(options.organization, isNull); - expect(options.parameters, isEmpty); }); test('creates options with all parameters', () { @@ -24,7 +23,6 @@ void main() { audience: 'https://example.com/api', scopes: {'openid', 'profile', 'email'}, organization: 'org_abc123', - parameters: {'test': 'test-123'}, ); expect(options.subjectToken, 'existing-token'); @@ -32,7 +30,6 @@ void main() { expect(options.audience, 'https://example.com/api'); expect(options.scopes, {'openid', 'profile', 'email'}); expect(options.organization, 'org_abc123'); - expect(options.parameters, {'test': 'test-123'}); }); test('toMap includes all properties', () { @@ -42,7 +39,6 @@ void main() { audience: 'https://example.com/api', scopes: {'openid', 'profile', 'email'}, organization: 'org_abc123', - parameters: {'test': 'test-123'}, ); final map = options.toMap(); @@ -52,7 +48,6 @@ void main() { expect(map['audience'], 'https://example.com/api'); expect(map['scopes'], ['openid', 'profile', 'email']); expect(map['organization'], 'org_abc123'); - expect(map['parameters'], {'test': 'test-123'}); }); test('toMap excludes null audience', () { @@ -90,18 +85,16 @@ void main() { expect(map.containsKey('organization'), isTrue); }); - test('toMap includes empty scopes and parameters', () { + test('toMap includes empty scopes', () { final options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', subjectTokenType: 'http://acme.com/legacy-token', scopes: {}, - parameters: {}, ); final map = options.toMap(); expect(map['scopes'], isEmpty); - expect(map['parameters'], isEmpty); }); }); } diff --git a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart index cebf9c65c..70340f594 100644 --- a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart +++ b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart @@ -900,8 +900,7 @@ void main() { subjectToken: 'existing-token', subjectTokenType: 'http://acme.com/legacy-token', audience: 'https://example.com/api', - scopes: {'openid', 'profile', 'email'}, - parameters: {'test': 'test-123'}))); + scopes: {'openid', 'profile', 'email'}))); final verificationResult = verify(mocked.methodCallHandler(captureAny)).captured.single; @@ -917,7 +916,6 @@ void main() { 'http://acme.com/legacy-token'); expect(verificationResult.arguments['audience'], 'https://example.com/api'); expect(verificationResult.arguments['scopes'], ['openid', 'profile', 'email']); - expect(verificationResult.arguments['parameters']['test'], 'test-123'); }); test( @@ -936,7 +934,6 @@ void main() { final verificationResult = verify(mocked.methodCallHandler(captureAny)).captured.single; expect(verificationResult.arguments['scopes'], isEmpty); - expect(verificationResult.arguments['parameters'], isEmpty); expect(verificationResult.arguments.containsKey('audience'), isFalse); expect(verificationResult.arguments.containsKey('organization'), isFalse); }); From 2997dc49bd1825307a20589ef27b0ea90dfd6bd1 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Wed, 14 Jan 2026 16:16:48 +0530 Subject: [PATCH 16/42] refactor --- ...hAPICustomTokenExchangeMethodHandler.swift | 8 +++---- ...ustomTokenExchangeMethodHandlerTests.swift | 21 +++---------------- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift index e1c05227d..cb2c17f6c 100644 --- a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -24,13 +24,11 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { guard let subjectTokenType = arguments[Argument.subjectTokenType] as? String else { return callback(FlutterError(from: .requiredArgumentMissing(Argument.subjectTokenType.rawValue))) } - guard let scopes = arguments[Argument.scopes] as? [String] else { - return callback(FlutterError(from: .requiredArgumentMissing(Argument.scopes.rawValue))) - } - + + let scopes = arguments[Argument.scopes] as? [String] ?? [] + let scope = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString let audience = arguments[Argument.audience] as? String let organization = arguments[Argument.organization] as? String - let scope: String = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString client .customTokenExchange(subjectToken: subjectToken, diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift index 924dce056..ad3f8a5ab 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift @@ -19,7 +19,7 @@ class AuthAPICustomTokenExchangeMethodHandlerTests: XCTestCase { extension AuthAPICustomTokenExchangeMethodHandlerTests { func testProducesErrorWhenRequiredArgumentsAreMissing() { - let keys: [Argument] = [.subjectToken, .subjectTokenType, .scopes, .parameters] + let keys: [Argument] = [.subjectToken, .subjectTokenType] let expectations = keys.map { expectation(description: "\($0.rawValue) is missing") } for (argument, currentExpectation) in zip(keys, expectations) { sut.handle(with: arguments(without: argument)) { result in @@ -90,7 +90,7 @@ extension AuthAPICustomTokenExchangeMethodHandlerTests { func testWorksWithEmptyScopes() { let expectation = self.expectation(description: "Called with empty scopes") spy.onCustomTokenExchange = { _, _, _, scope, _ in - XCTAssertNil(scope) + XCTAssertEqual(scope, "openid profile email") expectation.fulfill() } var args = arguments() @@ -126,20 +126,6 @@ extension AuthAPICustomTokenExchangeMethodHandlerTests { } } -// MARK: - Additional Parameters - -extension AuthAPICustomTokenExchangeMethodHandlerTests { - func testCallsParametersWithCustomParameters() { - let expectation = self.expectation(description: "Called parameters") - spy.onParameters = { parameters in - XCTAssertTrue(parameters["test"] as? String == "test-123") - expectation.fulfill() - } - sut.handle(with: arguments()) { _ in } - wait(for: [expectation]) - } -} - // MARK: - Error extension AuthAPICustomTokenExchangeMethodHandlerTests { @@ -167,8 +153,7 @@ fileprivate extension AuthAPICustomTokenExchangeMethodHandlerTests { Argument.subjectToken.rawValue: "existing-token", Argument.subjectTokenType.rawValue: "http://acme.com/legacy-token", Argument.audience.rawValue: "https://example.com/api", - Argument.scopes.rawValue: ["openid", "profile", "email"], - Argument.parameters.rawValue: ["test": "test-123"] + Argument.scopes.rawValue: ["openid", "profile", "email"] ] if let key = key { args.removeValue(forKey: key.rawValue) From 0fa36df3b9bc996c3933663104d219c472756ab2 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Fri, 16 Jan 2026 12:28:35 +0530 Subject: [PATCH 17/42] address feedback --- auth0_flutter/EXAMPLES.md | 15 ++++----- ...ustomTokenExchangeApiRequestHandlerTest.kt | 14 ++++----- ...hAPICustomTokenExchangeMethodHandler.swift | 6 ++-- ...ustomTokenExchangeMethodHandlerTests.swift | 14 ++------- auth0_flutter/lib/auth0_flutter_web.dart | 31 ++++++++++--------- ...exchange_token_options_extension_test.dart | 4 +-- .../lib/src/web/exchange_token_options.dart | 12 +++++++ 7 files changed, 50 insertions(+), 46 deletions(-) diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index 81bfab1f4..992e9548c 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -704,7 +704,6 @@ final didStore = ### Custom Token Exchange [Custom Token Exchange](https://auth0.com/docs/authenticate/custom-token-exchange) allows you to enable applications to exchange their existing tokens for Auth0 tokens when calling the /oauth/token endpoint. This is useful for advanced integration use cases, such as: -- Get Auth0 tokens for another audience - Integrate an external identity provider - Migrate to Auth0 @@ -716,11 +715,10 @@ final didStore = ```dart final credentials = await auth0.api.customTokenExchange( subjectToken: 'external-idp-token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', - audience: 'https://api.example.com', - scopes: {'openid', 'profile', 'email'}, + subjectTokenType: 'urn:acme:legacy-token', + audience: 'https://api.example.com', // Optional + scopes: {'openid', 'profile', 'email'}, // Optional, defaults to {'openid', 'profile', 'email'} organization: 'org_abc123', // Optional - parameters: {'custom_param': 'value'} // Optional ); ``` @@ -732,11 +730,10 @@ final credentials = await auth0.api.customTokenExchange( ```dart final credentials = await auth0Web.customTokenExchange( subjectToken: 'external-idp-token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', - audience: 'https://api.example.com', - scopes: {'openid', 'profile', 'email'}, + subjectTokenType: 'urn:acme:legacy-token', + audience: 'https://api.example.com', // Optional + scopes: {'openid', 'profile', 'email'}, // Optional organizationId: 'org_abc123', // Optional - parameters: {'custom_param': 'value'} // Optional ); ``` diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt index 8eadab55e..31f82ec4f 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/CustomTokenExchangeApiRequestHandlerTest.kt @@ -23,7 +23,7 @@ import java.util.* class CustomTokenExchangeApiRequestHandlerTest { @Test fun `should throw when missing subjectToken`() { - val options = hashMapOf("subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt") + val options = hashMapOf("subjectTokenType" to "urn:acme:legacy-token") val handler = CustomTokenExchangeApiRequestHandler() val mockApi = mock() val mockAccount = mock() @@ -53,7 +53,7 @@ class CustomTokenExchangeApiRequestHandlerTest { fun `should call success with required parameters only`() { val options = hashMapOf( "subjectToken" to "external-token-123", - "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt" + "subjectTokenType" to "urn:acme:legacy-token" ) val handler = CustomTokenExchangeApiRequestHandler() val mockApi = mock() @@ -88,7 +88,7 @@ class CustomTokenExchangeApiRequestHandlerTest { handler.handle(mockApi, request, mockResult) verify(mockApi).customTokenExchange( - "urn:ietf:params:oauth:token-type:jwt", + "urn:acme:legacy-token", "external-token-123", null ) @@ -101,7 +101,7 @@ class CustomTokenExchangeApiRequestHandlerTest { fun `should handle error callback`() { val options = hashMapOf( "subjectToken" to "invalid-token", - "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt" + "subjectTokenType" to "urn:acme:legacy-token" ) val handler = CustomTokenExchangeApiRequestHandler() val mockApi = mock() @@ -172,7 +172,7 @@ class CustomTokenExchangeApiRequestHandlerTest { fun `should include scopes when provided`() { val options = hashMapOf( "subjectToken" to "external-token-789", - "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt", + "subjectTokenType" to "urn:acme:legacy-token", "scopes" to arrayListOf("openid", "profile", "email", "read:data") ) val handler = CustomTokenExchangeApiRequestHandler() @@ -254,7 +254,7 @@ class CustomTokenExchangeApiRequestHandlerTest { fun `should include organization when provided`() { val options = hashMapOf( "subjectToken" to "external-token-org", - "subjectTokenType" to "urn:ietf:params:oauth:token-type:jwt", + "subjectTokenType" to "urn:acme:legacy-token", "organization" to "org_abc123" ) val handler = CustomTokenExchangeApiRequestHandler() @@ -285,7 +285,7 @@ class CustomTokenExchangeApiRequestHandlerTest { handler.handle(mockApi, request, mockResult) verify(mockApi).customTokenExchange( - "urn:ietf:params:oauth:token-type:jwt", + "urn:acme:legacy-token", "external-token-org", "org_abc123" ) diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift index cb2c17f6c..f415b3b75 100644 --- a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPICustomTokenExchangeMethodHandler.swift @@ -24,9 +24,11 @@ struct AuthAPICustomTokenExchangeMethodHandler: MethodHandler { guard let subjectTokenType = arguments[Argument.subjectTokenType] as? String else { return callback(FlutterError(from: .requiredArgumentMissing(Argument.subjectTokenType.rawValue))) } + guard let scopes = arguments[Argument.scopes] as? [String], !scopes.isEmpty else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.scopes.rawValue))) + } - let scopes = arguments[Argument.scopes] as? [String] ?? [] - let scope = scopes.isEmpty ? "openid profile email" : scopes.asSpaceSeparatedString + let scope = scopes.asSpaceSeparatedString let audience = arguments[Argument.audience] as? String let organization = arguments[Argument.organization] as? String diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift index ad3f8a5ab..6f34ec019 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPICustomTokenExchangeMethodHandlerTests.swift @@ -58,7 +58,7 @@ extension AuthAPICustomTokenExchangeMethodHandlerTests { expiresIn: Date(timeIntervalSinceNow: 3600), scope: "openid profile email" ) - spy.onCustomTokenExchange = { _, _, _, _ in + spy.onCustomTokenExchange = { _, _, _, _, _ in return self.spy.request(returning: credentials) } sut.handle(with: arguments()) { result in @@ -87,17 +87,7 @@ extension AuthAPICustomTokenExchangeMethodHandlerTests { wait(for: [expectation]) } - func testWorksWithEmptyScopes() { - let expectation = self.expectation(description: "Called with empty scopes") - spy.onCustomTokenExchange = { _, _, _, scope, _ in - XCTAssertEqual(scope, "openid profile email") - expectation.fulfill() - } - var args = arguments() - args[Argument.scopes.rawValue] = [] - sut.handle(with: args) { _ in } - wait(for: [expectation]) - } + func testWorksWithoutOrganization() { let expectation = self.expectation(description: "Called without organization") diff --git a/auth0_flutter/lib/auth0_flutter_web.dart b/auth0_flutter/lib/auth0_flutter_web.dart index 22fb6b801..d12e14c6d 100644 --- a/auth0_flutter/lib/auth0_flutter_web.dart +++ b/auth0_flutter/lib/auth0_flutter_web.dart @@ -289,24 +289,27 @@ class Auth0Web { /// system or another identity provider. /// /// * [subjectTokenType] (required) - A URI identifying the type of the - /// subject token according to RFC 8693. Common examples: - /// - `urn:ietf:params:oauth:token-type:jwt` for JWT tokens - /// - `urn:ietf:params:oauth:token-type:id_token` for OIDC ID tokens - /// - `urn:ietf:params:oauth:token-type:access_token` for OAuth access tokens - /// - Custom URNs like `urn:example:external-token` for custom token types + /// subject token according to RFC 8693. Must be a namespaced URI under your + /// organization's control. + /// + /// **Forbidden patterns:** + /// - `^urn:ietf:params:oauth:*` (IETF reserved) + /// - `^https://auth0.com/*` (Auth0 reserved) + /// - `^urn:auth0:*` (Auth0 reserved) + /// + /// **Example:** `urn:acme:legacy-system-token` /// /// * [audience] - Optional API identifier for which you want to receive an - /// access token. If not specified, uses the audience from [onLoad] configuration - /// or the default audience configured in your Auth0 application. + /// access token. Must match exactly with an API identifier configured in + /// your Auth0 tenant. If not provided, falls back to the client's default audience. /// /// * [scopes] - Optional set of scopes to request. - /// These scopes determine what information and permissions the resulting tokens will have. + /// These scopes determine what permissions the resulting tokens will have. + /// Subject to API authorization policies configured in Auth0. /// /// * [organizationId] - Optional organization ID or name to associate the - /// token exchange with a specific organization context. - /// - /// * [parameters] - Additional custom parameters to include in the token - /// exchange request. These can be processed by Auth0 Actions or Rules. + /// token exchange with a specific organization context. The organization ID + /// will be present in the access token payload. /// /// **Returns** a [Credentials] object containing: /// * `accessToken` - The new Auth0 access token @@ -327,7 +330,7 @@ class Auth0Web { /// try { /// final credentials = await auth0Web.customTokenExchange( /// subjectToken: externalToken, - /// subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', + /// subjectTokenType: 'urn:acme:legacy-system-token', /// audience: 'https://myapi.example.com', /// scopes: {'openid', 'profile', 'email', 'read:data'}, /// ); @@ -344,7 +347,7 @@ class Auth0Web { /// * Network issues prevent the exchange request /// /// See also: - /// * [Token Exchange Documentation](https://auth0.com/docs/authenticate/login/token-exchange) + /// * [Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange) /// * [RFC 8693 Specification](https://tools.ietf.org/html/rfc8693) Future customTokenExchange({ required final String subjectToken, diff --git a/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart b/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart index abca4d55a..02ebf9149 100644 --- a/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart +++ b/auth0_flutter/test/web/extensions/exchange_token_options_extension_test.dart @@ -9,14 +9,14 @@ void main() { test('converts ExchangeTokenOptions with required fields only', () { final options = ExchangeTokenOptions( subjectToken: 'external-token-123', - subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', + subjectTokenType: 'urn:acme:legacy-token', ); final result = options.toInteropExchangeTokenOptions(); expect(result.subject_token, 'external-token-123'); expect(result.subject_token_type, - 'urn:ietf:params:oauth:token-type:jwt'); + 'urn:acme:legacy-token'); expect(result.audience, isNull); expect(result.scope, isNull); expect(result.organization, isNull); diff --git a/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart index f3c2b95d2..c41906621 100644 --- a/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart +++ b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart @@ -1,3 +1,15 @@ +/// Options for custom token exchange on web platforms. +/// +/// This class encapsulates the parameters needed to exchange an external +/// token for Auth0 tokens using the OAuth 2.0 Token Exchange flow (RFC 8693). +/// +/// **Parameters:** +/// +/// * [subjectToken] - The external token to be exchanged (required) +/// * [subjectTokenType] - A URI that indicates the type of the subject token, +/// * [audience] - The API identifier for which the access token is requested (optional) +/// * [scopes] - Set of OAuth scopes to request (optional) +/// * [organizationId] - organization ID or name of the organization to authenticate with (optional) class ExchangeTokenOptions { final String subjectToken; final String subjectTokenType; From ae788f7bfeeca067d049d7641900f5c041ac9f3f Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:09:48 +0530 Subject: [PATCH 18/42] chore:Fixed errors from flutter analyze (#727) --- auth0_flutter/lib/auth0_flutter_web.dart | 21 +++++++++++-------- .../lib/src/mobile/authentication_api.dart | 10 ++++----- .../test/mobile/web_authentication_test.dart | 3 ++- .../test/web/auth0_flutter_web_test.dart | 3 ++- .../lib/src/auth0_flutter_web_platform.dart | 4 +++- .../lib/src/web/exchange_token_options.dart | 6 ++++-- 6 files changed, 28 insertions(+), 19 deletions(-) diff --git a/auth0_flutter/lib/auth0_flutter_web.dart b/auth0_flutter/lib/auth0_flutter_web.dart index d12e14c6d..73a5f5c5b 100644 --- a/auth0_flutter/lib/auth0_flutter_web.dart +++ b/auth0_flutter/lib/auth0_flutter_web.dart @@ -278,15 +278,15 @@ class Auth0Web { /// RFC 8693 Token Exchange. /// /// This method implements the OAuth 2.0 Token Exchange flow, allowing you to - /// exchange a token from an external provider for Auth0 tokens. This is useful - /// when integrating with external identity providers or custom authentication - /// systems. + /// exchange a token from an external provider for Auth0 tokens. + /// This is useful when integrating with external identity providers or + /// custom authentication systems. /// /// **Parameters:** /// /// * [subjectToken] (required) - The token being exchanged from the external - /// provider. For example, this might be a JWT from your custom authentication - /// system or another identity provider. + /// provider. For example, this might be a JWT from your + /// custom authentication system or another identity provider. /// /// * [subjectTokenType] (required) - A URI identifying the type of the /// subject token according to RFC 8693. Must be a namespaced URI under your @@ -301,7 +301,8 @@ class Auth0Web { /// /// * [audience] - Optional API identifier for which you want to receive an /// access token. Must match exactly with an API identifier configured in - /// your Auth0 tenant. If not provided, falls back to the client's default audience. + /// your Auth0 tenant. If not provided, falls back to the client's default + /// audience. /// /// * [scopes] - Optional set of scopes to request. /// These scopes determine what permissions the resulting tokens will have. @@ -316,13 +317,15 @@ class Auth0Web { /// * `idToken` - The Auth0 ID token with user information /// * `expiresAt` - When the access token expires /// * `scopes` - The granted scopes - /// * `refreshToken` - Optional refresh token (if offline_access scope was requested) + /// * `refreshToken` - Optional refresh token /// /// **Requirements:** /// /// 1. Configure a Token Exchange profile in your Auth0 Dashboard - /// 2. Implement validation logic in an Auth0 Action to verify the external token - /// 3. Grant your Auth0 application the `urn:auth0:oauth2:grant-type:token-exchange` permission + /// 2. Implement validation logic in an Auth0 Action to verify the external + /// token + /// 3. Grant your Auth0 application the + /// `urn:auth0:oauth2:grant-type:token-exchange` permission /// /// **Example:** /// diff --git a/auth0_flutter/lib/src/mobile/authentication_api.dart b/auth0_flutter/lib/src/mobile/authentication_api.dart index 15599d083..5dd576048 100644 --- a/auth0_flutter/lib/src/mobile/authentication_api.dart +++ b/auth0_flutter/lib/src/mobile/authentication_api.dart @@ -349,20 +349,20 @@ class AuthenticationApi { scopes: scopes, parameters: parameters))); - /// Performs a custom token exchange to obtain Auth0 credentials using an + /// Performs a custom token exchange to obtain Auth0 credentials using an /// existing identity provider token. /// /// This method allows you to exchange tokens from external identity providers - /// for Auth0 tokens, enabling seamless integration with existing authentication - /// systems. + /// for Auth0 tokens, enabling seamless integration with existing + /// authentication systems. /// /// ## Endpoint /// https://auth0.com/docs/api/authentication#token-exchange /// /// ## Notes /// - /// * [subjectToken] is the token obtained from the external identity provider. - /// * [subjectTokenType] specifies the format of the subject token (e.g., + /// * [subjectToken] the token obtained from the external identity provider. + /// * [subjectTokenType] specifies the format of the subject token (e.g., /// 'http://acme.com/legacy-token'). /// * [audience] relates to the API Identifier you want to reference in your /// access tokens. See [API settings](https://auth0.com/docs/get-started/apis/api-settings) diff --git a/auth0_flutter/test/mobile/web_authentication_test.dart b/auth0_flutter/test/mobile/web_authentication_test.dart index c15db2f55..e1fb62106 100644 --- a/auth0_flutter/test/mobile/web_authentication_test.dart +++ b/auth0_flutter/test/mobile/web_authentication_test.dart @@ -329,7 +329,8 @@ void main() { expect(verificationResult.options.useHTTPS, true); expect(verificationResult.options.returnTo, 'https://example.com/logout'); expect(verificationResult.options.federated, true); - expect(verificationResult.options.allowedBrowsers, ['com.android.chrome']); + expect(verificationResult.options.allowedBrowsers, + ['com.android.chrome']); }); }); diff --git a/auth0_flutter/test/web/auth0_flutter_web_test.dart b/auth0_flutter/test/web/auth0_flutter_web_test.dart index 477da1084..a3ba905c8 100644 --- a/auth0_flutter/test/web/auth0_flutter_web_test.dart +++ b/auth0_flutter/test/web/auth0_flutter_web_test.dart @@ -497,7 +497,8 @@ void main() { for (final errorCase in errorCases) { when(mockClientProxy.exchangeToken(any)) - .thenThrow(createJsException(errorCase['code']!, errorCase['message']!)); + .thenThrow(createJsException(errorCase['code']!, + errorCase['message']!)); await expectLater( auth0.customTokenExchange( diff --git a/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart b/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart index f2a4d2e46..ad13215fe 100644 --- a/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart @@ -41,7 +41,9 @@ abstract class Auth0FlutterWebPlatform extends PlatformInterface { } Future customTokenExchange(final ExchangeTokenOptions options) { - throw UnimplementedError('web.customTokenExchange has not been implemented'); + throw UnimplementedError( + 'web.customTokenExchange has not been implemented' + ); } Future hasValidCredentials() { diff --git a/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart index c41906621..8fda15570 100644 --- a/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart +++ b/auth0_flutter_platform_interface/lib/src/web/exchange_token_options.dart @@ -7,9 +7,11 @@ /// /// * [subjectToken] - The external token to be exchanged (required) /// * [subjectTokenType] - A URI that indicates the type of the subject token, -/// * [audience] - The API identifier for which the access token is requested (optional) +/// * [audience] - The API identifier for which the access token is +/// requested (optional) /// * [scopes] - Set of OAuth scopes to request (optional) -/// * [organizationId] - organization ID or name of the organization to authenticate with (optional) +/// * [organizationId] - organization ID or name of the organization to +/// authenticate with (optional) class ExchangeTokenOptions { final String subjectToken; final String subjectTokenType; From e81599b83c95f08b09077c070b07ca6fb2ce89bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:12:12 +0530 Subject: [PATCH 19/42] build(deps-dev): bump lodash from 4.17.21 to 4.17.23 in /appium-test (#729) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- appium-test/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/appium-test/package-lock.json b/appium-test/package-lock.json index 6f3b7c0b1..dd8aa575f 100644 --- a/appium-test/package-lock.json +++ b/appium-test/package-lock.json @@ -1751,9 +1751,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, From 721d875bb66129acd1be900c8b61fbc84b4ddee5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:21:23 +0530 Subject: [PATCH 20/42] build(deps): bump ruby/setup-ruby from 1.285.0 to 1.286.0 in /.github/actions/setup-darwin (#728) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Prince Mathew <17837162+pmathew92@users.noreply.github.com> --- .github/actions/setup-darwin/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index 07014a980..a4dc9da16 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@e69dcf3ded5967f30d7ef595704928d91cdae930 # pin@v1.285.0 + uses: ruby/setup-ruby@90be1154f987f4dc0fe0dd0feedac9e473aa4ba8 # pin@v1.286.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true From d2ea48463c8348db9a6ce30e17857e2cf0bda234 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Thu, 22 Jan 2026 12:14:53 +0530 Subject: [PATCH 21/42] fix flutter analyse errors --- .../auth_custom_token_exchange_options_test.dart | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart b/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart index a871f1fb3..5270c58da 100644 --- a/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart +++ b/auth0_flutter_platform_interface/test/auth_custom_token_exchange_options_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('AuthCustomTokenExchangeOptions', () { test('creates options with required parameters', () { - final options = AuthCustomTokenExchangeOptions( + const options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', subjectTokenType: 'http://acme.com/legacy-token', ); @@ -17,7 +17,7 @@ void main() { }); test('creates options with all parameters', () { - final options = AuthCustomTokenExchangeOptions( + const options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', subjectTokenType: 'http://acme.com/legacy-token', audience: 'https://example.com/api', @@ -33,7 +33,7 @@ void main() { }); test('toMap includes all properties', () { - final options = AuthCustomTokenExchangeOptions( + const options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', subjectTokenType: 'http://acme.com/legacy-token', audience: 'https://example.com/api', @@ -51,7 +51,7 @@ void main() { }); test('toMap excludes null audience', () { - final options = AuthCustomTokenExchangeOptions( + const options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', subjectTokenType: 'http://acme.com/legacy-token', ); @@ -62,7 +62,7 @@ void main() { }); test('toMap excludes null organization', () { - final options = AuthCustomTokenExchangeOptions( + const options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', subjectTokenType: 'http://acme.com/legacy-token', ); @@ -73,7 +73,7 @@ void main() { }); test('toMap includes organization when provided', () { - final options = AuthCustomTokenExchangeOptions( + const options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', subjectTokenType: 'http://acme.com/legacy-token', organization: 'org_abc123', @@ -86,10 +86,9 @@ void main() { }); test('toMap includes empty scopes', () { - final options = AuthCustomTokenExchangeOptions( + const options = AuthCustomTokenExchangeOptions( subjectToken: 'existing-token', subjectTokenType: 'http://acme.com/legacy-token', - scopes: {}, ); final map = options.toMap(); From 616f58925298a639d53fac752e6954c5cb30ad66 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Thu, 22 Jan 2026 13:27:46 +0530 Subject: [PATCH 22/42] pubspec update for beta release --- auth0_flutter_platform_interface/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth0_flutter_platform_interface/pubspec.yaml b/auth0_flutter_platform_interface/pubspec.yaml index c99b551cf..219e7ed9b 100644 --- a/auth0_flutter_platform_interface/pubspec.yaml +++ b/auth0_flutter_platform_interface/pubspec.yaml @@ -1,6 +1,6 @@ name: auth0_flutter_platform_interface description: A common platform interface for the auth0_flutter federated plugin. -version: 2.0.0-beta.1 +version: 2.0.0-beta.2 homepage: https://github.com/auth0/auth0-flutter From 89467e8a842c335a227d08a3540cdf72808f3c13 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Thu, 22 Jan 2026 12:53:28 +0530 Subject: [PATCH 23/42] Release afpi-v2.0.0-beta.2 --- auth0_flutter_platform_interface/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/auth0_flutter_platform_interface/CHANGELOG.md b/auth0_flutter_platform_interface/CHANGELOG.md index f3c4b75ee..1fa9e8457 100644 --- a/auth0_flutter_platform_interface/CHANGELOG.md +++ b/auth0_flutter_platform_interface/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## [afpi-v2.0.0-beta.2](https://github.com/auth0/auth0-flutter/tree/afpi-v2.0.0-beta.2) (2026-01-22) +[Full Changelog](https://github.com/auth0/auth0-flutter/compare/afpi-v2.0.0-beta.1...afpi-v2.0.0-beta.2) + +**Added** +- feat: add custom token exchange support across all platforms [\#721](https://github.com/auth0/auth0-flutter/pull/721) ([sanchitmehta94](https://github.com/sanchitmehta94)) +- feat: Add allowedBrowsers parameter to logout API [\#726](https://github.com/auth0/auth0-flutter/pull/726) ([pmathew92](https://github.com/pmathew92)) + ## [afpi-v2.0.0-beta.1](https://github.com/auth0/auth0-flutter/tree/afpi-v2.0.0-beta.1) (2025-12-10) [Full Changelog](https://github.com/auth0/auth0-flutter/compare/afpi-v1.14.0...afpi-v2.0.0-beta.1) From f688cd447c4c0ab1641d6847ffdcfd10ba464b3e Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Thu, 22 Jan 2026 15:00:10 +0530 Subject: [PATCH 24/42] Release af-v2.0.0-beta.2 --- auth0_flutter/CHANGELOG.md | 7 +++++++ auth0_flutter/lib/src/version.dart | 2 +- auth0_flutter/pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/auth0_flutter/CHANGELOG.md b/auth0_flutter/CHANGELOG.md index 7a7e10b43..a9b0c67fc 100644 --- a/auth0_flutter/CHANGELOG.md +++ b/auth0_flutter/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## [af-v2.0.0-beta.2](https://github.com/auth0/auth0-flutter/tree/af-v2.0.0-beta.2) (2026-01-22) +[Full Changelog](https://github.com/auth0/auth0-flutter/compare/af-v2.0.0-beta.1...af-v2.0.0-beta.2) + +**Added** +- feat: add custom token exchange support across all platforms [\#721](https://github.com/auth0/auth0-flutter/pull/721) ([sanchitmehta94](https://github.com/sanchitmehta94)) +- feat: Add allowedBrowsers parameter to logout API [\#726](https://github.com/auth0/auth0-flutter/pull/726) ([pmathew92](https://github.com/pmathew92)) + ## [af-v2.0.0-beta.1](https://github.com/auth0/auth0-flutter/tree/af-v2.0.0-beta.1) (2025-12-10) [Full Changelog](https://github.com/auth0/auth0-flutter/compare/af-v1.14.0...af-v2.0.0-beta.1) diff --git a/auth0_flutter/lib/src/version.dart b/auth0_flutter/lib/src/version.dart index 8e5652dbe..b133f69c7 100644 --- a/auth0_flutter/lib/src/version.dart +++ b/auth0_flutter/lib/src/version.dart @@ -1 +1 @@ -const String version = '2.0.0-beta.1'; +const String version = '2.0.0-beta.2'; diff --git a/auth0_flutter/pubspec.yaml b/auth0_flutter/pubspec.yaml index 19be56d89..561befcda 100644 --- a/auth0_flutter/pubspec.yaml +++ b/auth0_flutter/pubspec.yaml @@ -8,7 +8,7 @@ environment: flutter: ">=3.24.0" dependencies: - auth0_flutter_platform_interface: ^2.0.0-beta.1 + auth0_flutter_platform_interface: ^2.0.0-beta.2 flutter: sdk: flutter flutter_web_plugins: From 7afabd8679d39669848e8cb81b010d4a8afe4716 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Thu, 22 Jan 2026 15:13:12 +0530 Subject: [PATCH 25/42] udpate podspecs --- auth0_flutter/darwin/auth0_flutter.podspec | 2 +- auth0_flutter/ios/auth0_flutter.podspec | 2 +- auth0_flutter/macos/auth0_flutter.podspec | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/auth0_flutter/darwin/auth0_flutter.podspec b/auth0_flutter/darwin/auth0_flutter.podspec index 169f8f050..12106aafd 100644 --- a/auth0_flutter/darwin/auth0_flutter.podspec +++ b/auth0_flutter/darwin/auth0_flutter.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'auth0_flutter' - s.version = '2.0.0-beta.1' + s.version = '2.0.0-beta.2' s.summary = 'Auth0 SDK for Flutter' s.description = 'Auth0 SDK for Flutter Android and iOS apps.' s.homepage = 'https://auth0.com' diff --git a/auth0_flutter/ios/auth0_flutter.podspec b/auth0_flutter/ios/auth0_flutter.podspec index 169f8f050..12106aafd 100644 --- a/auth0_flutter/ios/auth0_flutter.podspec +++ b/auth0_flutter/ios/auth0_flutter.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'auth0_flutter' - s.version = '2.0.0-beta.1' + s.version = '2.0.0-beta.2' s.summary = 'Auth0 SDK for Flutter' s.description = 'Auth0 SDK for Flutter Android and iOS apps.' s.homepage = 'https://auth0.com' diff --git a/auth0_flutter/macos/auth0_flutter.podspec b/auth0_flutter/macos/auth0_flutter.podspec index 169f8f050..12106aafd 100644 --- a/auth0_flutter/macos/auth0_flutter.podspec +++ b/auth0_flutter/macos/auth0_flutter.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'auth0_flutter' - s.version = '2.0.0-beta.1' + s.version = '2.0.0-beta.2' s.summary = 'Auth0 SDK for Flutter' s.description = 'Auth0 SDK for Flutter Android and iOS apps.' s.homepage = 'https://auth0.com' From e5cf989ca0bd9ccc679425e89fb67fb5755e6989 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Thu, 22 Jan 2026 15:37:10 +0530 Subject: [PATCH 26/42] udpate pubspec --- auth0_flutter/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth0_flutter/pubspec.yaml b/auth0_flutter/pubspec.yaml index 561befcda..92a075bb6 100644 --- a/auth0_flutter/pubspec.yaml +++ b/auth0_flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: auth0_flutter description: Auth0 SDK for Flutter. Easily integrate Auth0 into Android / iOS Flutter apps. -version: 2.0.0-beta.1 +version: 2.0.0-beta.2 homepage: https://github.com/auth0/auth0-flutter environment: From fd9e914d9485d99789834729b0ef0571e3d1f77e Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Fri, 23 Jan 2026 18:35:53 +0530 Subject: [PATCH 27/42] make PR runs with release workflow that triggers flutter analyze Removed flags from flutter analyze command. --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d1d0798e6..c234d36aa 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -60,7 +60,7 @@ jobs: - name: Analyze auth0_flutter_platform_interface package working-directory: auth0_flutter_platform_interface - run: flutter analyze --no-fatal-warnings --no-fatal-infos + run: flutter analyze test-auth0_flutter: name: Test auth0_flutter Flutter package From 27f7ad750e68ba69568e114a6e29e764a5bb6793 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Fri, 23 Jan 2026 18:38:34 +0530 Subject: [PATCH 28/42] Avoid running always failing smoke tests for iOS Comment out iOS smoke test configuration in workflow --- .github/workflows/main.yml | 60 +++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c234d36aa..474d39dfe 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -160,41 +160,41 @@ jobs: with: name: iOS coverage path: auth0_flutter/example/ios/cobertura + # TODO: fix both android and iOS smoke testcases and uncomment them. + # test-ios-smoke: + # name: Run native iOS smoke tests using Xcode ${{ matrix.xcode }} + # runs-on: macos-15-large + # environment: ${{ github.event.pull_request.head.repo.fork && 'external' || 'internal' }} - test-ios-smoke: - name: Run native iOS smoke tests using Xcode ${{ matrix.xcode }} - runs-on: macos-15-large - environment: ${{ github.event.pull_request.head.repo.fork && 'external' || 'internal' }} - - env: - platform: iOS - USER_EMAIL: ${{ secrets.USER_EMAIL }} - USER_PASSWORD: ${{ secrets.USER_PASSWORD }} + # env: + # platform: iOS + # USER_EMAIL: ${{ secrets.USER_EMAIL }} + # USER_PASSWORD: ${{ secrets.USER_PASSWORD }} - strategy: - matrix: - xcode: - - '26.0' + # strategy: + # matrix: + # xcode: + # - '26.0' - steps: - - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + # steps: + # - name: Checkout + # uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - - name: Set up environment - uses: ./.github/actions/setup-darwin - with: - platform: ${{ env.platform }} - ruby: ${{ env.ruby }} - flutter: ${{ env.flutter }} - xcode: ${{ matrix.xcode }} - auth0-domain: ${{ vars.AUTH0_DOMAIN }} - auth0-client-id: ${{ vars.AUTH0_CLIENT_ID }} + # - name: Set up environment + # uses: ./.github/actions/setup-darwin + # with: + # platform: ${{ env.platform }} + # ruby: ${{ env.ruby }} + # flutter: ${{ env.flutter }} + # xcode: ${{ matrix.xcode }} + # auth0-domain: ${{ vars.AUTH0_DOMAIN }} + # auth0-client-id: ${{ vars.AUTH0_CLIENT_ID }} - - name: Run iOS smoke tests - uses: ./.github/actions/smoke-tests-darwin - with: - platform: ${{ env.platform }} - destination: ${{ format('{0}{1}', 'platform=iOS Simulator,name=', env.ios-simulator) }} + # - name: Run iOS smoke tests + # uses: ./.github/actions/smoke-tests-darwin + # with: + # platform: ${{ env.platform }} + # destination: ${{ format('{0}{1}', 'platform=iOS Simulator,name=', env.ios-simulator) }} test-macos-unit: name: Run native macOS unit tests using Xcode ${{ matrix.xcode }} From 9186451b11ec12e239a3bbf0224308df45dd39a5 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Fri, 23 Jan 2026 18:48:49 +0530 Subject: [PATCH 29/42] CI cleanup Comment out macOS smoke tests and related environment setup. --- .github/workflows/main.yml | 59 +++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 474d39dfe..1fcd63b0d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -233,40 +233,41 @@ jobs: platform: ${{ env.platform }} destination: platform=macOS,arch=x86_64 - test-macos-smoke: - name: Run native macOS smoke tests using Xcode ${{ matrix.xcode }} - runs-on: macos-15 - environment: ${{ github.event.pull_request.head.repo.fork && 'external' || 'internal' }} + # TODO: fix macos smoke tests + # test-macos-smoke: + # name: Run native macOS smoke tests using Xcode ${{ matrix.xcode }} + # runs-on: macos-15 + # environment: ${{ github.event.pull_request.head.repo.fork && 'external' || 'internal' }} - env: - platform: macOS - USER_EMAIL: ${{ secrets.USER_EMAIL }} - USER_PASSWORD: ${{ secrets.USER_PASSWORD }} + # env: + # platform: macOS + # USER_EMAIL: ${{ secrets.USER_EMAIL }} + # USER_PASSWORD: ${{ secrets.USER_PASSWORD }} - strategy: - matrix: - xcode: - - '26.0' + # strategy: + # matrix: + # xcode: + # - '26.0' - steps: - - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + # steps: + # - name: Checkout + # uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - - name: Set up environment - uses: ./.github/actions/setup-darwin - with: - platform: ${{ env.platform }} - ruby: ${{ env.ruby }} - flutter: ${{ env.flutter }} - xcode: ${{ matrix.xcode }} - auth0-domain: ${{ vars.AUTH0_DOMAIN }} - auth0-client-id: ${{ vars.AUTH0_CLIENT_ID }} + # - name: Set up environment + # uses: ./.github/actions/setup-darwin + # with: + # platform: ${{ env.platform }} + # ruby: ${{ env.ruby }} + # flutter: ${{ env.flutter }} + # xcode: ${{ matrix.xcode }} + # auth0-domain: ${{ vars.AUTH0_DOMAIN }} + # auth0-client-id: ${{ vars.AUTH0_CLIENT_ID }} - - name: Run macOS smoke tests - uses: ./.github/actions/smoke-tests-darwin - with: - platform: ${{ env.platform }} - destination: platform=macOS,arch=x86_64 + # - name: Run macOS smoke tests + # uses: ./.github/actions/smoke-tests-darwin + # with: + # platform: ${{ env.platform }} + # destination: platform=macOS,arch=x86_64 test-android-unit: name: Run native Android unit tests From e654edc91e2a831ea42473b5721b2c84180e724e Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 30 Jan 2026 11:45:28 +0530 Subject: [PATCH 30/42] Resolved merge conflict --- .../CredentialsManager/CredentialsManagerHandler.swift | 1 + auth0_flutter/lib/src/mobile/credentials_manager.dart | 7 +++++++ .../credentials-manager/credentials_manager_platform.dart | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift index 953ea574a..3bb1efbae 100644 --- a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift +++ b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift @@ -133,6 +133,7 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { case .clear: return CredentialsManagerClearMethodHandler(credentialsManager: credentialsManager) case .userInfo: return CredentialsManagerUserInfoMethodHandler(credentialsManager: credentialsManager) case .renew: return CredentialsManagerRenewMethodHandler(credentialsManager: credentialsManager) + case .userInfo: return CredentialsManagerUserInfoMethodHandler(credentialsManager: credentialsManager) } } diff --git a/auth0_flutter/lib/src/mobile/credentials_manager.dart b/auth0_flutter/lib/src/mobile/credentials_manager.dart index cd31b54e1..e967964dd 100644 --- a/auth0_flutter/lib/src/mobile/credentials_manager.dart +++ b/auth0_flutter/lib/src/mobile/credentials_manager.dart @@ -1,4 +1,5 @@ import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; +import 'package:auth0_flutter_platform_interface/src/user_info.dart'; /// Abstract CredentialsManager that can be used to provide a custom /// CredentialManager. @@ -15,6 +16,8 @@ abstract class CredentialsManager { final Map parameters = const {}, }); + Future getIDTokenContents(); + Future storeCredentials(final Credentials credentials); Future hasValidCredentials({ @@ -83,6 +86,10 @@ class DefaultCredentialsManager extends CredentialsManager { _createApiRequest(RenewCredentialsOptions(parameters: parameters))); >>>>>>> main + @override + Future getIDTokenContents() => + CredentialsManagerPlatform.instance.getIDTokenContents(_createApiRequest(null)); + /// Stores the given credentials in the storage. Must have an `access_token` /// or `id_token` and a `expires_in` value. @override diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart index 5120ccb45..bc61c68a5 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart @@ -49,6 +49,11 @@ abstract class CredentialsManagerPlatform extends PlatformInterface { throw UnimplementedError('renewCredentials() has not been implemented'); } + /// Retrieves the ID token contents from the native storage. + Future getIDTokenContents(final CredentialsManagerRequest request) { + throw UnimplementedError('getIDTokenContents() has not been implemented'); + } + /// Removes the credentials from the native storage if present. Future clearCredentials(final CredentialsManagerRequest request) { throw UnimplementedError('clearCredentials() has not been implemented'); From ebd57d93eeb74d5e7dec13dddc72b33c49b84d5e Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 30 Jan 2026 13:12:06 +0530 Subject: [PATCH 31/42] Renamed getIDTokenContents to user --- auth0_flutter/lib/src/mobile/credentials_manager.dart | 9 +++++---- .../credentials_manager_platform.dart | 6 +++--- .../method_channel_credentials_manager.dart | 6 ++++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/auth0_flutter/lib/src/mobile/credentials_manager.dart b/auth0_flutter/lib/src/mobile/credentials_manager.dart index e967964dd..a124822be 100644 --- a/auth0_flutter/lib/src/mobile/credentials_manager.dart +++ b/auth0_flutter/lib/src/mobile/credentials_manager.dart @@ -1,5 +1,4 @@ import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; -import 'package:auth0_flutter_platform_interface/src/user_info.dart'; /// Abstract CredentialsManager that can be used to provide a custom /// CredentialManager. @@ -16,7 +15,7 @@ abstract class CredentialsManager { final Map parameters = const {}, }); - Future getIDTokenContents(); + Future user(); Future storeCredentials(final Credentials credentials); @@ -86,9 +85,11 @@ class DefaultCredentialsManager extends CredentialsManager { _createApiRequest(RenewCredentialsOptions(parameters: parameters))); >>>>>>> main + /// Fetches the user profile associated with the stored credentials. + /// Returns null if no credentials are present in storage. @override - Future getIDTokenContents() => - CredentialsManagerPlatform.instance.getIDTokenContents(_createApiRequest(null)); + Future user() => + CredentialsManagerPlatform.instance.user(_createApiRequest(null)); /// Stores the given credentials in the storage. Must have an `access_token` /// or `id_token` and a `expires_in` value. diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart index bc61c68a5..79a6fb577 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart @@ -49,9 +49,9 @@ abstract class CredentialsManagerPlatform extends PlatformInterface { throw UnimplementedError('renewCredentials() has not been implemented'); } - /// Retrieves the ID token contents from the native storage. - Future getIDTokenContents(final CredentialsManagerRequest request) { - throw UnimplementedError('getIDTokenContents() has not been implemented'); + /// Retrieves the user info contents from the native storage. + Future user(final CredentialsManagerRequest request) { + throw UnimplementedError('user() has not been implemented'); } /// Removes the credentials from the native storage if present. diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart index a50ec937c..09fb8a3b7 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart @@ -65,10 +65,12 @@ class MethodChannelCredentialsManager extends CredentialsManagerPlatform { return result ?? true; } + /// Fetches the user profile associated with the stored credentials. + /// Returns null is no credentials are stored @override - Future getIDTokenContents(final CredentialsManagerRequest request) async { + Future user(final CredentialsManagerRequest request) async { final Map result = await _invokeMapRequest(method: credentialsManagerGetUserProfileMethod, request: request); - return UserInfo.fromJson(result); + return UserProfile.fromMap(result); } /// Removes the credentials from the native storage if present. From 1d5246dc40a7f4571e485469836e884a3a7a6567 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 30 Jan 2026 19:04:47 +0530 Subject: [PATCH 32/42] Added the code to get profile from android SDK --- .../GetIdTokenContentRequestHandler.kt | 36 ++++--------------- ...dentialsManagerUserInfoMethodHandler.swift | 6 +++- 2 files changed, 12 insertions(+), 30 deletions(-) diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt index 729d53e80..1f39ce100 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt @@ -1,12 +1,10 @@ package com.auth0.auth0_flutter.request_handlers.credentials_manager import android.content.Context -import com.auth0.android.authentication.storage.CredentialsManagerException import com.auth0.android.authentication.storage.SecureCredentialsManager import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMap import io.flutter.plugin.common.MethodChannel -import java.io.Serializable -import java.lang.Exception class GetIdTokenContentRequestHandler: CredentialsManagerRequestHandler { @@ -17,31 +15,11 @@ class GetIdTokenContentRequestHandler: CredentialsManagerRequestHandler { request: MethodCallRequest, result: MethodChannel.Result ) { - result.success( - mapOf( - "id" to credentialsManager.userProfile?.getId(), - "name" to credentialsManager.userProfile?.name, - "nickname" to credentialsManager.userProfile?.nickname, - "pictureURL" to credentialsManager.userProfile?.pictureURL, - "email" to credentialsManager.userProfile?.email, - "isEmailVerified" to credentialsManager.userProfile?.isEmailVerified, - "familyName" to credentialsManager.userProfile?.familyName, - "createdAt" to credentialsManager.userProfile?.createdAt, - "identities" to credentialsManager.userProfile?.getIdentities()?.map { - mapOf( - "provider" to it.provider, - "id" to it.connection, - "isSocial" to it.isSocial, - "accessToken" to it.accessToken, - "accessTokenSecret" to it.accessTokenSecret, - "profileInfo" to it.getProfileInfo() - ) - }, - "extraInfo" to credentialsManager.userProfile?.getExtraInfo(), - "userMetadata" to credentialsManager.userProfile?.getUserMetadata(), - "appMetadata" to credentialsManager.userProfile?.getAppMetadata(), - "givenName" to credentialsManager.userProfile?.givenName - ) - ) + val userProfile = credentialsManager.userProfile + if (userProfile != null) { + result.success(userProfile.toMap()) + } else { + result.success(null) + } } } diff --git a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift index aa18955c8..f09591825 100644 --- a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift +++ b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift @@ -11,6 +11,10 @@ struct CredentialsManagerUserInfoMethodHandler: MethodHandler { let credentialsManager: CredentialsManager func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { - callback(credentialsManager.user) + if let user = credentialsManager.user { + callback(user.asDictionary()) + } else { + callback(nil) + } } } From 2424bf98d72a09e90a50aee630a95419427e2acd Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 30 Jan 2026 19:06:18 +0530 Subject: [PATCH 33/42] Added changes to darwin and other classes --- ...redentialsManagerUserInfoMethodHandler.swift | 17 +---------------- ...redentialsManagerUserInfoMethodHandler.swift | 1 + 2 files changed, 2 insertions(+), 16 deletions(-) mode change 100644 => 120000 auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift create mode 120000 auth0_flutter/macos/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift diff --git a/auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift b/auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift deleted file mode 100644 index aa18955c8..000000000 --- a/auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift +++ /dev/null @@ -1,16 +0,0 @@ - -import Auth0 - -#if os(iOS) -import Flutter -#else -import FlutterMacOS -#endif - -struct CredentialsManagerUserInfoMethodHandler: MethodHandler { - let credentialsManager: CredentialsManager - - func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { - callback(credentialsManager.user) - } -} diff --git a/auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift b/auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift new file mode 120000 index 000000000..355099aac --- /dev/null +++ b/auth0_flutter/ios/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift b/auth0_flutter/macos/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift new file mode 120000 index 000000000..355099aac --- /dev/null +++ b/auth0_flutter/macos/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift \ No newline at end of file From e39ff3301802460efb6a2ad5482df3a8248f2c8d Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Sun, 1 Feb 2026 19:41:15 +0530 Subject: [PATCH 34/42] Added test cases for the user API --- .../auth0/auth0_flutter/Auth0FlutterPlugin.kt | 2 +- ...> GetCredentialsUserInfoRequestHandler.kt} | 2 +- ...CredentialsManagerMethodCallHandlerTest.kt | 64 ++++- ...etCredentialsUserInfoRequestHandlerTest.kt | 207 ++++++++++++++++ .../CredentialsManagerHandlerTests.swift | 17 +- ...alsManagerUserInfoMethodHandlerTests.swift | 174 ++++++++++++++ .../mobile/authentication_api_test.mocks.dart | 222 ++++++++++++------ .../test/mobile/credentials_manager_test.dart | 77 +++++- .../credentials_manager_test.mocks.dart | 89 ++++--- .../mobile/web_authentication_test.mocks.dart | 188 +++++++++++---- .../method_channel_credentials_manager.dart | 12 +- ...thod_channel_credentials_manager_test.dart | 132 +++++++++++ 12 files changed, 1023 insertions(+), 163 deletions(-) rename auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/{GetIdTokenContentRequestHandler.kt => GetCredentialsUserInfoRequestHandler.kt} (90%) create mode 100644 auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandlerTest.kt create mode 100644 auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerUserInfoMethodHandlerTests.swift 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 7ab335f6f..edf628993 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 @@ -36,7 +36,7 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { SaveCredentialsRequestHandler(), HasValidCredentialsRequestHandler(), ClearCredentialsRequestHandler(), - GetIdTokenContentRequestHandler() + GetCredentialsUserInfoRequestHandler() )) private val dpopCallHandler = Auth0FlutterDPoPMethodCallHandler(listOf( GetDPoPHeadersApiRequestHandler(), diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandler.kt similarity index 90% rename from auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt rename to auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandler.kt index 1f39ce100..1be371e4d 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetIdTokenContentRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandler.kt @@ -7,7 +7,7 @@ import com.auth0.auth0_flutter.toMap import io.flutter.plugin.common.MethodChannel -class GetIdTokenContentRequestHandler: CredentialsManagerRequestHandler { +class GetCredentialsUserInfoRequestHandler: CredentialsManagerRequestHandler { override val method: String = "credentialsManager#getUserInfo" override fun handle( credentialsManager: SecureCredentialsManager, diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt index 5f413dc62..96d9b6b03 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.SharedPreferences import com.auth0.auth0_flutter.request_handlers.credentials_manager.ClearCredentialsRequestHandler import com.auth0.auth0_flutter.request_handlers.credentials_manager.CredentialsManagerRequestHandler +import com.auth0.auth0_flutter.request_handlers.credentials_manager.GetCredentialsUserInfoRequestHandler import com.auth0.auth0_flutter.request_handlers.credentials_manager.HasValidCredentialsRequestHandler import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel.Result @@ -139,7 +140,7 @@ class CredentialsManagerMethodCallHandlerTest { val managerCaptor = argumentCaptor() verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) - + MatcherAssert.assertThat( "Manager should be reused when configuration is identical", managerCaptor.firstValue, @@ -193,7 +194,7 @@ class CredentialsManagerMethodCallHandlerTest { val managerCaptor = argumentCaptor() verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) - + MatcherAssert.assertThat( "New manager should be created when domain changes", managerCaptor.firstValue, @@ -233,7 +234,7 @@ class CredentialsManagerMethodCallHandlerTest { val arguments2 = hashMapOf( "_account" to mapOf( "domain" to "test.auth0.com", - "clientId" to "client-2", + "clientId" to "client-2", ), "_userAgent" to mapOf( "name" to "auth0-flutter", @@ -245,7 +246,7 @@ class CredentialsManagerMethodCallHandlerTest { val managerCaptor = argumentCaptor() verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) - + MatcherAssert.assertThat( "New manager should be created when clientId changes", managerCaptor.firstValue, @@ -294,7 +295,7 @@ class CredentialsManagerMethodCallHandlerTest { "version" to "1.0.0" ), "credentialsManagerConfiguration" to mapOf( - "android" to mapOf("sharedPreferencesName" to "prefs_2") + "android" to mapOf("sharedPreferencesName" to "prefs_2") ) ) val call2 = MethodCall("credentialsManager#clearCredentials", arguments2) @@ -302,7 +303,7 @@ class CredentialsManagerMethodCallHandlerTest { val managerCaptor = argumentCaptor() verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) - + MatcherAssert.assertThat( "New manager should be created when sharedPreferencesName changes", managerCaptor.firstValue, @@ -349,14 +350,14 @@ class CredentialsManagerMethodCallHandlerTest { "name" to "auth0-flutter", "version" to "1.0.0" ), - "useDPoP" to true + "useDPoP" to true ) val call2 = MethodCall("credentialsManager#clearCredentials", arguments2) handler.onMethodCall(call2, mockResult) val managerCaptor = argumentCaptor() verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) - + MatcherAssert.assertThat( "New manager should be created when useDPoP flag changes", managerCaptor.firstValue, @@ -414,7 +415,7 @@ class CredentialsManagerMethodCallHandlerTest { val managerCaptor = argumentCaptor() verify(clearCredentialsHandler, times(2)).handle(managerCaptor.capture(), any(), any(), any()) - + MatcherAssert.assertThat( "New manager should be created when localAuthentication changes", managerCaptor.firstValue, @@ -426,7 +427,7 @@ class CredentialsManagerMethodCallHandlerTest { fun `handler should reuse manager across different method calls with same configuration`() { val clearCredentialsHandler = mock() val hasValidCredentialsHandler = mock() - + `when`(clearCredentialsHandler.method).thenReturn("credentialsManager#clearCredentials") `when`(hasValidCredentialsHandler.method).thenReturn("credentialsManager#hasValidCredentials") @@ -451,15 +452,54 @@ class CredentialsManagerMethodCallHandlerTest { val clearManagerCaptor = argumentCaptor() val hasValidManagerCaptor = argumentCaptor() - + verify(clearCredentialsHandler).handle(clearManagerCaptor.capture(), any(), any(), any()) verify(hasValidCredentialsHandler).handle(hasValidManagerCaptor.capture(), any(), any(), any()) - + MatcherAssert.assertThat( "Same manager should be reused across different method calls with identical configuration", clearManagerCaptor.firstValue, CoreMatchers.sameInstance(hasValidManagerCaptor.firstValue) ) } + + @Test + fun `handler invokes GetCredentialsUserInfoRequestHandler for credentialsManager#user`() { + val getUserInfoHandler = mock() + `when`(getUserInfoHandler.handles).thenReturn("credentialsManager#user") + + runCallHandler( + "credentialsManager#user", + requestHandlers = listOf(getUserInfoHandler) + ) { result -> + verify(getUserInfoHandler).handle(any(), any(), any(), any()) + } + } + + @Test + fun `handler returns UserProfile result from GetCredentialsUserInfoRequestHandler`() { + val getUserInfoHandler = mock() + `when`(getUserInfoHandler.handles).thenReturn("credentialsManager#user") + + val userProfileMap = mapOf( + "sub" to "auth0|123456", + "name" to "John Doe", + "email" to "john.doe@example.com" + ) + + doAnswer { invocation -> + val result = invocation.getArgument(3) + result.success(userProfileMap) + }.`when`(getUserInfoHandler).handle(any(), any(), any(), any()) + + val mockResult = mock() + val handler = CredentialsManagerMethodCallHandler(listOf(getUserInfoHandler)) + handler.activity = mock() + handler.context = mock() + + handler.onMethodCall(MethodCall("credentialsManager#user", defaultArguments), mockResult) + + verify(mockResult).success(userProfileMap) + } } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandlerTest.kt new file mode 100644 index 000000000..6b7102b3a --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandlerTest.kt @@ -0,0 +1,207 @@ +package com.auth0.auth0_flutter.request_handlers.credentials_manager + +import com.auth0.android.Auth0 +import com.auth0.android.authentication.storage.SecureCredentialsManager +import com.auth0.android.result.UserProfile +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMap +import io.flutter.plugin.common.MethodChannel.Result +import org.hamcrest.CoreMatchers +import org.hamcrest.MatcherAssert +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner +import java.util.Date + +@RunWith(RobolectricTestRunner::class) +class GetCredentialsUserInfoRequestHandlerTest { + + @Test + fun `handles returns correct method`() { + val handler = GetCredentialsUserInfoRequestHandler() + MatcherAssert.assertThat( + handler.handles, + CoreMatchers.`is`("credentialsManager#user") + ) + } + + @Test + fun `returns user profile map when credentials exist`() { + val handler = GetCredentialsUserInfoRequestHandler() + val options = hashMapOf() + val mockResult = mock() + val mockAccount = mock() + val mockCredentialsManager = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val userProfile = UserProfile( + "auth0|123456", + "John Doe", + "john.doe@example.com", + true, + "John", + "Doe", + "johndoe", + "https://example.com/picture.jpg", + Date(), + mapOf("role" to "admin") + ) + + `when`(mockCredentialsManager.userProfile).thenReturn(userProfile) + + handler.handle( + mockCredentialsManager, + mock(), + request, + mockResult + ) + + verify(mockResult).success(userProfile.toMap()) + } + + @Test + fun `returns null when no credentials stored`() { + val handler = GetCredentialsUserInfoRequestHandler() + val options = hashMapOf() + val mockResult = mock() + val mockAccount = mock() + val mockCredentialsManager = mock() + val request = MethodCallRequest(account = mockAccount, options) + + `when`(mockCredentialsManager.userProfile).thenReturn(null) + + handler.handle( + mockCredentialsManager, + mock(), + request, + mockResult + ) + + verify(mockResult).success(null) + } + + @Test + fun `returns null when userProfile is null`() { + val handler = GetCredentialsUserInfoRequestHandler() + val options = hashMapOf() + val mockResult = mock() + val mockAccount = mock() + val mockCredentialsManager = mock() + val request = MethodCallRequest(account = mockAccount, options) + + `when`(mockCredentialsManager.userProfile).thenReturn(null) + + handler.handle( + mockCredentialsManager, + mock(), + request, + mockResult + ) + + verify(mockResult).success(null) + } + + @Test + fun `converts all UserProfile fields correctly`() { + val handler = GetCredentialsUserInfoRequestHandler() + val options = hashMapOf() + val mockResult = mock() + val mockAccount = mock() + val mockCredentialsManager = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val updatedAt = Date() + val userProfile = UserProfile( + "auth0|123456", + "John Doe", + "john.doe@example.com", + true, + "John", + "Doe", + "johndoe", + "https://example.com/picture.jpg", + updatedAt, + mapOf("role" to "admin", "department" to "engineering") + ) + + `when`(mockCredentialsManager.userProfile).thenReturn(userProfile) + + handler.handle( + mockCredentialsManager, + mock(), + request, + mockResult + ) + + argumentCaptor>().apply { + verify(mockResult).success(capture()) + + val result = firstValue + MatcherAssert.assertThat(result["sub"], CoreMatchers.`is`("auth0|123456")) + MatcherAssert.assertThat(result["name"], CoreMatchers.`is`("John Doe")) + MatcherAssert.assertThat(result["email"], CoreMatchers.`is`("john.doe@example.com")) + MatcherAssert.assertThat(result["email_verified"], CoreMatchers.`is`(true)) + MatcherAssert.assertThat(result["given_name"], CoreMatchers.`is`("John")) + MatcherAssert.assertThat(result["family_name"], CoreMatchers.`is`("Doe")) + MatcherAssert.assertThat(result["nickname"], CoreMatchers.`is`("johndoe")) + MatcherAssert.assertThat(result["picture"], CoreMatchers.`is`("https://example.com/picture.jpg")) + MatcherAssert.assertThat(result["custom_claims"], CoreMatchers.notNullValue()) + + @Suppress("UNCHECKED_CAST") + val customClaims = result["custom_claims"] as Map + MatcherAssert.assertThat(customClaims["role"], CoreMatchers.`is`("admin")) + MatcherAssert.assertThat(customClaims["department"], CoreMatchers.`is`("engineering")) + } + } + + @Test + fun `handles custom claims in UserProfile`() { + val handler = GetCredentialsUserInfoRequestHandler() + val options = hashMapOf() + val mockResult = mock() + val mockAccount = mock() + val mockCredentialsManager = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val userProfile = UserProfile( + "auth0|123456", + "John Doe", + null, + false, + null, + null, + null, + null, + null, + mapOf( + "custom_field_1" to "value1", + "custom_field_2" to 123, + "custom_field_3" to true + ) + ) + + `when`(mockCredentialsManager.userProfile).thenReturn(userProfile) + + handler.handle( + mockCredentialsManager, + mock(), + request, + mockResult + ) + + argumentCaptor>().apply { + verify(mockResult).success(capture()) + + val result = firstValue + MatcherAssert.assertThat(result["custom_claims"], CoreMatchers.notNullValue()) + + @Suppress("UNCHECKED_CAST") + val customClaims = result["custom_claims"] as Map + MatcherAssert.assertThat(customClaims["custom_field_1"], CoreMatchers.`is`("value1")) + MatcherAssert.assertThat(customClaims["custom_field_2"], CoreMatchers.`is`(123)) + MatcherAssert.assertThat(customClaims["custom_field_3"], CoreMatchers.`is`(true)) + } + } +} diff --git a/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerHandlerTests.swift b/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerHandlerTests.swift index 660f944db..7e6df9f91 100644 --- a/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerHandlerTests.swift @@ -259,7 +259,8 @@ extension CredentialsManagerHandlerTests { .save: CredentialsManagerSaveMethodHandler.self, .hasValid: CredentialsManagerHasValidMethodHandler.self, .get: CredentialsManagerGetMethodHandler.self, - .clear: CredentialsManagerClearMethodHandler.self + .clear: CredentialsManagerClearMethodHandler.self, + .userInfo: CredentialsManagerUserInfoMethodHandler.self ] methodHandlers.forEach { method, methodHandler in let methodCall = FlutterMethodCall(methodName: method.rawValue, arguments: arguments()) @@ -276,6 +277,20 @@ extension CredentialsManagerHandlerTests { wait(for: expectations) } + func testReturnsUserInfoMethodHandlerForUserInfoMethod() { + let methodCall = FlutterMethodCall(methodName: CredentialsManagerHandler.Method.userInfo.rawValue, arguments: arguments()) + let expectation = self.expectation(description: "Returned CredentialsManagerUserInfoMethodHandler") + + sut.methodHandlerProvider = { method, client in + let result = CredentialsManagerHandler().methodHandlerProvider(method, client) + XCTAssertTrue(type(of: result) == CredentialsManagerUserInfoMethodHandler.self) + expectation.fulfill() + return result + } + sut.handle(methodCall) { _ in } + wait(for: [expectation]) + } + func testCallsMethodHandlers() { var expectations: [XCTestExpectation] = [] CredentialsManagerHandler.Method.allCases.forEach { method in diff --git a/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerUserInfoMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerUserInfoMethodHandlerTests.swift new file mode 100644 index 000000000..224513281 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerUserInfoMethodHandlerTests.swift @@ -0,0 +1,174 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +class CredentialsManagerUserInfoMethodHandlerTests: XCTestCase { + var spyAuthentication: SpyAuthentication! + var spyStorage: SpyCredentialsStorage! + var sut: CredentialsManagerUserInfoMethodHandler! + + override func setUpWithError() throws { + spyAuthentication = SpyAuthentication() + spyStorage = SpyCredentialsStorage() + let credentialsManager = CredentialsManager(authentication: spyAuthentication, storage: spyStorage) + sut = CredentialsManagerUserInfoMethodHandler(credentialsManager: credentialsManager) + } +} + +// MARK: - Method Name + +extension CredentialsManagerUserInfoMethodHandlerTests { + func testHandlesCorrectMethodName() { + XCTAssertTrue(sut.method == .userInfo) + } +} + +// MARK: - UserInfo Result + +extension CredentialsManagerUserInfoMethodHandlerTests { + func testCallsCredentialsManagerUserInfoProperty() { + let credentials = Credentials(accessToken: "accessToken", + tokenType: "tokenType", + idToken: testIdToken, + refreshToken: "refreshToken", + expiresIn: Date(timeIntervalSinceNow: 3600), + scope: "foo bar") + let data = try? NSKeyedArchiver.archivedData(withRootObject: credentials, requiringSecureCoding: true) + let expectation = self.expectation(description: "Called userInfo property") + spyStorage.getEntryReturnValue = data + + sut.handle(with: [:]) { _ in + XCTAssertTrue(self.spyStorage.calledGetEntry) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } + + func testReturnsUserInfoDictionaryWhenUserInfoExists() { + let credentials = Credentials(accessToken: "accessToken", + tokenType: "tokenType", + idToken: testIdToken, + refreshToken: "refreshToken", + expiresIn: Date(timeIntervalSinceNow: 3600), + scope: "foo bar") + let data = try? NSKeyedArchiver.archivedData(withRootObject: credentials, requiringSecureCoding: true) + let expectation = self.expectation(description: "Returned user info dictionary") + spyStorage.getEntryReturnValue = data + + sut.handle(with: [:]) { result in + guard case .success(let value) = result else { + return XCTFail("Expected success but got error") + } + + guard let userInfo = value as? [String: Any] else { + return XCTFail("Expected dictionary but got \(type(of: value))") + } + + XCTAssertEqual(userInfo["sub"] as? String, "auth0|user123") + XCTAssertEqual(userInfo["name"] as? String, "John Doe") + XCTAssertEqual(userInfo["email"] as? String, "john@example.com") + XCTAssertEqual(userInfo["nickname"] as? String, "johndoe") + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } + + func testReturnsNilWhenCredentialsHaveNoUserInfo() { + let expectation = self.expectation(description: "Returned nil") + spyStorage.getEntryReturnValue = nil + + sut.handle(with: [:]) { result in + guard case .success(let value) = result else { + return XCTFail("Expected success but got error") + } + + XCTAssertNil(value) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } + + func testConvertsAllUserInfoFieldsToDictionaryCorrectly() { + let credentials = Credentials(accessToken: "accessToken", + tokenType: "tokenType", + idToken: testIdToken, + refreshToken: "refreshToken", + expiresIn: Date(timeIntervalSinceNow: 3600), + scope: "foo bar") + let data = try? NSKeyedArchiver.archivedData(withRootObject: credentials, requiringSecureCoding: true) + let expectation = self.expectation(description: "Converted all fields correctly") + spyStorage.getEntryReturnValue = data + + sut.handle(with: [:]) { result in + guard case .success(let value) = result else { + return XCTFail("Expected success but got error") + } + + guard let userInfo = value as? [String: Any] else { + return XCTFail("Expected dictionary") + } + + XCTAssertNotNil(userInfo["sub"]) + XCTAssertNotNil(userInfo["name"]) + XCTAssertNotNil(userInfo["email"]) + XCTAssertNotNil(userInfo["nickname"]) + XCTAssertNotNil(userInfo["picture"]) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } + + func testHandlesUserInfoWithCustomClaims() { + let credentials = Credentials(accessToken: "accessToken", + tokenType: "tokenType", + idToken: testIdTokenWithCustomClaims, + refreshToken: "refreshToken", + expiresIn: Date(timeIntervalSinceNow: 3600), + scope: "foo bar") + let data = try? NSKeyedArchiver.archivedData(withRootObject: credentials, requiringSecureCoding: true) + let expectation = self.expectation(description: "Handled custom claims") + spyStorage.getEntryReturnValue = data + + sut.handle(with: [:]) { result in + guard case .success(let value) = result else { + return XCTFail("Expected success but got error") + } + + guard let userInfo = value as? [String: Any] else { + return XCTFail("Expected dictionary") + } + + XCTAssertNotNil(userInfo["sub"]) + + if let customClaims = userInfo["custom_claims"] as? [String: Any] { + XCTAssertNotNil(customClaims) + } + + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } + + func testProducesErrorWhenGetFails() { + let expectedError = CredentialsManagerError.noCredentials + let expectation = self.expectation(description: "Produced error") + spyStorage.getEntryError = expectedError + + sut.handle(with: [:]) { result in + guard case .failure(let error) = result else { + return XCTFail("Expected error but got success") + } + + XCTAssertTrue(error is CredentialsManagerError) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } +} + +// MARK: - Test Helpers + +private let testIdToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHx1c2VyMTIzIiwibmFtZSI6IkpvaG4gRG9lIiwiZW1haWwiOiJqb2huQGV4YW1wbGUuY29tIiwibmlja25hbWUiOiJqb2huZG9lIiwicGljdHVyZSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcGljdHVyZS5qcGciLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + +private let testIdTokenWithCustomClaims = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHx1c2VyMTIzIiwibmFtZSI6IkpvaG4gRG9lIiwiZW1haWwiOiJqb2huQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiZGVwYXJ0bWVudCI6ImVuZ2luZWVyaW5nIiwiaWF0IjoxNTE2MjM5MDIyfQ.1234567890" diff --git a/auth0_flutter/test/mobile/authentication_api_test.mocks.dart b/auth0_flutter/test/mobile/authentication_api_test.mocks.dart index 1425d33e8..014e5cbbc 100644 --- a/auth0_flutter/test/mobile/authentication_api_test.mocks.dart +++ b/auth0_flutter/test/mobile/authentication_api_test.mocks.dart @@ -1,5 +1,5 @@ -// Mocks generated by Mockito 5.4.5 from annotations -// in auth0_flutter/test/mobile/authentication_api_test.dart. +// Mocks generated by Mockito 5.4.4 from annotations +// in auth0_flutter/example/ios/.symlinks/plugins/auth0_flutter/test/mobile/authentication_api_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes @@ -20,30 +20,49 @@ import 'authentication_api_test.dart' as _i4; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class class _FakeCredentials_0 extends _i1.SmartFake implements _i2.Credentials { - _FakeCredentials_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeCredentials_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeChallenge_1 extends _i1.SmartFake implements _i3.Challenge { - _FakeChallenge_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeChallenge_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeUserProfile_2 extends _i1.SmartFake implements _i2.UserProfile { - _FakeUserProfile_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeUserProfile_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeDatabaseUser_3 extends _i1.SmartFake implements _i2.DatabaseUser { - _FakeDatabaseUser_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeDatabaseUser_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [TestPlatform]. @@ -56,130 +75,189 @@ class MockTestPlatform extends _i1.Mock implements _i4.TestPlatform { @override _i5.Future<_i2.Credentials> login( - _i3.ApiRequest<_i3.AuthLoginOptions>? request, - ) => + _i3.ApiRequest<_i3.AuthLoginOptions>? request) => (super.noSuchMethod( - Invocation.method(#login, [request]), - returnValue: _i5.Future<_i2.Credentials>.value( - _FakeCredentials_0(this, Invocation.method(#login, [request])), + Invocation.method( + #login, + [request], ), + returnValue: _i5.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #login, + [request], + ), + )), ) as _i5.Future<_i2.Credentials>); @override _i5.Future<_i2.Credentials> loginWithOtp( - _i3.ApiRequest<_i3.AuthLoginWithOtpOptions>? request, - ) => + _i3.ApiRequest<_i3.AuthLoginWithOtpOptions>? request) => (super.noSuchMethod( - Invocation.method(#loginWithOtp, [request]), - returnValue: _i5.Future<_i2.Credentials>.value( - _FakeCredentials_0( - this, - Invocation.method(#loginWithOtp, [request]), - ), + Invocation.method( + #loginWithOtp, + [request], ), + returnValue: _i5.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #loginWithOtp, + [request], + ), + )), ) as _i5.Future<_i2.Credentials>); @override _i5.Future<_i3.Challenge> multifactorChallenge( - _i3.ApiRequest<_i3.AuthMultifactorChallengeOptions>? request, - ) => + _i3.ApiRequest<_i3.AuthMultifactorChallengeOptions>? request) => (super.noSuchMethod( - Invocation.method(#multifactorChallenge, [request]), - returnValue: _i5.Future<_i3.Challenge>.value( - _FakeChallenge_1( - this, - Invocation.method(#multifactorChallenge, [request]), - ), + Invocation.method( + #multifactorChallenge, + [request], ), + returnValue: _i5.Future<_i3.Challenge>.value(_FakeChallenge_1( + this, + Invocation.method( + #multifactorChallenge, + [request], + ), + )), ) as _i5.Future<_i3.Challenge>); @override _i5.Future startPasswordlessWithEmail( - _i3.ApiRequest<_i3.AuthPasswordlessLoginOptions>? request, - ) => + _i3.ApiRequest<_i3.AuthPasswordlessLoginOptions>? request) => (super.noSuchMethod( - Invocation.method(#startPasswordlessWithEmail, [request]), + Invocation.method( + #startPasswordlessWithEmail, + [request], + ), returnValue: _i5.Future.value(), returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); @override _i5.Future<_i2.Credentials> loginWithEmailCode( - _i3.ApiRequest<_i3.AuthLoginWithCodeOptions>? request, - ) => + _i3.ApiRequest<_i3.AuthLoginWithCodeOptions>? request) => (super.noSuchMethod( - Invocation.method(#loginWithEmailCode, [request]), - returnValue: _i5.Future<_i2.Credentials>.value( - _FakeCredentials_0( - this, - Invocation.method(#loginWithEmailCode, [request]), - ), + Invocation.method( + #loginWithEmailCode, + [request], ), + returnValue: _i5.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #loginWithEmailCode, + [request], + ), + )), ) as _i5.Future<_i2.Credentials>); @override _i5.Future startPasswordlessWithPhoneNumber( - _i3.ApiRequest<_i3.AuthPasswordlessLoginOptions>? request, - ) => + _i3.ApiRequest<_i3.AuthPasswordlessLoginOptions>? request) => (super.noSuchMethod( - Invocation.method(#startPasswordlessWithPhoneNumber, [request]), + Invocation.method( + #startPasswordlessWithPhoneNumber, + [request], + ), returnValue: _i5.Future.value(), returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); @override _i5.Future<_i2.Credentials> loginWithSmsCode( - _i3.ApiRequest<_i3.AuthLoginWithCodeOptions>? request, - ) => + _i3.ApiRequest<_i3.AuthLoginWithCodeOptions>? request) => (super.noSuchMethod( - Invocation.method(#loginWithSmsCode, [request]), - returnValue: _i5.Future<_i2.Credentials>.value( - _FakeCredentials_0( - this, - Invocation.method(#loginWithSmsCode, [request]), - ), + Invocation.method( + #loginWithSmsCode, + [request], ), + returnValue: _i5.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #loginWithSmsCode, + [request], + ), + )), ) as _i5.Future<_i2.Credentials>); @override _i5.Future<_i2.UserProfile> userInfo( - _i3.ApiRequest<_i3.AuthUserInfoOptions>? request, - ) => + _i3.ApiRequest<_i3.AuthUserInfoOptions>? request) => (super.noSuchMethod( - Invocation.method(#userInfo, [request]), - returnValue: _i5.Future<_i2.UserProfile>.value( - _FakeUserProfile_2(this, Invocation.method(#userInfo, [request])), + Invocation.method( + #userInfo, + [request], ), + returnValue: _i5.Future<_i2.UserProfile>.value(_FakeUserProfile_2( + this, + Invocation.method( + #userInfo, + [request], + ), + )), ) as _i5.Future<_i2.UserProfile>); @override _i5.Future<_i2.DatabaseUser> signup( - _i3.ApiRequest<_i3.AuthSignupOptions>? request, - ) => + _i3.ApiRequest<_i3.AuthSignupOptions>? request) => (super.noSuchMethod( - Invocation.method(#signup, [request]), - returnValue: _i5.Future<_i2.DatabaseUser>.value( - _FakeDatabaseUser_3(this, Invocation.method(#signup, [request])), + Invocation.method( + #signup, + [request], ), + returnValue: _i5.Future<_i2.DatabaseUser>.value(_FakeDatabaseUser_3( + this, + Invocation.method( + #signup, + [request], + ), + )), ) as _i5.Future<_i2.DatabaseUser>); @override _i5.Future<_i2.Credentials> renew( - _i3.ApiRequest<_i3.AuthRenewOptions>? request, - ) => + _i3.ApiRequest<_i3.AuthRenewOptions>? request) => + (super.noSuchMethod( + Invocation.method( + #renew, + [request], + ), + returnValue: _i5.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #renew, + [request], + ), + )), + ) as _i5.Future<_i2.Credentials>); + + @override + _i5.Future<_i2.Credentials> customTokenExchange( + _i3.ApiRequest<_i3.AuthCustomTokenExchangeOptions>? request) => (super.noSuchMethod( - Invocation.method(#renew, [request]), - returnValue: _i5.Future<_i2.Credentials>.value( - _FakeCredentials_0(this, Invocation.method(#renew, [request])), + Invocation.method( + #customTokenExchange, + [request], ), + returnValue: _i5.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #customTokenExchange, + [request], + ), + )), ) as _i5.Future<_i2.Credentials>); @override _i5.Future resetPassword( - _i3.ApiRequest<_i3.AuthResetPasswordOptions>? request, - ) => + _i3.ApiRequest<_i3.AuthResetPasswordOptions>? request) => (super.noSuchMethod( - Invocation.method(#resetPassword, [request]), + Invocation.method( + #resetPassword, + [request], + ), returnValue: _i5.Future.value(), returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); diff --git a/auth0_flutter/test/mobile/credentials_manager_test.dart b/auth0_flutter/test/mobile/credentials_manager_test.dart index f7f289927..05045349f 100644 --- a/auth0_flutter/test/mobile/credentials_manager_test.dart +++ b/auth0_flutter/test/mobile/credentials_manager_test.dart @@ -2,7 +2,7 @@ import 'package:auth0_flutter/auth0_flutter.dart'; import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; +import 'package:mockito/mockito.dart' hide Fake; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'credentials_manager_test.mocks.dart'; @@ -206,4 +206,79 @@ void main() { expect(verificationResult.options?.parameters, {'a': 'b'}); }); }); + + group('user', () { + test('passes through properties to the platform', () async { + final userProfile = UserProfile.fromMap({ + 'sub': 'auth0|123456', + 'name': 'John Doe', + 'email': 'john.doe@example.com', + 'nickname': 'johndoe' + }); + + when(mockedPlatform.user(argThat(isA()))) + .thenAnswer((final _) async => userProfile); + + final result = await DefaultCredentialsManager(account, userAgent).user(); + + final verificationResult = + verify(mockedPlatform.user(captureAny)).captured.single + as CredentialsManagerRequest; + expect(verificationResult.account.domain, 'test-domain'); + expect(verificationResult.account.clientId, 'test-clientId'); + expect(result, isNotNull); + expect(result!.sub, userProfile.sub); + expect(result.name, userProfile.name); + }); + + test('returns UserProfile when platform returns profile', () async { + final userProfile = UserProfile.fromMap({ + 'sub': 'auth0|123456', + 'name': 'John Doe', + 'email': 'john.doe@example.com', + 'email_verified': true, + 'nickname': 'johndoe', + 'picture': 'https://example.com/picture.jpg', + }); + + when(mockedPlatform.user(argThat(isA()))) + .thenAnswer((final _) async => userProfile); + + final result = await DefaultCredentialsManager(account, userAgent).user(); + + expect(result, isNotNull); + expect(result!.sub, 'auth0|123456'); + expect(result.name, 'John Doe'); + expect(result.email, 'john.doe@example.com'); + expect(result.isEmailVerified, true); + expect(result.nickname, 'johndoe'); + expect(result.pictureUrl.toString(), 'https://example.com/picture.jpg'); + }); + + test('returns null when platform returns null', () async { + when(mockedPlatform.user(argThat(isA()))) + .thenAnswer((final _) async => null); + + final result = await DefaultCredentialsManager(account, userAgent).user(); + + expect(result, isNull); + }); + + test('propagates exceptions from platform', () async { + when(mockedPlatform.user(argThat(isA()))) + .thenThrow(const CredentialsManagerException( + 'FAILED', 'No credentials stored', {})); + + Future actual() async { + return await DefaultCredentialsManager(account, userAgent).user(); + } + + await expectLater( + actual, + throwsA(predicate((final e) => + e is CredentialsManagerException && + e.code == 'FAILED' && + e.message == 'No credentials stored'))); + }); + }); } diff --git a/auth0_flutter/test/mobile/credentials_manager_test.mocks.dart b/auth0_flutter/test/mobile/credentials_manager_test.mocks.dart index 88452de2f..5a82cfa54 100644 --- a/auth0_flutter/test/mobile/credentials_manager_test.mocks.dart +++ b/auth0_flutter/test/mobile/credentials_manager_test.mocks.dart @@ -1,5 +1,5 @@ -// Mocks generated by Mockito 5.4.5 from annotations -// in auth0_flutter/test/mobile/credentials_manager_test.dart. +// Mocks generated by Mockito 5.4.4 from annotations +// in auth0_flutter/example/ios/.symlinks/plugins/auth0_flutter/test/mobile/credentials_manager_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes @@ -20,15 +20,19 @@ import 'credentials_manager_test.dart' as _i3; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class class _FakeCredentials_0 extends _i1.SmartFake implements _i2.Credentials { - _FakeCredentials_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeCredentials_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [TestPlatform]. @@ -41,56 +45,81 @@ class MockTestPlatform extends _i1.Mock implements _i3.TestPlatform { @override _i4.Future<_i2.Credentials> getCredentials( - _i5.CredentialsManagerRequest<_i5.GetCredentialsOptions>? request, - ) => + _i5.CredentialsManagerRequest<_i5.GetCredentialsOptions>? request) => (super.noSuchMethod( - Invocation.method(#getCredentials, [request]), - returnValue: _i4.Future<_i2.Credentials>.value( - _FakeCredentials_0( - this, - Invocation.method(#getCredentials, [request]), - ), + Invocation.method( + #getCredentials, + [request], ), + returnValue: _i4.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #getCredentials, + [request], + ), + )), ) as _i4.Future<_i2.Credentials>); @override _i4.Future<_i2.Credentials> renewCredentials( - _i5.CredentialsManagerRequest<_i5.RenewCredentialsOptions>? request, - ) => + _i5.CredentialsManagerRequest<_i5.RenewCredentialsOptions>? + request) => (super.noSuchMethod( - Invocation.method(#renewCredentials, [request]), - returnValue: _i4.Future<_i2.Credentials>.value( - _FakeCredentials_0( - this, - Invocation.method(#renewCredentials, [request]), - ), + Invocation.method( + #renewCredentials, + [request], ), + returnValue: _i4.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #renewCredentials, + [request], + ), + )), ) as _i4.Future<_i2.Credentials>); + @override + _i4.Future<_i2.UserProfile?> user( + _i5.CredentialsManagerRequest<_i5.RequestOptions?>? request) => + (super.noSuchMethod( + Invocation.method( + #user, + [request], + ), + returnValue: _i4.Future<_i2.UserProfile?>.value(), + ) as _i4.Future<_i2.UserProfile?>); + @override _i4.Future clearCredentials( - _i5.CredentialsManagerRequest<_i5.RequestOptions?>? request, - ) => + _i5.CredentialsManagerRequest<_i5.RequestOptions?>? request) => (super.noSuchMethod( - Invocation.method(#clearCredentials, [request]), + Invocation.method( + #clearCredentials, + [request], + ), returnValue: _i4.Future.value(false), ) as _i4.Future); @override _i4.Future saveCredentials( - _i5.CredentialsManagerRequest<_i5.SaveCredentialsOptions>? request, - ) => + _i5.CredentialsManagerRequest<_i5.SaveCredentialsOptions>? request) => (super.noSuchMethod( - Invocation.method(#saveCredentials, [request]), + Invocation.method( + #saveCredentials, + [request], + ), returnValue: _i4.Future.value(false), ) as _i4.Future); @override _i4.Future hasValidCredentials( - _i5.CredentialsManagerRequest<_i5.HasValidCredentialsOptions>? request, - ) => + _i5.CredentialsManagerRequest<_i5.HasValidCredentialsOptions>? + request) => (super.noSuchMethod( - Invocation.method(#hasValidCredentials, [request]), + Invocation.method( + #hasValidCredentials, + [request], + ), returnValue: _i4.Future.value(false), ) as _i4.Future); } diff --git a/auth0_flutter/test/mobile/web_authentication_test.mocks.dart b/auth0_flutter/test/mobile/web_authentication_test.mocks.dart index aa7aaa73b..551cbc534 100644 --- a/auth0_flutter/test/mobile/web_authentication_test.mocks.dart +++ b/auth0_flutter/test/mobile/web_authentication_test.mocks.dart @@ -1,5 +1,5 @@ -// Mocks generated by Mockito 5.4.5 from annotations -// in auth0_flutter/test/mobile/web_authentication_test.dart. +// Mocks generated by Mockito 5.4.4 from annotations +// in auth0_flutter/example/ios/.symlinks/plugins/auth0_flutter/test/mobile/web_authentication_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes @@ -20,15 +20,19 @@ import 'web_authentication_test.dart' as _i3; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class class _FakeCredentials_0 extends _i1.SmartFake implements _i2.Credentials { - _FakeCredentials_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeCredentials_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [TestPlatform]. @@ -41,24 +45,41 @@ class MockTestPlatform extends _i1.Mock implements _i3.TestPlatform { @override _i4.Future<_i2.Credentials> login( - _i5.WebAuthRequest<_i5.WebAuthLoginOptions>? request, - ) => + _i5.WebAuthRequest<_i5.WebAuthLoginOptions>? request) => (super.noSuchMethod( - Invocation.method(#login, [request]), - returnValue: _i4.Future<_i2.Credentials>.value( - _FakeCredentials_0(this, Invocation.method(#login, [request])), + Invocation.method( + #login, + [request], ), + returnValue: _i4.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #login, + [request], + ), + )), ) as _i4.Future<_i2.Credentials>); @override _i4.Future logout( - _i5.WebAuthRequest<_i5.WebAuthLogoutOptions>? request, - ) => + _i5.WebAuthRequest<_i5.WebAuthLogoutOptions>? request) => (super.noSuchMethod( - Invocation.method(#logout, [request]), + Invocation.method( + #logout, + [request], + ), returnValue: _i4.Future.value(), returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + + @override + void cancel() => super.noSuchMethod( + Invocation.method( + #cancel, + [], + ), + returnValueForMissingStub: null, + ); } /// A class which mocks [TestCMPlatform]. @@ -71,42 +92,81 @@ class MockTestCMPlatform extends _i1.Mock implements _i3.TestCMPlatform { @override _i4.Future<_i2.Credentials> getCredentials( - _i5.CredentialsManagerRequest<_i5.GetCredentialsOptions>? request, - ) => + _i5.CredentialsManagerRequest<_i5.GetCredentialsOptions>? request) => (super.noSuchMethod( - Invocation.method(#getCredentials, [request]), - returnValue: _i4.Future<_i2.Credentials>.value( - _FakeCredentials_0( - this, - Invocation.method(#getCredentials, [request]), + Invocation.method( + #getCredentials, + [request], + ), + returnValue: _i4.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #getCredentials, + [request], ), + )), + ) as _i4.Future<_i2.Credentials>); + + @override + _i4.Future<_i2.Credentials> renewCredentials( + _i5.CredentialsManagerRequest<_i5.RenewCredentialsOptions>? + request) => + (super.noSuchMethod( + Invocation.method( + #renewCredentials, + [request], ), + returnValue: _i4.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #renewCredentials, + [request], + ), + )), ) as _i4.Future<_i2.Credentials>); + @override + _i4.Future<_i2.UserProfile?> user( + _i5.CredentialsManagerRequest<_i5.RequestOptions?>? request) => + (super.noSuchMethod( + Invocation.method( + #user, + [request], + ), + returnValue: _i4.Future<_i2.UserProfile?>.value(), + ) as _i4.Future<_i2.UserProfile?>); + @override _i4.Future clearCredentials( - _i5.CredentialsManagerRequest<_i5.RequestOptions?>? request, - ) => + _i5.CredentialsManagerRequest<_i5.RequestOptions?>? request) => (super.noSuchMethod( - Invocation.method(#clearCredentials, [request]), + Invocation.method( + #clearCredentials, + [request], + ), returnValue: _i4.Future.value(false), ) as _i4.Future); @override _i4.Future saveCredentials( - _i5.CredentialsManagerRequest<_i5.SaveCredentialsOptions>? request, - ) => + _i5.CredentialsManagerRequest<_i5.SaveCredentialsOptions>? request) => (super.noSuchMethod( - Invocation.method(#saveCredentials, [request]), + Invocation.method( + #saveCredentials, + [request], + ), returnValue: _i4.Future.value(false), ) as _i4.Future); @override _i4.Future hasValidCredentials( - _i5.CredentialsManagerRequest<_i5.HasValidCredentialsOptions>? request, - ) => + _i5.CredentialsManagerRequest<_i5.HasValidCredentialsOptions>? + request) => (super.noSuchMethod( - Invocation.method(#hasValidCredentials, [request]), + Invocation.method( + #hasValidCredentials, + [request], + ), returnValue: _i4.Future.value(false), ) as _i4.Future); } @@ -127,40 +187,84 @@ class MockCredentialsManager extends _i1.Mock Map? parameters = const {}, }) => (super.noSuchMethod( - Invocation.method(#credentials, [], { - #minTtl: minTtl, - #scopes: scopes, - #parameters: parameters, - }), - returnValue: _i4.Future<_i2.Credentials>.value( - _FakeCredentials_0( - this, - Invocation.method(#credentials, [], { + Invocation.method( + #credentials, + [], + { + #minTtl: minTtl, + #scopes: scopes, + #parameters: parameters, + }, + ), + returnValue: _i4.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #credentials, + [], + { #minTtl: minTtl, #scopes: scopes, #parameters: parameters, - }), + }, ), + )), + ) as _i4.Future<_i2.Credentials>); + + @override + _i4.Future<_i2.Credentials> renewCredentials( + {Map? parameters = const {}}) => + (super.noSuchMethod( + Invocation.method( + #renewCredentials, + [], + {#parameters: parameters}, ), + returnValue: _i4.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #renewCredentials, + [], + {#parameters: parameters}, + ), + )), ) as _i4.Future<_i2.Credentials>); + @override + _i4.Future<_i2.UserProfile?> user() => (super.noSuchMethod( + Invocation.method( + #user, + [], + ), + returnValue: _i4.Future<_i2.UserProfile?>.value(), + ) as _i4.Future<_i2.UserProfile?>); + @override _i4.Future storeCredentials(_i2.Credentials? credentials) => (super.noSuchMethod( - Invocation.method(#storeCredentials, [credentials]), + Invocation.method( + #storeCredentials, + [credentials], + ), returnValue: _i4.Future.value(false), ) as _i4.Future); @override _i4.Future hasValidCredentials({int? minTtl = 0}) => (super.noSuchMethod( - Invocation.method(#hasValidCredentials, [], {#minTtl: minTtl}), + Invocation.method( + #hasValidCredentials, + [], + {#minTtl: minTtl}, + ), returnValue: _i4.Future.value(false), ) as _i4.Future); @override _i4.Future clearCredentials() => (super.noSuchMethod( - Invocation.method(#clearCredentials, []), + Invocation.method( + #clearCredentials, + [], + ), returnValue: _i4.Future.value(false), ) as _i4.Future); } diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart index 09fb8a3b7..7c8a1763c 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart @@ -17,7 +17,7 @@ const String credentialsManagerSaveCredentialsMethod = const String credentialsManagerGetCredentialsMethod = 'credentialsManager#getCredentials'; const String credentialsManagerGetUserProfileMethod = -'credentialsManager#getUserInfo'; + 'credentialsManager#user'; const String credentialsManagerClearCredentialsMethod = 'credentialsManager#clearCredentials'; const String credentialsManagerHasValidCredentialsMethod = @@ -69,8 +69,14 @@ class MethodChannelCredentialsManager extends CredentialsManagerPlatform { /// Returns null is no credentials are stored @override Future user(final CredentialsManagerRequest request) async { - final Map result = await _invokeMapRequest(method: credentialsManagerGetUserProfileMethod, request: request); - return UserProfile.fromMap(result); + final Map? result; + try { + result = await _channel.invokeMapMethod(credentialsManagerGetUserProfileMethod, request.toMap()); + } on PlatformException catch (e) { + throw CredentialsManagerException.fromPlatformException(e); + } + + return result != null ? UserProfile.fromMap(result) : null; } /// Removes the credentials from the native storage if present. diff --git a/auth0_flutter_platform_interface/test/method_channel_credentials_manager_test.dart b/auth0_flutter_platform_interface/test/method_channel_credentials_manager_test.dart index 4956c46ae..1df72907e 100644 --- a/auth0_flutter_platform_interface/test/method_channel_credentials_manager_test.dart +++ b/auth0_flutter_platform_interface/test/method_channel_credentials_manager_test.dart @@ -730,4 +730,136 @@ void main() { await expectLater(actual, throwsA(isA())); }); }); + + group('user', () { + test('calls the correct MethodChannel method', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => {'sub': '123', 'name': 'John Doe'}); + + await MethodChannelCredentialsManager() + .user(CredentialsManagerRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: null)); + + expect( + verify(mocked.methodCallHandler(captureAny)).captured.single.method, + 'credentialsManager#user'); + }); + + test('correctly maps all properties', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => {'sub': '123', 'name': 'John Doe'}); + + await MethodChannelCredentialsManager() + .user(CredentialsManagerRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: null)); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['_account']['domain'], 'test-domain'); + expect(verificationResult.arguments['_account']['clientId'], + 'test-clientId'); + expect(verificationResult.arguments['_userAgent']['name'], 'test-name'); + expect(verificationResult.arguments['_userAgent']['version'], + 'test-version'); + }); + + test('returns UserProfile when native returns profile data', () async { + final Map userProfileData = { + 'sub': 'auth0|123456', + 'name': 'John Doe', + 'email': 'john.doe@example.com', + 'email_verified': true, + 'nickname': 'johndoe', + 'picture': 'https://example.com/picture.jpg', + 'given_name': 'John', + 'family_name': 'Doe', + 'updated_at': '2023-11-01T22:16:35.760Z', + 'custom_claims': {'role': 'admin', 'department': 'engineering'} + }; + + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => userProfileData); + + final result = await MethodChannelCredentialsManager() + .user(CredentialsManagerRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: null)); + + verify(mocked.methodCallHandler(captureAny)); + + expect(result, isNotNull); + expect(result!.sub, userProfileData['sub']); + expect(result.name, userProfileData['name']); + expect(result.email, userProfileData['email']); + expect(result.isEmailVerified, userProfileData['email_verified']); + expect(result.nickname, userProfileData['nickname']); + expect(result.pictureUrl.toString(), userProfileData['picture']); + expect(result.givenName, userProfileData['given_name']); + expect(result.familyName, userProfileData['family_name']); + expect(result.customClaims?['role'], 'admin'); + expect(result.customClaims?['department'], 'engineering'); + }); + + test('returns null when native returns null', () async { + when(mocked.methodCallHandler(any)).thenAnswer((final _) async => null); + + final result = await MethodChannelCredentialsManager() + .user(CredentialsManagerRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: null)); + + verify(mocked.methodCallHandler(captureAny)); + expect(result, isNull); + }); + + test('throws CredentialsManagerException on PlatformException', () async { + when(mocked.methodCallHandler(any)) + .thenThrow(PlatformException(code: 'FAILED', message: 'No credentials stored')); + + Future actual() async { + return await MethodChannelCredentialsManager() + .user(CredentialsManagerRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: null)); + } + + await expectLater( + actual, + throwsA(predicate((final e) => + e is CredentialsManagerException && + e.code == 'FAILED' && + e.message == 'No credentials stored'))); + }); + + test('handles missing optional profile fields gracefully', () async { + final Map minimalProfile = { + 'sub': 'auth0|123456', + }; + + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => minimalProfile); + + final result = await MethodChannelCredentialsManager() + .user(CredentialsManagerRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: null)); + + verify(mocked.methodCallHandler(captureAny)); + + expect(result, isNotNull); + expect(result!.sub, 'auth0|123456'); + expect(result.name, isNull); + expect(result.email, isNull); + expect(result.nickname, isNull); + expect(result.pictureUrl, isNull); + }); + }); } From 932925c6b9cfe958c66e4dfda140933dd9d5b84f Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Sun, 1 Feb 2026 19:50:38 +0530 Subject: [PATCH 35/42] fixed analyze error --- .../test/mobile/credentials_manager_test.dart | 5 ++--- .../lib/auth0_flutter_platform_interface.dart | 2 +- .../credentials_manager_platform.dart | 7 +----- .../method_channel_credentials_manager.dart | 13 +++-------- .../lib/src/user_info_credentials.dart | 4 ++-- ...thod_channel_credentials_manager_test.dart | 22 ++++++------------- 6 files changed, 16 insertions(+), 37 deletions(-) diff --git a/auth0_flutter/test/mobile/credentials_manager_test.dart b/auth0_flutter/test/mobile/credentials_manager_test.dart index 05045349f..c71945a7d 100644 --- a/auth0_flutter/test/mobile/credentials_manager_test.dart +++ b/auth0_flutter/test/mobile/credentials_manager_test.dart @@ -269,9 +269,8 @@ void main() { .thenThrow(const CredentialsManagerException( 'FAILED', 'No credentials stored', {})); - Future actual() async { - return await DefaultCredentialsManager(account, userAgent).user(); - } + Future actual() async => DefaultCredentialsManager( + account, userAgent).user(); await expectLater( actual, diff --git a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart index e9f79fa8f..95b423a24 100644 --- a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart +++ b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart @@ -31,7 +31,6 @@ export 'src/credentials-manager/options/local_authentication.dart'; export 'src/credentials-manager/options/renew_credentials_options.dart'; export 'src/credentials-manager/options/save_credentials_options.dart'; export 'src/credentials.dart'; -export 'src/user_info_credentials.dart'; export 'src/database_user.dart'; export 'src/id_token_validation_config.dart'; export 'src/login_options.dart'; @@ -42,6 +41,7 @@ export 'src/request/dpop_request.dart'; export 'src/request/request.dart'; export 'src/request/request_options.dart'; export 'src/user_agent.dart'; +export 'src/user_info_credentials.dart'; export 'src/user_profile.dart'; export 'src/web-auth/safari_view_controller.dart'; export 'src/web-auth/web_auth_login_options.dart'; diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart index 79a6fb577..12542e9e1 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart @@ -1,12 +1,7 @@ // coverage:ignore-file import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; -import 'method_channel_credentials_manager.dart'; -import 'options/get_credentials_options.dart'; -import 'options/has_valid_credentials_options.dart'; -import 'options/renew_credentials_options.dart'; -import 'options/save_credentials_options.dart'; +import '../../auth0_flutter_platform_interface.dart'; /// The interface that implementations of CredentialsManager must implement. abstract class CredentialsManagerPlatform extends PlatformInterface { diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart index 7c8a1763c..995978101 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart @@ -1,14 +1,6 @@ import 'package:flutter/services.dart'; import '../../auth0_flutter_platform_interface.dart'; -import '../request/request.dart'; -import '../request/request_options.dart'; -import 'credentials_manager_exception.dart'; -import 'credentials_manager_platform.dart'; -import 'options/get_credentials_options.dart'; -import 'options/has_valid_credentials_options.dart'; -import 'options/renew_credentials_options.dart'; -import 'options/save_credentials_options.dart'; const MethodChannel _channel = MethodChannel('auth0.com/auth0_flutter/credentials_manager'); @@ -71,11 +63,12 @@ class MethodChannelCredentialsManager extends CredentialsManagerPlatform { Future user(final CredentialsManagerRequest request) async { final Map? result; try { - result = await _channel.invokeMapMethod(credentialsManagerGetUserProfileMethod, request.toMap()); + result = await _channel.invokeMapMethod( + credentialsManagerGetUserProfileMethod, request.toMap()); } on PlatformException catch (e) { throw CredentialsManagerException.fromPlatformException(e); } - + return result != null ? UserProfile.fromMap(result) : null; } diff --git a/auth0_flutter_platform_interface/lib/src/user_info_credentials.dart b/auth0_flutter_platform_interface/lib/src/user_info_credentials.dart index 417c7a836..70a02ef55 100644 --- a/auth0_flutter_platform_interface/lib/src/user_info_credentials.dart +++ b/auth0_flutter_platform_interface/lib/src/user_info_credentials.dart @@ -14,7 +14,7 @@ required this.provider, this.isSocial, this.accessToken, this.accessTokenSecret, -Map? profileInfo}) +final Map? profileInfo}) : _profileInfo = profileInfo ; factory UserIdentity.fromJson(final Map json) => UserIdentity( @@ -101,7 +101,7 @@ createdAt: json['createdAt'] != null : null, // Handle list of UserIdentity identities: (json['identities'] as List?) - ?.map((e) => UserIdentity.fromJson(e as Map)) + ?.map((final e) => UserIdentity.fromJson(e as Map)) .toList(), extraInfo: Map.from(json['extraInfo'] as Map), userMetadata: Map.from(json['userMetadata'] as Map), diff --git a/auth0_flutter_platform_interface/test/method_channel_credentials_manager_test.dart b/auth0_flutter_platform_interface/test/method_channel_credentials_manager_test.dart index 1df72907e..a4bbb982f 100644 --- a/auth0_flutter_platform_interface/test/method_channel_credentials_manager_test.dart +++ b/auth0_flutter_platform_interface/test/method_channel_credentials_manager_test.dart @@ -739,8 +739,7 @@ void main() { await MethodChannelCredentialsManager() .user(CredentialsManagerRequest( account: const Account('test-domain', 'test-clientId'), - userAgent: UserAgent(name: 'test-name', version: 'test-version'), - options: null)); + userAgent: UserAgent(name: 'test-name', version: 'test-version'))); expect( verify(mocked.methodCallHandler(captureAny)).captured.single.method, @@ -754,8 +753,7 @@ void main() { await MethodChannelCredentialsManager() .user(CredentialsManagerRequest( account: const Account('test-domain', 'test-clientId'), - userAgent: UserAgent(name: 'test-name', version: 'test-version'), - options: null)); + userAgent: UserAgent(name: 'test-name', version: 'test-version'))); final verificationResult = verify(mocked.methodCallHandler(captureAny)).captured.single; @@ -787,8 +785,7 @@ void main() { final result = await MethodChannelCredentialsManager() .user(CredentialsManagerRequest( account: const Account('test-domain', 'test-clientId'), - userAgent: UserAgent(name: 'test-name', version: 'test-version'), - options: null)); + userAgent: UserAgent(name: 'test-name', version: 'test-version'))); verify(mocked.methodCallHandler(captureAny)); @@ -811,8 +808,7 @@ void main() { final result = await MethodChannelCredentialsManager() .user(CredentialsManagerRequest( account: const Account('test-domain', 'test-clientId'), - userAgent: UserAgent(name: 'test-name', version: 'test-version'), - options: null)); + userAgent: UserAgent(name: 'test-name', version: 'test-version'))); verify(mocked.methodCallHandler(captureAny)); expect(result, isNull); @@ -822,13 +818,10 @@ void main() { when(mocked.methodCallHandler(any)) .thenThrow(PlatformException(code: 'FAILED', message: 'No credentials stored')); - Future actual() async { - return await MethodChannelCredentialsManager() + Future actual() async => MethodChannelCredentialsManager() .user(CredentialsManagerRequest( account: const Account('test-domain', 'test-clientId'), - userAgent: UserAgent(name: 'test-name', version: 'test-version'), - options: null)); - } + userAgent: UserAgent(name: 'test-name', version: 'test-version'))); await expectLater( actual, @@ -849,8 +842,7 @@ void main() { final result = await MethodChannelCredentialsManager() .user(CredentialsManagerRequest( account: const Account('test-domain', 'test-clientId'), - userAgent: UserAgent(name: 'test-name', version: 'test-version'), - options: null)); + userAgent: UserAgent(name: 'test-name', version: 'test-version'))); verify(mocked.methodCallHandler(captureAny)); From d825b01fb433194f9fde0cbe6364591c88d8371c Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Sun, 1 Feb 2026 20:19:31 +0530 Subject: [PATCH 36/42] name change in the method handler class --- .../credentials_manager/GetCredentialsUserInfoRequestHandler.kt | 2 +- .../Classes/CredentialsManager/CredentialsManagerHandler.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandler.kt index 1be371e4d..51551baef 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandler.kt @@ -8,7 +8,7 @@ import io.flutter.plugin.common.MethodChannel class GetCredentialsUserInfoRequestHandler: CredentialsManagerRequestHandler { - override val method: String = "credentialsManager#getUserInfo" + override val method: String = "credentialsManager#user" override fun handle( credentialsManager: SecureCredentialsManager, context: Context, diff --git a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift index 3bb1efbae..f76cc7f60 100644 --- a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift +++ b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift @@ -23,7 +23,7 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { case get = "credentialsManager#getCredentials" case renew = "credentialsManager#renewCredentials" case clear = "credentialsManager#clearCredentials" - case userInfo = "credentialsManager#getUserInfo" + case userInfo = "credentialsManager#user" } private struct ManagerCacheKey: Equatable { From f43cd21533023661cbe3973f50290a3e641dca27 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 2 Feb 2026 10:08:47 +0530 Subject: [PATCH 37/42] Fixed the failing test cases --- ...CredentialsManagerMethodCallHandlerTest.kt | 21 ++++++++-- ...etCredentialsUserInfoRequestHandlerTest.kt | 42 ++++++++++++------- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt index 96d9b6b03..e59d6aadd 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandlerTest.kt @@ -42,7 +42,16 @@ class CredentialsManagerMethodCallHandlerTest { val mockResult = mock() handler.activity = if (activity === null) mock() else activity - handler.context = if (context === null) mock() else context + + val mockContext = if (context === null) { + val ctx: Context = mock() + val mockPrefs: SharedPreferences = mock() + `when`(ctx.getSharedPreferences(any(), any())).thenReturn(mockPrefs) + ctx + } else { + context + } + handler.context = mockContext handler.onMethodCall(MethodCall(method, arguments), mockResult) onResult(mockResult) @@ -466,7 +475,7 @@ class CredentialsManagerMethodCallHandlerTest { @Test fun `handler invokes GetCredentialsUserInfoRequestHandler for credentialsManager#user`() { val getUserInfoHandler = mock() - `when`(getUserInfoHandler.handles).thenReturn("credentialsManager#user") + `when`(getUserInfoHandler.method).thenReturn("credentialsManager#user") runCallHandler( "credentialsManager#user", @@ -479,7 +488,7 @@ class CredentialsManagerMethodCallHandlerTest { @Test fun `handler returns UserProfile result from GetCredentialsUserInfoRequestHandler`() { val getUserInfoHandler = mock() - `when`(getUserInfoHandler.handles).thenReturn("credentialsManager#user") + `when`(getUserInfoHandler.method).thenReturn("credentialsManager#user") val userProfileMap = mapOf( "sub" to "auth0|123456", @@ -495,7 +504,11 @@ class CredentialsManagerMethodCallHandlerTest { val mockResult = mock() val handler = CredentialsManagerMethodCallHandler(listOf(getUserInfoHandler)) handler.activity = mock() - handler.context = mock() + + val mockContext: Context = mock() + val mockPrefs: SharedPreferences = mock() + `when`(mockContext.getSharedPreferences(any(), any())).thenReturn(mockPrefs) + handler.context = mockContext handler.onMethodCall(MethodCall("credentialsManager#user", defaultArguments), mockResult) diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandlerTest.kt index 6b7102b3a..73377a01a 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandlerTest.kt @@ -22,7 +22,7 @@ class GetCredentialsUserInfoRequestHandlerTest { fun `handles returns correct method`() { val handler = GetCredentialsUserInfoRequestHandler() MatcherAssert.assertThat( - handler.handles, + handler.method, CoreMatchers.`is`("credentialsManager#user") ) } @@ -37,16 +37,19 @@ class GetCredentialsUserInfoRequestHandlerTest { val request = MethodCallRequest(account = mockAccount, options) val userProfile = UserProfile( - "auth0|123456", + "", "John Doe", + "johndoe", + "https://example.com/picture.jpg", "john.doe@example.com", true, - "John", "Doe", - "johndoe", - "https://example.com/picture.jpg", - Date(), - mapOf("role" to "admin") + null, + null, + mapOf("sub" to "auth0|123456", "role" to "admin"), + null, + null, + "John" ) `when`(mockCredentialsManager.userProfile).thenReturn(userProfile) @@ -114,16 +117,19 @@ class GetCredentialsUserInfoRequestHandlerTest { val updatedAt = Date() val userProfile = UserProfile( - "auth0|123456", + "", "John Doe", + "johndoe", + "https://example.com/picture.jpg", "john.doe@example.com", true, - "John", "Doe", - "johndoe", - "https://example.com/picture.jpg", - updatedAt, - mapOf("role" to "admin", "department" to "engineering") + null, + null, + mapOf("sub" to "auth0|123456", "role" to "admin", "department" to "engineering", "updated_at" to updatedAt.toInstant().toString()), + null, + null, + "John" ) `when`(mockCredentialsManager.userProfile).thenReturn(userProfile) @@ -166,20 +172,24 @@ class GetCredentialsUserInfoRequestHandlerTest { val request = MethodCallRequest(account = mockAccount, options) val userProfile = UserProfile( - "auth0|123456", + "", "John Doe", null, - false, null, null, + false, null, null, null, mapOf( + "sub" to "auth0|123456", "custom_field_1" to "value1", "custom_field_2" to 123, "custom_field_3" to true - ) + ), + null, + null, + null ) `when`(mockCredentialsManager.userProfile).thenReturn(userProfile) From d70d1e190edb0568b2df1630836235eb5c431ee1 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 2 Feb 2026 10:50:49 +0530 Subject: [PATCH 38/42] Fixed the error in analyze due to the branch conflict issue --- .../lib/src/mobile/credentials_manager.dart | 8 -- .../lib/auth0_flutter_platform_interface.dart | 1 - .../credentials_manager_platform.dart | 5 - .../method_channel_credentials_manager.dart | 4 +- .../lib/src/user_info_credentials.dart | 111 ------------------ 5 files changed, 2 insertions(+), 127 deletions(-) delete mode 100644 auth0_flutter_platform_interface/lib/src/user_info_credentials.dart diff --git a/auth0_flutter/lib/src/mobile/credentials_manager.dart b/auth0_flutter/lib/src/mobile/credentials_manager.dart index a124822be..b22a7c033 100644 --- a/auth0_flutter/lib/src/mobile/credentials_manager.dart +++ b/auth0_flutter/lib/src/mobile/credentials_manager.dart @@ -9,8 +9,6 @@ abstract class CredentialsManager { final Map parameters = const {}, }); - Future getIDTokenContents(); - Future renewCredentials({ final Map parameters = const {}, }); @@ -66,11 +64,6 @@ class DefaultCredentialsManager extends CredentialsManager { parameters: parameters, ))); -<<<<<<< HEAD - @override - Future getIDTokenContents() => - CredentialsManagerPlatform.instance.getIDTokenContents(_createApiRequest(null)); -======= /// Fetches new set of credentials each time and stores them in storage. /// This will replace the existing credentials currently stored /// even if they are not expired. @@ -83,7 +76,6 @@ class DefaultCredentialsManager extends CredentialsManager { }) => CredentialsManagerPlatform.instance.renewCredentials( _createApiRequest(RenewCredentialsOptions(parameters: parameters))); ->>>>>>> main /// Fetches the user profile associated with the stored credentials. /// Returns null if no credentials are present in storage. diff --git a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart index 95b423a24..64fa64777 100644 --- a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart +++ b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart @@ -41,7 +41,6 @@ export 'src/request/dpop_request.dart'; export 'src/request/request.dart'; export 'src/request/request_options.dart'; export 'src/user_agent.dart'; -export 'src/user_info_credentials.dart'; export 'src/user_profile.dart'; export 'src/web-auth/safari_view_controller.dart'; export 'src/web-auth/web_auth_login_options.dart'; diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart index 12542e9e1..5245d1154 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart @@ -32,11 +32,6 @@ abstract class CredentialsManagerPlatform extends PlatformInterface { throw UnimplementedError('getCredentials() has not been implemented'); } - /// Retrieves the credentials from the native storage. - Future getIDTokenContents(final CredentialsManagerRequest request) { - throw UnimplementedError('getIDTokenContents() has not been implemented'); - } - /// Fetches new credentials and save them in the native storage. Future renewCredentials( final CredentialsManagerRequest request, diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart index 995978101..2e05f4309 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart @@ -64,11 +64,11 @@ class MethodChannelCredentialsManager extends CredentialsManagerPlatform { final Map? result; try { result = await _channel.invokeMapMethod( - credentialsManagerGetUserProfileMethod, request.toMap()); + credentialsManagerGetUserProfileMethod,request.toMap()); } on PlatformException catch (e) { throw CredentialsManagerException.fromPlatformException(e); } - + return result != null ? UserProfile.fromMap(result) : null; } diff --git a/auth0_flutter_platform_interface/lib/src/user_info_credentials.dart b/auth0_flutter_platform_interface/lib/src/user_info_credentials.dart deleted file mode 100644 index 70a02ef55..000000000 --- a/auth0_flutter_platform_interface/lib/src/user_info_credentials.dart +++ /dev/null @@ -1,111 +0,0 @@ -class UserIdentity { -final String id; -final String connection; -final String provider; -final bool? isSocial; -final String? accessToken; -final String? accessTokenSecret; -final Map? _profileInfo; - -UserIdentity({ -required this.id, -required this.connection, -required this.provider, -this.isSocial, -this.accessToken, -this.accessTokenSecret, -final Map? profileInfo}) - : _profileInfo = profileInfo ; -factory UserIdentity.fromJson(final Map json) -=> UserIdentity( -connection: json['connection'] as String, -id: json['id'] as String, -isSocial: json['isSocial'] as bool?, -provider: json['provider'] as String, -accessToken: json['accessToken'] as String?, -accessTokenSecret: json['accessTokenSecret'] as String?, -profileInfo: json['profileInfo'] as Map? -); -} - -class UserInfo { -final String? _id; -final List? _identities; -final Map? _extraInfo; -final Map? _userMetadata; -final Map? _appMetadata; - -final String? name; -final String? nickname; -final String? pictureURL; -final String? email; -final bool? isEmailVerified; -final String? familyName; -final DateTime? createdAt; -final String? givenName; - - -UserInfo({ -final String? id, -this.name, -this.nickname, -this.pictureURL, -this.email, -this.isEmailVerified, -this.familyName, -this.createdAt, -final List? identities, -final Map? extraInfo, -final Map? userMetadata, -final Map? appMetadata, -this.givenName, -}) - : _id = id, -_identities = identities, -_extraInfo = extraInfo, -_userMetadata = userMetadata, -_appMetadata = appMetadata; - -String? getId() { -if (_id != null) { -return _id; -} -return (_extraInfo != null && _extraInfo.containsKey('sub')) -? _extraInfo['sub'] as String? - : null; -} - -Map getUserMetadata() => -_userMetadata ?? {}; -Map getAppMetadata() => -_appMetadata ?? {}; - -List getIdentities() => -_identities ?? []; - -Map getExtraInfo() => -_extraInfo ?? {}; - -factory UserInfo.fromJson(final Map json) => -UserInfo( -id: json['id'] as String?, -name: json['name'] as String?, -nickname: json['nickname'] as String?, -pictureURL: json['pictureURL'] as String?, -email: json['email'] as String?, -isEmailVerified: json['isEmailVerified'] as bool?, -familyName: json['familyName'] as String?, -// Handle date parsing -createdAt: json['createdAt'] != null -? DateTime.parse(json['createdAt'] as String) - : null, -// Handle list of UserIdentity -identities: (json['identities'] as List?) - ?.map((final e) => UserIdentity.fromJson(e as Map)) - .toList(), -extraInfo: Map.from(json['extraInfo'] as Map), -userMetadata: Map.from(json['userMetadata'] as Map), -appMetadata: Map.from(json['appMetadata'] as Map), -givenName: json['givenName'] as String?, -); -} From ad06a370686740c4a7a4a7750ebb21b1e0168cd3 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 2 Feb 2026 11:33:40 +0530 Subject: [PATCH 39/42] fixing swift failures on UTs --- auth0_flutter/example/ios/Tests/Mocks.swift | 2 +- auth0_flutter/example/macos/Runner/AppDelegate.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/auth0_flutter/example/ios/Tests/Mocks.swift b/auth0_flutter/example/ios/Tests/Mocks.swift index eac01e6d2..f125832d5 100644 --- a/auth0_flutter/example/ios/Tests/Mocks.swift +++ b/auth0_flutter/example/ios/Tests/Mocks.swift @@ -113,7 +113,7 @@ class SpyPluginRegistrar: NSObject, FlutterPluginRegistrar { } func addApplicationDelegate(_ delegate: FlutterPlugin) {} - func addSceneDelegate(_ delegate: any FlutterSceneLifeCycleDelegate) {} + func register(_ factory: FlutterPlatformViewFactory, withId: String, gestureRecognizersBlockingPolicy: FlutterPlatformViewGestureRecognizersBlockingPolicy) {} diff --git a/auth0_flutter/example/macos/Runner/AppDelegate.swift b/auth0_flutter/example/macos/Runner/AppDelegate.swift index d53ef6437..8e02df288 100644 --- a/auth0_flutter/example/macos/Runner/AppDelegate.swift +++ b/auth0_flutter/example/macos/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true From a9cd6ac44b6efbb567fa8c90979f109952e5fdb9 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 2 Feb 2026 11:58:34 +0530 Subject: [PATCH 40/42] Updated the workflow file --- .github/workflows/main.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1fcd63b0d..9d54953eb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -135,6 +135,12 @@ jobs: - name: Checkout uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + - name: Select Xcode version + run: sudo xcode-select -s '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer' + + - name: Install iOS Simulator Runtime + run: xcodebuild -downloadPlatform iOS + - name: Set up environment uses: ./.github/actions/setup-darwin with: From d80cfa892c1993b9fe5f1523935e43000937c19b Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 2 Feb 2026 12:29:57 +0530 Subject: [PATCH 41/42] Minor change --- auth0_flutter/example/ios/Tests/Mocks.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/auth0_flutter/example/ios/Tests/Mocks.swift b/auth0_flutter/example/ios/Tests/Mocks.swift index f125832d5..6c3ab7afd 100644 --- a/auth0_flutter/example/ios/Tests/Mocks.swift +++ b/auth0_flutter/example/ios/Tests/Mocks.swift @@ -117,6 +117,18 @@ class SpyPluginRegistrar: NSObject, FlutterPluginRegistrar { func register(_ factory: FlutterPlatformViewFactory, withId: String, gestureRecognizersBlockingPolicy: FlutterPlatformViewGestureRecognizersBlockingPolicy) {} + + func register(_ factory: FlutterPlatformViewFactory, withId: String) {} + + func publish(_ value: NSObject) {} + + func lookupKey(forAsset asset: String) -> String { + return "" + } + + func lookupKey(forAsset: String, fromPackage: String) -> String { + return "" + } #else var view: NSView? var viewController: NSViewController? @@ -126,9 +138,6 @@ class SpyPluginRegistrar: NSObject, FlutterPluginRegistrar { let textures: FlutterTextureRegistry = MockTextureRegistry() func addApplicationDelegate(_ delegate: FlutterAppLifecycleDelegate) {} - #endif - - private(set) var delegate: FlutterPlugin? func register(_ factory: FlutterPlatformViewFactory, withId: String) {} @@ -141,6 +150,9 @@ class SpyPluginRegistrar: NSObject, FlutterPluginRegistrar { func lookupKey(forAsset: String, fromPackage: String) -> String { return "" } + #endif + + private(set) var delegate: FlutterPlugin? func addMethodCallDelegate(_ delegate: FlutterPlugin, channel: FlutterMethodChannel) { self.delegate = delegate From f6f2bf5435a8c0a35a6438d306b234377b1054f9 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 2 Feb 2026 15:02:19 +0530 Subject: [PATCH 42/42] Minor changes from review comments --- .../CredentialsManagerHandler.swift | 15 +++++++-------- .../method_channel_credentials_manager.dart | 4 ++-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift index f76cc7f60..7caa022a7 100644 --- a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift +++ b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift @@ -25,7 +25,7 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { case clear = "credentialsManager#clearCredentials" case userInfo = "credentialsManager#user" } - + private struct ManagerCacheKey: Equatable { let accountDomain: String let accountClientId: String @@ -88,12 +88,12 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { let accessGroup = iosConfiguration?["accessGroup"] let useDPoP = arguments["useDPoP"] as? Bool ?? false let hasLocalAuth = arguments[LocalAuthentication.key] != nil - + guard let accountDictionary = arguments[Account.key] as? [String: String], let account = Account(from: accountDictionary) else { return self.createCredentialManager(apiClient, arguments) } - + let currentKey = ManagerCacheKey( accountDomain: account.domain, accountClientId: account.clientId, @@ -102,7 +102,7 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { useDPoP: useDPoP, hasLocalAuth: hasLocalAuth ) - + var instance: CredentialsManager if let cachedKey = CredentialsManagerHandler.cachedKey, cachedKey == currentKey, @@ -110,18 +110,18 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { instance = cachedManager } else { instance = self.createCredentialManager(apiClient, arguments) - + CredentialsManagerHandler.credentialsManager = instance CredentialsManagerHandler.cachedKey = currentKey } - + if let localAuthenticationDictionary = arguments[LocalAuthentication.key] as? [String: String?] { let localAuthentication = LocalAuthentication(from: localAuthenticationDictionary) instance.enableBiometrics(withTitle: localAuthentication.title, cancelTitle: localAuthentication.cancelTitle, fallbackTitle: localAuthentication.fallbackTitle) } - + return instance } @@ -133,7 +133,6 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { case .clear: return CredentialsManagerClearMethodHandler(credentialsManager: credentialsManager) case .userInfo: return CredentialsManagerUserInfoMethodHandler(credentialsManager: credentialsManager) case .renew: return CredentialsManagerRenewMethodHandler(credentialsManager: credentialsManager) - case .userInfo: return CredentialsManagerUserInfoMethodHandler(credentialsManager: credentialsManager) } } diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart index 2e05f4309..ad5a645bf 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/method_channel_credentials_manager.dart @@ -58,7 +58,7 @@ class MethodChannelCredentialsManager extends CredentialsManagerPlatform { } /// Fetches the user profile associated with the stored credentials. - /// Returns null is no credentials are stored + /// Returns null if no credentials are stored @override Future user(final CredentialsManagerRequest request) async { final Map? result; @@ -68,7 +68,7 @@ class MethodChannelCredentialsManager extends CredentialsManagerPlatform { } on PlatformException catch (e) { throw CredentialsManagerException.fromPlatformException(e); } - + return result != null ? UserProfile.fromMap(result) : null; }