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);