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: 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 27e3b872f..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 @@ -35,7 +35,8 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { RenewCredentialsRequestHandler(), SaveCredentialsRequestHandler(), HasValidCredentialsRequestHandler(), - ClearCredentialsRequestHandler() + ClearCredentialsRequestHandler(), + GetCredentialsUserInfoRequestHandler() )) private val dpopCallHandler = Auth0FlutterDPoPMethodCallHandler(listOf( GetDPoPHeadersApiRequestHandler(), 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 new file mode 100644 index 000000000..51551baef --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandler.kt @@ -0,0 +1,25 @@ +package com.auth0.auth0_flutter.request_handlers.credentials_manager + +import android.content.Context +import com.auth0.android.authentication.storage.SecureCredentialsManager +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMap +import io.flutter.plugin.common.MethodChannel + + +class GetCredentialsUserInfoRequestHandler: CredentialsManagerRequestHandler { + override val method: String = "credentialsManager#user" + override fun handle( + credentialsManager: SecureCredentialsManager, + context: Context, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + val userProfile = credentialsManager.userProfile + if (userProfile != null) { + result.success(userProfile.toMap()) + } else { + result.success(null) + } + } +} 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..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 @@ -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 @@ -41,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) @@ -139,7 +149,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 +203,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 +243,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 +255,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 +304,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 +312,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 +359,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 +424,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 +436,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 +461,58 @@ 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.method).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.method).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() + + 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) + + 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..73377a01a --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsUserInfoRequestHandlerTest.kt @@ -0,0 +1,217 @@ +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.method, + 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( + "", + "John Doe", + "johndoe", + "https://example.com/picture.jpg", + "john.doe@example.com", + true, + "Doe", + null, + null, + mapOf("sub" to "auth0|123456", "role" to "admin"), + null, + null, + "John" + ) + + `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( + "", + "John Doe", + "johndoe", + "https://example.com/picture.jpg", + "john.doe@example.com", + true, + "Doe", + 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) + + 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( + "", + "John Doe", + null, + 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) + + 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/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift index 642fecef1..7caa022a7 100644 --- a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift +++ b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerHandler.swift @@ -23,8 +23,9 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { case get = "credentialsManager#getCredentials" case renew = "credentialsManager#renewCredentials" case clear = "credentialsManager#clearCredentials" + case userInfo = "credentialsManager#user" } - + private struct ManagerCacheKey: Equatable { let accountDomain: String let accountClientId: String @@ -51,7 +52,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] { @@ -77,21 +78,22 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { client.using(inLibrary: userAgent.name, version: userAgent.version) return useDPoP ? client.useDPoP() : client } - + lazy var credentialsManagerProvider: CredentialsManagerProvider = { apiClient, arguments in + let configuration = arguments["credentialsManagerConfiguration"] as? [String: Any] let iosConfiguration = configuration?["ios"] as? [String: String] let storeKey = iosConfiguration?["storeKey"] ?? "credentials" 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, @@ -100,7 +102,7 @@ public class CredentialsManagerHandler: NSObject, FlutterPlugin { useDPoP: useDPoP, hasLocalAuth: hasLocalAuth ) - + var instance: CredentialsManager if let cachedKey = CredentialsManagerHandler.cachedKey, cachedKey == currentKey, @@ -108,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 } @@ -129,6 +131,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) case .renew: return CredentialsManagerRenewMethodHandler(credentialsManager: credentialsManager) } } @@ -154,5 +157,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..f09591825 --- /dev/null +++ b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerUserInfoMethodHandler.swift @@ -0,0 +1,20 @@ + +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) { + if let user = credentialsManager.user { + callback(user.asDictionary()) + } else { + callback(nil) + } + } +} 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/example/ios/Tests/Mocks.swift b/auth0_flutter/example/ios/Tests/Mocks.swift index eac01e6d2..6c3ab7afd 100644 --- a/auth0_flutter/example/ios/Tests/Mocks.swift +++ b/auth0_flutter/example/ios/Tests/Mocks.swift @@ -113,10 +113,22 @@ class SpyPluginRegistrar: NSObject, FlutterPluginRegistrar { } func addApplicationDelegate(_ delegate: FlutterPlugin) {} - func addSceneDelegate(_ delegate: any FlutterSceneLifeCycleDelegate) {} + 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 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 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/lib/src/mobile/credentials_manager.dart b/auth0_flutter/lib/src/mobile/credentials_manager.dart index 26ee984cf..b22a7c033 100644 --- a/auth0_flutter/lib/src/mobile/credentials_manager.dart +++ b/auth0_flutter/lib/src/mobile/credentials_manager.dart @@ -13,6 +13,8 @@ abstract class CredentialsManager { final Map parameters = const {}, }); + Future user(); + Future storeCredentials(final Credentials credentials); Future hasValidCredentials({ @@ -75,6 +77,12 @@ class DefaultCredentialsManager extends CredentialsManager { CredentialsManagerPlatform.instance.renewCredentials( _createApiRequest(RenewCredentialsOptions(parameters: parameters))); + /// Fetches the user profile associated with the stored credentials. + /// Returns null if no credentials are present in storage. + @override + 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. @override 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 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..c71945a7d 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,78 @@ 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 => 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/credentials_manager_platform.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_platform.dart index a6ab01e1f..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 @@ -1,13 +1,7 @@ // coverage:ignore-file import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import '../credentials.dart'; -import '../request/request.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 { @@ -33,16 +27,23 @@ abstract class CredentialsManagerPlatform extends PlatformInterface { /// Retrieves the credentials from the native storage. Future getCredentials( - final CredentialsManagerRequest request) { + final CredentialsManagerRequest request, + ) { throw UnimplementedError('getCredentials() has not been implemented'); } /// Fetches new credentials and save them in the native storage. Future renewCredentials( - final CredentialsManagerRequest request) { + final CredentialsManagerRequest request, + ) { throw UnimplementedError('renewCredentials() 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. Future clearCredentials(final CredentialsManagerRequest request) { throw UnimplementedError('clearCredentials() has not been implemented'); @@ -51,14 +52,16 @@ abstract class CredentialsManagerPlatform extends PlatformInterface { /// Stores the given credentials in the native storage. Must have an /// access_token or id_token and a expires_in value. Future saveCredentials( - final CredentialsManagerRequest request) { + final CredentialsManagerRequest request, + ) { throw UnimplementedError('saveCredentials() has not been implemented'); } /// Checks if a non-expired pair of credentials can be obtained from the /// native storage. Future hasValidCredentials( - final CredentialsManagerRequest request) { + final CredentialsManagerRequest request, + ) { throw UnimplementedError('hasValidCredentials() 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 2de357107..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 @@ -1,14 +1,6 @@ import 'package:flutter/services.dart'; -import '../credentials.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'; +import '../../auth0_flutter_platform_interface.dart'; const MethodChannel _channel = MethodChannel('auth0.com/auth0_flutter/credentials_manager'); @@ -16,6 +8,8 @@ const String credentialsManagerSaveCredentialsMethod = 'credentialsManager#saveCredentials'; const String credentialsManagerGetCredentialsMethod = 'credentialsManager#getCredentials'; +const String credentialsManagerGetUserProfileMethod = + 'credentialsManager#user'; const String credentialsManagerClearCredentialsMethod = 'credentialsManager#clearCredentials'; const String credentialsManagerHasValidCredentialsMethod = @@ -63,6 +57,21 @@ class MethodChannelCredentialsManager extends CredentialsManagerPlatform { return result ?? true; } + /// Fetches the user profile associated with the stored credentials. + /// Returns null if no credentials are stored + @override + Future user(final CredentialsManagerRequest request) async { + 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. /// /// Uses the [MethodChannel] to communicate with the Native platforms. 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..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 @@ -730,4 +730,128 @@ 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'))); + + 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'))); + + 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'))); + + 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'))); + + 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 => MethodChannelCredentialsManager() + .user(CredentialsManagerRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'))); + + 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'))); + + 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); + }); + }); }