diff --git a/.idea/auth0-flutter.iml b/.idea/auth0-flutter.iml index 7614ccb4..72b8b8ab 100644 --- a/.idea/auth0-flutter.iml +++ b/.idea/auth0-flutter.iml @@ -3,6 +3,12 @@ + + + + + + @@ -12,8 +18,15 @@ + + + + + + + \ No newline at end of file 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 eaa5d0a6..102ebffd 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 @@ -30,6 +30,7 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { )) private val authCallHandler = Auth0FlutterAuthMethodCallHandler(listOf( LoginApiRequestHandler(), + LoginWithOtpApiRequestHandler(), SignupApiRequestHandler(), UserInfoApiRequestHandler(), RenewApiRequestHandler(), diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/AuthenticationExceptionExtensions.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/AuthenticationExceptionExtensions.kt index f4cff304..e8435cf0 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/AuthenticationExceptionExtensions.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/AuthenticationExceptionExtensions.kt @@ -3,31 +3,33 @@ package com.auth0.auth0_flutter import com.auth0.android.authentication.AuthenticationException fun AuthenticationException.toMap(): Map { - return mapOf( - "_statusCode" to this.statusCode, - "_errorFlags" to mapOf( - "isMultifactorRequired" to this.isMultifactorRequired, - "isMultifactorEnrollRequired" to this.isMultifactorEnrollRequired, - "isMultifactorCodeInvalid" to this.isMultifactorCodeInvalid, - "isMultifactorTokenInvalid" to this.isMultifactorTokenInvalid, - "isPasswordNotStrongEnough" to this.isPasswordNotStrongEnough, - "isPasswordAlreadyUsed" to this.isPasswordAlreadyUsed, - "isRuleError" to this.isRuleError, - "isInvalidCredentials" to this.isInvalidCredentials, - "isRefreshTokenDeleted" to this.isRefreshTokenDeleted, - "isAccessDenied" to this.isAccessDenied, - "isTooManyAttempts" to this.isTooManyAttempts, - "isVerificationRequired" to this.isVerificationRequired, - "isNetworkError" to this.isNetworkError, - "isBrowserAppNotAvailable" to this.isBrowserAppNotAvailable, - "isPKCENotAvailable" to this.isPKCENotAvailable, - "isInvalidAuthorizeURL" to this.isInvalidAuthorizeURL, - "isInvalidConfiguration" to this.isInvalidConfiguration, - "isCanceled" to this.isCanceled, - "isPasswordLeaked" to this.isPasswordLeaked, - "isLoginRequired" to this.isLoginRequired, - ) - ) + val exception = this + return buildMap { + put("_statusCode", exception.statusCode) + put("_errorFlags", mapOf( + "isMultifactorRequired" to exception.isMultifactorRequired, + "isMultifactorEnrollRequired" to exception.isMultifactorEnrollRequired, + "isMultifactorCodeInvalid" to exception.isMultifactorCodeInvalid, + "isMultifactorTokenInvalid" to exception.isMultifactorTokenInvalid, + "isPasswordNotStrongEnough" to exception.isPasswordNotStrongEnough, + "isPasswordAlreadyUsed" to exception.isPasswordAlreadyUsed, + "isRuleError" to exception.isRuleError, + "isInvalidCredentials" to exception.isInvalidCredentials, + "isRefreshTokenDeleted" to exception.isRefreshTokenDeleted, + "isAccessDenied" to exception.isAccessDenied, + "isTooManyAttempts" to exception.isTooManyAttempts, + "isVerificationRequired" to exception.isVerificationRequired, + "isNetworkError" to exception.isNetworkError, + "isBrowserAppNotAvailable" to exception.isBrowserAppNotAvailable, + "isPKCENotAvailable" to exception.isPKCENotAvailable, + "isInvalidAuthorizeURL" to exception.isInvalidAuthorizeURL, + "isInvalidConfiguration" to exception.isInvalidConfiguration, + "isCanceled" to exception.isCanceled, + "isPasswordLeaked" to exception.isPasswordLeaked, + "isLoginRequired" to exception.isLoginRequired, + )) + if (exception.getValue("mfa_token") != null) { put("mfa_token", exception.getValue("mfa_token")!!) } + } } val AuthenticationException.isTooManyAttempts: Boolean diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandler.kt new file mode 100644 index 00000000..1224aa29 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandler.kt @@ -0,0 +1,68 @@ +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.jwt.JWT +import com.auth0.android.result.Credentials +import com.auth0.auth0_flutter.createUserProfileFromClaims +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.text.SimpleDateFormat +import java.util.* + +private const val AUTH_LOGIN_OTP_METHOD = "auth#loginOtp" + +class LoginWithOtpApiRequestHandler: ApiRequestHandler { + override val method: String = AUTH_LOGIN_OTP_METHOD + + override fun handle( + api: AuthenticationAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + val args = request.data + + assertHasProperties(listOf("mfaToken", "otp"), args) + + val loginBuilder = api + .loginWithOTP( + args["mfaToken"] as String, + args["otp"] as String, + ) + + loginBuilder.start(object : Callback { + override fun onFailure(exception: AuthenticationException) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMap() + ) + } + + override fun onSuccess(credentials: Credentials) { + val scope = credentials.scope?.split(" ") ?: listOf() + val jwt = JWT(credentials.idToken) + val userProfile = createUserProfileFromClaims(jwt.claims) + val sdf = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) + + val formattedDate = sdf.format(credentials.expiresAt) + result.success( + mapOf( + "accessToken" to credentials.accessToken, + "idToken" to credentials.idToken, + "refreshToken" to credentials.refreshToken, + "userProfile" to userProfile.toMap(), + "expiresAt" to formattedDate, + "scopes" to scope, + "tokenType" to credentials.type + ) + ) + } + }) + } + +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/AuthenticationExceptionExtensionsTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/AuthenticationExceptionExtensionsTest.kt index 1aad6740..87e5770c 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/AuthenticationExceptionExtensionsTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/AuthenticationExceptionExtensionsTest.kt @@ -185,4 +185,28 @@ class AuthenticationExceptionExtensionsTest { assertThat(statusCode, equalTo(50)) } + + @Test + fun `should set mfa_token when calling toMap()`() { + val exception = AuthenticationException(mapOf( + "mfa_token" to "test-mfaToken" + )) + + val map = exception.toMap() + val mfaToken = map["mfa_token"] as String + + assertThat(mfaToken, equalTo("test-mfaToken")) + } + + @Test + fun `should not set mfa_token when calling toMap()`() { + val exception = AuthenticationException(mapOf( + "code" to "some-code" + )) + + val map = exception.toMap() + val mfaToken = map["mfa_token"] as String? + + assertThat(mfaToken, equalTo(null)) + } } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandlerTest.kt new file mode 100644 index 00000000..462867c8 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/LoginWithOtpApiRequestHandlerTest.kt @@ -0,0 +1,168 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.request.AuthenticationRequest +import com.auth0.android.result.Credentials +import com.auth0.auth0_flutter.JwtTestUtils + +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel.Result +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner +import java.text.SimpleDateFormat +import java.util.* + +@RunWith(RobolectricTestRunner::class) +class LoginWithOtpApiRequestHandlerTest { + @Test + fun `should throw when missing otp`() { + val options = hashMapOf("mfaToken" to "test-mfa-token") + val handler = LoginWithOtpApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle( + mockApi, + request, + mockResult + ) + } + + assertThat( + exception.message, + equalTo("Required property 'otp' is not provided.") + ) + } + + @Test + fun `should throw when missing mfaToken`() { + val options = hashMapOf("otp" to "test-otp") + val handler = LoginWithOtpApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle( + mockApi, + request, + mockResult + ) + } + + assertThat( + exception.message, + equalTo("Required property 'mfaToken' is not provided.") + ) + } + + @Test + fun `should call loginWithOTp with the correct parameters`() { + val options = hashMapOf( + "otp" to "test-otp", + "mfaToken" to "test-mfaToken" + ) + val handler = LoginWithOtpApiRequestHandler() + val mockLoginBuilder = mock() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + doReturn(mockLoginBuilder).`when`(mockApi).loginWithOTP(any(), any()) + + handler.handle( + mockApi, + request, + mockResult + ) + + verify(mockApi).loginWithOTP("test-mfaToken", "test-otp") + verify(mockLoginBuilder).start(any()) + } + + @Test + fun `should call result error on failure`() { + val options = hashMapOf( + "otp" to "test-otp", + "mfaToken" to "test-mfaToken" + ) + val handler = LoginWithOtpApiRequestHandler() + val mockLoginBuilder = mock() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + val exception = + AuthenticationException(code = "test-code", description = "test-description") + + doReturn(mockLoginBuilder).`when`(mockApi).loginWithOTP(any(), any()) + doAnswer { + val ob = it.getArgument>(0) + ob.onFailure(exception) + }.`when`(mockLoginBuilder).start(any()) + + handler.handle( + mockApi, + request, + mockResult + ) + + verify(mockResult).error(eq("test-code"), eq("test-description"), any()) + } + + @Test + fun `should call result success on success`() { + val options = hashMapOf( + "otp" to "test-otp", + "mfaToken" to "test-mfaToken" + ) + val handler = LoginWithOtpApiRequestHandler() + val mockLoginBuilder = mock() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + val idToken = JwtTestUtils.createJwt(claims = mapOf("name" to "John Doe")) + val credentials = Credentials(idToken, "test", "", null, Date(), "scope1 scope2") + + doReturn(mockLoginBuilder).`when`(mockApi).loginWithOTP(any(), any()) + doAnswer { + val ob = it.getArgument>(0) + ob.onSuccess(credentials) + }.`when`(mockLoginBuilder).start(any()) + + handler.handle( + mockApi, + request, + mockResult + ) + + val captor = argumentCaptor<() -> Map>() + verify(mockResult).success(captor.capture()) + + val sdf = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) + + val formattedDate = sdf.format(credentials.expiresAt) + + assertThat((captor.firstValue as Map<*, *>)["accessToken"], equalTo(credentials.accessToken)) + assertThat((captor.firstValue as Map<*, *>)["idToken"], equalTo(credentials.idToken)) + assertThat((captor.firstValue as Map<*, *>)["refreshToken"], equalTo(credentials.refreshToken)) + assertThat((captor.firstValue as Map<*, *>)["expiresAt"] as String, equalTo(formattedDate)) + assertThat((captor.firstValue as Map<*, *>)["scopes"], equalTo(listOf("scope1", "scope2"))) + assertThat(((captor.firstValue as Map<*, *>)["userProfile"] as Map<*, *>)["name"], equalTo("John Doe")) + } +} diff --git a/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj index 72bffc4b..6d5f9d61 100644 --- a/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 2D82163828F9A89300467FD1 /* AuthApiLoginWithOtpMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82163728F9A89300467FD1 /* AuthApiLoginWithOtpMethodHandlerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3F1B396BCCAD9DA04D4998A2 /* Pods_Runner_RunnerUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52FDC1B88C21EFE2421C98FA /* Pods_Runner_RunnerUITests.framework */; }; 5C08DBC8288A7646000D2F37 /* CredentialsManagerExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C08DBC7288A7646000D2F37 /* CredentialsManagerExtensionsTests.swift */; }; @@ -80,6 +81,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 29F5DF109AAC0995DAF67FD3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2D82163728F9A89300467FD1 /* AuthApiLoginWithOtpMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthApiLoginWithOtpMethodHandlerTests.swift; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 3DA3F79738DB2CDFBC131B5E /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 52FDC1B88C21EFE2421C98FA /* Pods_Runner_RunnerUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner_RunnerUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -201,6 +203,7 @@ 5C335E4627FE68A500EDDE3A /* AuthAPI */ = { isa = PBXGroup; children = ( + 2D82163728F9A89300467FD1 /* AuthApiLoginWithOtpMethodHandlerTests.swift */, 5C335E4727FE68BC00EDDE3A /* AuthAPISpies.swift */, 5C59DA6427FFCF0600365CDB /* AuthAPIHandlerTests.swift */, 5C59DA6627FFE75600365CDB /* AuthAPILoginUsernameOrEmailMethodHandlerTests.swift */, @@ -581,6 +584,7 @@ 5C59DA8B2809386B00365CDB /* AuthAPIExtensionsTests.swift in Sources */, 5C59DA8D280938BE00365CDB /* WebAuthExtensionsTests.swift in Sources */, 5C335E3F27FBCD1D00EDDE3A /* SwiftAuth0FlutterPluginTests.swift in Sources */, + 2D82163828F9A89300467FD1 /* AuthApiLoginWithOtpMethodHandlerTests.swift in Sources */, 5C4E65C5286D1CFB00141449 /* CredentialsManagerHasValidMethodHandlerTests.swift in Sources */, 5C328B4827F7822600451E70 /* WebAuthHandlerTests.swift in Sources */, 5C328B5327F7B12F00451E70 /* WebAuthSpies.swift in Sources */, diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift index 73e64fbc..8601961d 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift @@ -111,6 +111,7 @@ extension AuthAPIHandlerTests { var expectations: [XCTestExpectation] = [] let methodHandlers: [AuthAPIHandler.Method: MethodHandler.Type] = [ .loginWithUsernameOrEmail: AuthAPILoginUsernameOrEmailMethodHandler.self, + .loginWithOtp: AuthAPILoginWithOtpMethodHandler.self, .signup: AuthAPISignupMethodHandler.self, .userInfo: AuthAPIUserInfoMethodHandler.self, .renew: AuthAPIRenewMethodHandler.self, diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift index f867d9c4..c0a2a17b 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift @@ -11,6 +11,7 @@ class SpyAuthentication: Authentication { var userInfoResult: AuthenticationResult = .success(UserInfo(json: ["sub": ""])!) var voidResult: AuthenticationResult = .success(()) var calledLoginWithUsernameOrEmail = false + var calledLoginWithOtp = false var calledSignup = false var calledUserInfo = false var calledRenew = false @@ -34,6 +35,9 @@ class SpyAuthentication: Authentication { } func login(withOTP otp: String, mfaToken: String) -> Request { + arguments["otp"] = otp + arguments["mfaToken"] = mfaToken + calledLoginWithOtp = true return request(credentialsResult) } diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthApiLoginWithOtpMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthApiLoginWithOtpMethodHandlerTests.swift new file mode 100644 index 00000000..6cc1dfba --- /dev/null +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthApiLoginWithOtpMethodHandlerTests.swift @@ -0,0 +1,117 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +fileprivate typealias Argument = AuthAPILoginWithOtpMethodHandler.Argument + +class AuthAPILoginWithOtpMethodHandlerTests: XCTestCase { + var spy: SpyAuthentication! + var sut: AuthAPILoginWithOtpMethodHandler! + + override func setUpWithError() throws { + spy = SpyAuthentication() + sut = AuthAPILoginWithOtpMethodHandler(client: spy) + } +} + +// MARK: - Required Arguments Error + +extension AuthAPILoginWithOtpMethodHandlerTests { + func testProducesErrorWhenRequiredArgumentsAreMissing() { + let keys: [Argument] = [.otp, .mfaToken] + 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: - ID Token Decoding Failed Error + +extension AuthAPILoginWithOtpMethodHandlerTests { + func testProducesErrorWithInvalidIDToken() { + let credentials = Credentials(idToken: "foo") + let expectation = self.expectation(description: "ID Token cannot be decoded") + spy.credentialsResult = .success(credentials) + sut.handle(with: arguments()) { result in + assert(result: result, isError: .idTokenDecodingFailed) + expectation.fulfill() + } + wait(for: [expectation]) + } +} + +// MARK: - Arguments + +extension AuthAPILoginWithOtpMethodHandlerTests { + + // MARK: otp + + func testAddsOtp() { + let key = Argument.otp + let value = "foo" + sut.handle(with: arguments(withKey: key, value: value)) { _ in } + XCTAssertEqual(spy.arguments[key] as? String, value) + } + + // MARK: mfaToken + + func testAddsMfaToken() { + let key = Argument.mfaToken + let value = "foo" + sut.handle(with: arguments(withKey: key, value: value)) { _ in } + XCTAssertEqual(spy.arguments[key] as? String, value) + } +} + +// MARK: - Login Result + +extension AuthAPILoginWithOtpMethodHandlerTests { + func testCallsSDKLoginWithOtpMethod() { + sut.handle(with: arguments()) { _ in } + XCTAssertTrue(spy.calledLoginWithOtp) + } + + func testProducesCredentials() { + let credentials = Credentials(accessToken: "accessToken", + tokenType: "tokenType", + idToken: testIdToken, + refreshToken: "refreshToken", + expiresIn: Date(), + scope: "foo bar") + let expectation = self.expectation(description: "Produced credentials") + spy.credentialsResult = .success(credentials) + sut.handle(with: arguments()) { result in + assert(result: result, has: CredentialsProperty.allCases) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesAuthenticationError() { + let error = AuthenticationError(info: [:], statusCode: 0) + let expectation = self.expectation(description: "Produced the AuthenticationError \(error)") + spy.credentialsResult = .failure(error) + sut.handle(with: arguments()) { result in + assert(result: result, isError: error) + expectation.fulfill() + } + wait(for: [expectation]) + } +} + +// MARK: - Helpers + +extension AuthAPILoginWithOtpMethodHandlerTests { + override func arguments() -> [String: Any] { + return [ + Argument.otp.rawValue: "", + Argument.mfaToken.rawValue: "" + ] + } +} diff --git a/auth0_flutter/example/pubspec.yaml b/auth0_flutter/example/pubspec.yaml index b38d6487..37d95e00 100644 --- a/auth0_flutter/example/pubspec.yaml +++ b/auth0_flutter/example/pubspec.yaml @@ -31,6 +31,10 @@ dependencies: sdk: flutter flutter_dotenv: ^5.0.2 +dependency_overrides: + auth0_flutter_platform_interface: + path: ../../auth0_flutter_platform_interface + dev_dependencies: # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/auth0_flutter/ios/Classes/AuthAPI/AuthAPIHandler.swift b/auth0_flutter/ios/Classes/AuthAPI/AuthAPIHandler.swift index c39eecc2..cbdb7c67 100644 --- a/auth0_flutter/ios/Classes/AuthAPI/AuthAPIHandler.swift +++ b/auth0_flutter/ios/Classes/AuthAPI/AuthAPIHandler.swift @@ -11,6 +11,7 @@ typealias AuthAPIMethodHandlerProvider = (_ method: AuthAPIHandler.Method, _ cli public class AuthAPIHandler: NSObject, FlutterPlugin { enum Method: String, CaseIterable { case loginWithUsernameOrEmail = "auth#login" + case loginWithOtp = "auth#loginOtp" case signup = "auth#signUp" case userInfo = "auth#userInfo" case renew = "auth#renew" @@ -35,6 +36,7 @@ public class AuthAPIHandler: NSObject, FlutterPlugin { var methodHandlerProvider: AuthAPIMethodHandlerProvider = { method, client in switch method { case .loginWithUsernameOrEmail: return AuthAPILoginUsernameOrEmailMethodHandler(client: client) + case .loginWithOtp: return AuthAPILoginWithOtpMethodHandler(client: client) case .signup: return AuthAPISignupMethodHandler(client: client) case .userInfo: return AuthAPIUserInfoMethodHandler(client: client) case .renew: return AuthAPIRenewMethodHandler(client: client) diff --git a/auth0_flutter/ios/Classes/AuthAPI/AuthAPILoginWithOtpMethodHandler.swift b/auth0_flutter/ios/Classes/AuthAPI/AuthAPILoginWithOtpMethodHandler.swift new file mode 100644 index 00000000..0ca7b00f --- /dev/null +++ b/auth0_flutter/ios/Classes/AuthAPI/AuthAPILoginWithOtpMethodHandler.swift @@ -0,0 +1,29 @@ +import Flutter +import Auth0 + +struct AuthAPILoginWithOtpMethodHandler: MethodHandler { + enum Argument: String { + case otp + case mfaToken + } + + let client: Authentication + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + guard let otp = arguments[Argument.otp] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.otp.rawValue))) + } + guard let mfaToken = arguments[Argument.mfaToken] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.mfaToken.rawValue))) + } + + client + .login(withOTP: otp, mfaToken: mfaToken) + .start { + switch $0 { + case let .success(credentials): callback(result(from: credentials)) + case let .failure(error): callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/lib/src/authentication_api.dart b/auth0_flutter/lib/src/authentication_api.dart index b2263c04..d47fe29d 100644 --- a/auth0_flutter/lib/src/authentication_api.dart +++ b/auth0_flutter/lib/src/authentication_api.dart @@ -73,6 +73,31 @@ class AuthenticationApi { parameters: parameters, ))); + /// Authenticates the user using a [mfaToken] and an [otp], after [login] returned with an [ApiException] with [ApiException.isMultifactorRequired] set to `true`. + /// If successful, it returns a set of tokens, as well as the user's profile (constructed from ID token claims). + /// + /// + /// ## Endpoint docs + /// https://auth0.com/docs/api/authentication#verify-with-one-time-password-otp- + /// + /// ## Usage example + /// + /// ```dart + /// final result = await auth0.api.loginWithOtp({ + /// otp: '123456', + /// mfaToken: 'received_mfa_token' + /// }); + /// ``` + Future loginWithOtp({ + required final String otp, + required final String mfaToken, + }) => + Auth0FlutterAuthPlatform.instance + .loginWithOtp(_createApiRequest(AuthLoginWithOtpOptions( + otp: otp, + mfaToken: mfaToken, + ))); + /// Fetches the user's profile from the /userinfo endpoint. An [accessToken] from a successful authentication call must be supplied. /// /// ## Endpoint diff --git a/auth0_flutter/pubspec.lock b/auth0_flutter/pubspec.lock index 7b028b15..d83e3e9a 100644 --- a/auth0_flutter/pubspec.lock +++ b/auth0_flutter/pubspec.lock @@ -32,9 +32,9 @@ packages: auth0_flutter_platform_interface: dependency: "direct main" description: - name: auth0_flutter_platform_interface - url: "https://pub.dartlang.org" - source: hosted + path: "../auth0_flutter_platform_interface" + relative: true + source: path version: "1.0.1" boolean_selector: dependency: transitive diff --git a/auth0_flutter/pubspec.yaml b/auth0_flutter/pubspec.yaml index fa7f2aa3..7c68f217 100644 --- a/auth0_flutter/pubspec.yaml +++ b/auth0_flutter/pubspec.yaml @@ -12,6 +12,10 @@ dependencies: flutter: sdk: flutter +dependency_overrides: + auth0_flutter_platform_interface: + path: ../auth0_flutter_platform_interface + dev_dependencies: build_runner: ^2.1.8 flutter_lints: ^2.0.1 diff --git a/auth0_flutter/test/authentication_api_test.dart b/auth0_flutter/test/authentication_api_test.dart index 3dbf71b0..ba9cb64c 100644 --- a/auth0_flutter/test/authentication_api_test.dart +++ b/auth0_flutter/test/authentication_api_test.dart @@ -155,6 +155,26 @@ void main() { }); }); + group('loginWithOtp', () { + test('passes through properties to the platform', () async { + when(mockedPlatform.loginWithOtp(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + + final result = await Auth0('test-domain', 'test-clientId') + .api + .loginWithOtp(otp: 'test-otp', mfaToken: 'test-mfa-token'); + + final verificationResult = verify(mockedPlatform.loginWithOtp(captureAny)) + .captured + .single as ApiRequest; + expect(verificationResult.account.domain, 'test-domain'); + expect(verificationResult.account.clientId, 'test-clientId'); + expect(verificationResult.options.mfaToken, 'test-mfa-token'); + expect(verificationResult.options.otp, 'test-otp'); + expect(result, TestPlatform.loginResult); + }); + }); + group('resetPassword', () { test('passes through properties to the platform', () async { when(mockedPlatform.resetPassword(any)).thenAnswer((final _) async => {}); diff --git a/auth0_flutter/test/authentication_api_test.mocks.dart b/auth0_flutter/test/authentication_api_test.mocks.dart index 2a0ee5ad..0048e14a 100644 --- a/auth0_flutter/test/authentication_api_test.mocks.dart +++ b/auth0_flutter/test/authentication_api_test.mocks.dart @@ -42,6 +42,12 @@ class MockTestPlatform extends _i1.Mock implements _i4.TestPlatform { returnValue: Future<_i2.Credentials>.value(_FakeCredentials_0())) as _i5.Future<_i2.Credentials>); @override + _i5.Future<_i2.Credentials> loginWithOtp( + _i3.ApiRequest<_i3.AuthLoginWithOtpOptions>? request) => + (super.noSuchMethod(Invocation.method(#login, [request]), + returnValue: Future<_i2.Credentials>.value(_FakeCredentials_0())) + as _i5.Future<_i2.Credentials>); + @override _i5.Future<_i3.UserProfile> userInfo( _i3.ApiRequest<_i3.AuthUserInfoOptions>? request) => (super.noSuchMethod(Invocation.method(#userInfo, [request]), diff --git a/auth0_flutter/test/web_authentication_test.mocks.dart b/auth0_flutter/test/web_authentication_test.mocks.dart index 9ae6cf19..37bc1a80 100644 --- a/auth0_flutter/test/web_authentication_test.mocks.dart +++ b/auth0_flutter/test/web_authentication_test.mocks.dart @@ -109,6 +109,6 @@ class MockCredentialsManager extends _i1.Mock @override _i4.Future clearCredentials() => (super.noSuchMethod(Invocation.method(#clear, []), - returnValue: Future.value(true), - returnValueForMissingStub: Future.value()) as _i4.Future); + returnValue: Future.value(true), + returnValueForMissingStub: Future.value()) as _i4.Future); } 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 8a8d55a6..0a11b112 100644 --- a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart +++ b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart @@ -1,6 +1,7 @@ export 'src/account.dart'; export 'src/auth/api_exception.dart'; export 'src/auth/auth_login_options.dart'; +export 'src/auth/auth_login_with_otp_options.dart'; export 'src/auth/auth_renew_access_token_options.dart'; export 'src/auth/auth_reset_password_options.dart'; export 'src/auth/auth_signup_options.dart'; diff --git a/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart b/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart index b9a9d188..bf03dcbd 100644 --- a/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart +++ b/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart @@ -35,6 +35,10 @@ class ApiException extends Auth0Exception { bool get isMultifactorRequired => _errorFlags.getBooleanOrFalse('isMultifactorRequired'); + String? get mfaToken => + details.containsKey('mfa_token') && details['mfa_token'] is String + ? details['mfa_token'] as String + : null; bool get isMultifactorEnrollRequired => _errorFlags.getBooleanOrFalse('isMultifactorEnrollRequired'); bool get isMultifactorCodeInvalid => diff --git a/auth0_flutter_platform_interface/lib/src/auth/auth_login_with_otp_options.dart b/auth0_flutter_platform_interface/lib/src/auth/auth_login_with_otp_options.dart new file mode 100644 index 00000000..264a281e --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/auth_login_with_otp_options.dart @@ -0,0 +1,11 @@ +import '../request/request_options.dart'; + +class AuthLoginWithOtpOptions implements RequestOptions { + final String otp; + final String mfaToken; + + AuthLoginWithOtpOptions({required this.otp, required this.mfaToken}); + + @override + Map toMap() => {'otp': otp, 'mfaToken': mfaToken}; +} 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 f69a5a30..8a9c7dc1 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 @@ -2,6 +2,7 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'auth/auth_login_options.dart'; +import 'auth/auth_login_with_otp_options.dart'; import 'auth/auth_renew_access_token_options.dart'; import 'auth/auth_reset_password_options.dart'; import 'auth/auth_signup_options.dart'; @@ -30,6 +31,11 @@ abstract class Auth0FlutterAuthPlatform extends PlatformInterface { throw UnimplementedError('authLogin() has not been implemented'); } + Future loginWithOtp( + final ApiRequest request) { + throw UnimplementedError('authLoginWithOtp() has not been implemented'); + } + Future userInfo(final ApiRequest request) { throw UnimplementedError('authUserInfo() 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 f6ad619e..22a451b9 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 @@ -2,6 +2,7 @@ import 'package:flutter/services.dart'; import 'auth/api_exception.dart'; import 'auth/auth_login_options.dart'; +import 'auth/auth_login_with_otp_options.dart'; import 'auth/auth_renew_access_token_options.dart'; import 'auth/auth_reset_password_options.dart'; import 'auth/auth_signup_options.dart'; @@ -15,6 +16,7 @@ import 'user_profile.dart'; const MethodChannel _channel = MethodChannel('auth0.com/auth0_flutter/auth'); const String authLoginMethod = 'auth#login'; +const String authLoginWithOtpMethod = 'auth#loginOtp'; const String authUserInfoMethod = 'auth#userInfo'; const String authSignUpMethod = 'auth#signUp'; const String authRenewMethod = 'auth#renew'; @@ -29,6 +31,15 @@ class MethodChannelAuth0FlutterAuth extends Auth0FlutterAuthPlatform { return Credentials.fromMap(result); } + @override + Future loginWithOtp( + final ApiRequest request) async { + final Map result = + await invokeRequest(method: authLoginWithOtpMethod, request: request); + + return Credentials.fromMap(result); + } + @override Future userInfo( final ApiRequest request) async { diff --git a/auth0_flutter_platform_interface/test/api_exception_test.dart b/auth0_flutter_platform_interface/test/api_exception_test.dart index 954c6bc6..7793de30 100644 --- a/auth0_flutter_platform_interface/test/api_exception_test.dart +++ b/auth0_flutter_platform_interface/test/api_exception_test.dart @@ -233,4 +233,22 @@ void main() { expect(exception.isLoginRequired, false); expect(exception.isPasswordLeaked, false); }); + + test('correctly gets mfaToken when present', () async { + final details = {'mfa_token': 'test-mfaToken'}; + final platformException = + PlatformException(code: 'test-code', details: details); + + final exception = ApiException.fromPlatformException(platformException); + expect(exception.mfaToken, 'test-mfaToken'); + }); + + test('correctly returns null when mfaToken is not present', () async { + const details = {}; + final platformException = + PlatformException(code: 'test-code', details: details); + + final exception = ApiException.fromPlatformException(platformException); + expect(exception.mfaToken, null); + }); } 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 29790b50..a87f4dcb 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 @@ -405,6 +405,128 @@ void main() { }); }); + group('loginWithOtp', () { + test('calls the correct MethodChannel method', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.loginResult); + + await MethodChannelAuth0FlutterAuth().loginWithOtp( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthLoginWithOtpOptions( + otp: 'test-otp', mfaToken: 'test-mfa-token')), + ); + + expect( + verify(mocked.methodCallHandler(captureAny)).captured.single.method, + 'auth#loginOtp'); + }); + + test('correctly maps all properties', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.loginResult); + + await MethodChannelAuth0FlutterAuth().loginWithOtp( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthLoginWithOtpOptions( + otp: 'test-otp', mfaToken: 'test-mfa-token')), + ); + + 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['otp'], 'test-otp'); + expect(verificationResult.arguments['mfaToken'], 'test-mfa-token'); + }); + + test('correctly returns the response from the Method Channel', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.loginResult); + + final result = await MethodChannelAuth0FlutterAuth().loginWithOtp( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthLoginWithOtpOptions( + mfaToken: 'test-mfa-token', otp: 'test-otp')), + ); + + verify(mocked.methodCallHandler(captureAny)); + + expect(result.accessToken, MethodCallHandler.loginResult['accessToken']); + expect(result.idToken, MethodCallHandler.loginResult['idToken']); + expect(result.expiresAt, + DateTime.parse(MethodCallHandler.loginResult['expiresAt'] as String)); + expect(result.scopes, MethodCallHandler.loginResult['scopes']); + expect( + result.refreshToken, MethodCallHandler.loginResult['refreshToken']); + expect(result.user.name, + MethodCallHandler.loginResult['userProfile']['name']); + }); + + test( + 'correctly returns the response from the Method Channel when properties missing', + () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.loginResultRequired); + + final result = await MethodChannelAuth0FlutterAuth().loginWithOtp( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: '', version: ''), + options: AuthLoginWithOtpOptions(otp: '', mfaToken: ''))); + + verify(mocked.methodCallHandler(captureAny)); + + expect(result.refreshToken, isNull); + }); + + 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().loginWithOtp( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthLoginWithOtpOptions(otp: '', mfaToken: '')), + ); + + 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().loginWithOtp( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthLoginWithOtpOptions(otp: '', mfaToken: '')), + ); + + return result; + } + + await expectLater(actual, throwsA(isA())); + }); + }); + group('resetPassword', () { test('calls the correct MethodChannel method', () async { when(mocked.methodCallHandler(any)).thenAnswer((final _) async => null);