diff --git a/aws-auth-cognito/build.gradle.kts b/aws-auth-cognito/build.gradle.kts index d741d55a95..7a002160da 100644 --- a/aws-auth-cognito/build.gradle.kts +++ b/aws-auth-cognito/build.gradle.kts @@ -39,6 +39,8 @@ dependencies { implementation(libs.aws.cognitoidentityprovider) testImplementation(project(":testutils")) + testImplementation(project(":core")) + testImplementation(project(":aws-core")) //noinspection GradleDependency testImplementation(libs.test.json) @@ -60,6 +62,8 @@ dependencies { androidTestImplementation(libs.test.androidx.runner) androidTestImplementation(libs.test.androidx.junit) androidTestImplementation(libs.test.kotlin.coroutines) + androidTestImplementation(libs.test.totp) + androidTestImplementation(project(":aws-api")) androidTestImplementation(project(":testutils")) } diff --git a/aws-auth-cognito/src/androidTest/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPluginTOTPTests.kt b/aws-auth-cognito/src/androidTest/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPluginTOTPTests.kt new file mode 100644 index 0000000000..f1d4ac409e --- /dev/null +++ b/aws-auth-cognito/src/androidTest/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPluginTOTPTests.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.auth.cognito + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amplifyframework.auth.AuthUserAttribute +import com.amplifyframework.auth.AuthUserAttributeKey +import com.amplifyframework.auth.MFAType +import com.amplifyframework.auth.cognito.helpers.value +import com.amplifyframework.auth.cognito.test.R +import com.amplifyframework.auth.options.AuthSignUpOptions +import com.amplifyframework.auth.result.step.AuthSignInStep +import com.amplifyframework.core.AmplifyConfiguration +import com.amplifyframework.core.category.CategoryConfiguration +import com.amplifyframework.core.category.CategoryType +import com.amplifyframework.testutils.sync.SynchronousAuth +import dev.robinohs.totpkt.otp.totp.TotpGenerator +import dev.robinohs.totpkt.otp.totp.timesupport.generateCode +import java.util.Random +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AWSCognitoAuthPluginTOTPTests { + + private lateinit var authPlugin: AWSCognitoAuthPlugin + private lateinit var synchronousAuth: SynchronousAuth + private val password = UUID.randomUUID().toString() + private val userName = "testUser${Random().nextInt()}" + private val email = "$userName@testdomain.com" + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + val config = AmplifyConfiguration.fromConfigFile(context, R.raw.amplifyconfiguration_totp) + val authConfig: CategoryConfiguration = config.forCategoryType(CategoryType.AUTH) + val authConfigJson = authConfig.getPluginConfig("awsCognitoAuthPlugin") + authPlugin = AWSCognitoAuthPlugin() + authPlugin.configure(authConfigJson, context) + synchronousAuth = SynchronousAuth.delegatingTo(authPlugin) + signUpNewUser(userName, password, email) + synchronousAuth.signOut() + } + + @After + fun tearDown() { + synchronousAuth.deleteUser() + } + + /* + * This test signs up a new user and goes thru successful MFA Setup process. + * */ + @Test + fun mfa_setup() { + val result = synchronousAuth.signIn(userName, password) + Assert.assertEquals(AuthSignInStep.CONTINUE_SIGN_IN_WITH_TOTP_SETUP, result.nextStep.signInStep) + val otp = TotpGenerator().generateCode( + result.nextStep.totpSetupDetails!!.sharedSecret.toByteArray(), + System.currentTimeMillis() + ) + synchronousAuth.confirmSignIn(otp) + val currentUser = synchronousAuth.currentUser + Assert.assertEquals(userName.lowercase(), currentUser.username) + } + + /* + * This test signs up a new user, enter incorrect MFA code during verification and + * then enter correct OTP code to successfully set TOTP MFA. + * */ + @Test + fun mfasetup_with_incorrect_otp() { + val result = synchronousAuth.signIn(userName, password) + Assert.assertEquals(AuthSignInStep.CONTINUE_SIGN_IN_WITH_TOTP_SETUP, result.nextStep.signInStep) + try { + synchronousAuth.confirmSignIn("123456") + } catch (e: Exception) { + Assert.assertEquals("Code mismatch", e.cause?.message) + val otp = TotpGenerator().generateCode( + result.nextStep.totpSetupDetails!!.sharedSecret.toByteArray(), + System.currentTimeMillis() + ) + synchronousAuth.confirmSignIn(otp) + val currentUser = synchronousAuth.currentUser + Assert.assertEquals(userName.lowercase(), currentUser.username) + } + } + + /* + * This test signs up a new user, successfully setup MFA, sign-out and then goes thru sign-in with TOTP. + * */ + @Test + fun signIn_with_totp_after_mfa_setup() { + val result = synchronousAuth.signIn(userName, password) + Assert.assertEquals(AuthSignInStep.CONTINUE_SIGN_IN_WITH_TOTP_SETUP, result.nextStep.signInStep) + val otp = TotpGenerator().generateCode( + result.nextStep.totpSetupDetails!!.sharedSecret.toByteArray() + ) + synchronousAuth.confirmSignIn(otp) + synchronousAuth.signOut() + + val signInResult = synchronousAuth.signIn(userName, password) + Assert.assertEquals(AuthSignInStep.CONFIRM_SIGN_IN_WITH_TOTP_CODE, signInResult.nextStep.signInStep) + val otpCode = TotpGenerator().generateCode( + result.nextStep.totpSetupDetails!!.sharedSecret.toByteArray(), + System.currentTimeMillis() + 30 * 1000 // 30 sec is added to generate new OTP code + ) + synchronousAuth.confirmSignIn(otpCode) + val currentUser = synchronousAuth.currentUser + Assert.assertEquals(userName.lowercase(), currentUser.username) + } + + /* + * This test signs up a new user, successfully setup MFA, update user attribute to add phone number, + * sign-out the user, goes thru MFA selection flow during sign-in, select TOTP MFA type, + * successfully sign-in using TOTP + * */ + @Test + fun select_mfa_type() { + val result = synchronousAuth.signIn(userName, password) + Assert.assertEquals(AuthSignInStep.CONTINUE_SIGN_IN_WITH_TOTP_SETUP, result.nextStep.signInStep) + val otp = TotpGenerator().generateCode( + result.nextStep.totpSetupDetails!!.sharedSecret.toByteArray() + ) + synchronousAuth.confirmSignIn(otp) + synchronousAuth.updateUserAttribute(AuthUserAttribute(AuthUserAttributeKey.phoneNumber(), "+19876543210")) + updateMFAPreference(MFAPreference.ENABLED, MFAPreference.ENABLED) + synchronousAuth.signOut() + val signInResult = synchronousAuth.signIn(userName, password) + Assert.assertEquals(AuthSignInStep.CONTINUE_SIGN_IN_WITH_MFA_SELECTION, signInResult.nextStep.signInStep) + val totpSignInResult = synchronousAuth.confirmSignIn(MFAType.TOTP.value) + Assert.assertEquals(AuthSignInStep.CONFIRM_SIGN_IN_WITH_TOTP_CODE, totpSignInResult.nextStep.signInStep) + val otpCode = TotpGenerator().generateCode( + result.nextStep.totpSetupDetails!!.sharedSecret.toByteArray(), + System.currentTimeMillis() + 30 * 1000 // 30 sec is added to generate new OTP code + ) + synchronousAuth.confirmSignIn(otpCode) + val currentUser = synchronousAuth.currentUser + Assert.assertEquals(userName.lowercase(), currentUser.username) + } + + private fun signUpNewUser(userName: String, password: String, email: String) { + val options = AuthSignUpOptions.builder() + .userAttributes( + listOf( + AuthUserAttribute(AuthUserAttributeKey.email(), email) + ) + ).build() + synchronousAuth.signUp(userName, password, options) + } + + private fun updateMFAPreference(sms: MFAPreference, totp: MFAPreference) { + val latch = CountDownLatch(1) + authPlugin.updateMFAPreference(sms, totp, { latch.countDown() }, { latch.countDown() }) + latch.await(5, TimeUnit.SECONDS) + } +} diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPlugin.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPlugin.kt index c6b10b6cd6..6e5ad9173c 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPlugin.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPlugin.kt @@ -31,7 +31,9 @@ import com.amplifyframework.auth.AuthSession import com.amplifyframework.auth.AuthUser import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.AuthUserAttributeKey +import com.amplifyframework.auth.TOTPSetupDetails import com.amplifyframework.auth.cognito.asf.UserContextDataProvider +import com.amplifyframework.auth.cognito.options.AWSCognitoAuthVerifyTOTPSetupOptions import com.amplifyframework.auth.cognito.options.FederateToIdentityPoolOptions import com.amplifyframework.auth.cognito.result.FederateToIdentityPoolResult import com.amplifyframework.auth.exceptions.ConfigurationException @@ -48,6 +50,7 @@ import com.amplifyframework.auth.options.AuthSignOutOptions import com.amplifyframework.auth.options.AuthSignUpOptions import com.amplifyframework.auth.options.AuthUpdateUserAttributeOptions import com.amplifyframework.auth.options.AuthUpdateUserAttributesOptions +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions import com.amplifyframework.auth.options.AuthWebUISignInOptions import com.amplifyframework.auth.result.AuthResetPasswordResult import com.amplifyframework.auth.result.AuthSignInResult @@ -768,6 +771,40 @@ class AWSCognitoAuthPlugin : AuthPlugin() { ) } + override fun setUpTOTP(onSuccess: Consumer, onError: Consumer) { + queueChannel.trySend( + pluginScope.launch(start = CoroutineStart.LAZY) { + try { + val result = queueFacade.setUpTOTP() + onSuccess.accept(result) + } catch (e: Exception) { + onError.accept(e.toAuthException()) + } + } + ) + } + + override fun verifyTOTPSetup(code: String, onSuccess: Action, onError: Consumer) { + verifyTOTPSetup(code, AWSCognitoAuthVerifyTOTPSetupOptions.CognitoBuilder().build(), onSuccess, onError) + } + + override fun verifyTOTPSetup( + code: String, + options: AuthVerifyTOTPSetupOptions, + onSuccess: Action, + onError: Consumer + ) { + queueChannel.trySend( + pluginScope.launch(start = CoroutineStart.LAZY) { + try { + queueFacade.verifyTOTPSetup(code, options) + onSuccess.call() + } catch (e: Exception) { + onError.accept(e.toAuthException()) + } + } + ) + } override fun getEscapeHatch() = realPlugin.escapeHatch() override fun getPluginKey() = AWS_COGNITO_AUTH_PLUGIN_KEY @@ -852,4 +889,38 @@ class AWSCognitoAuthPlugin : AuthPlugin() { } ) } + + fun fetchMFAPreference( + onSuccess: Consumer, + onError: Consumer + ) { + queueChannel.trySend( + pluginScope.launch(start = CoroutineStart.LAZY) { + try { + val result = queueFacade.fetchMFAPreference() + onSuccess.accept(result) + } catch (e: Exception) { + onError.accept(e.toAuthException()) + } + } + ) + } + + fun updateMFAPreference( + sms: MFAPreference?, + totp: MFAPreference?, + onSuccess: Action, + onError: Consumer + ) { + queueChannel.trySend( + pluginScope.launch(start = CoroutineStart.LAZY) { + try { + queueFacade.updateMFAPreference(sms, totp) + onSuccess.call() + } catch (e: Exception) { + onError.accept(e.toAuthException()) + } + } + ) + } } diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AuthStateMachine.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AuthStateMachine.kt index c0467902a3..a781329481 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AuthStateMachine.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AuthStateMachine.kt @@ -24,6 +24,7 @@ import com.amplifyframework.auth.cognito.actions.FetchAuthSessionCognitoActions import com.amplifyframework.auth.cognito.actions.HostedUICognitoActions import com.amplifyframework.auth.cognito.actions.MigrateAuthCognitoActions import com.amplifyframework.auth.cognito.actions.SRPCognitoActions +import com.amplifyframework.auth.cognito.actions.SetupTOTPCognitoActions import com.amplifyframework.auth.cognito.actions.SignInChallengeCognitoActions import com.amplifyframework.auth.cognito.actions.SignInCognitoActions import com.amplifyframework.auth.cognito.actions.SignInCustomCognitoActions @@ -42,6 +43,7 @@ import com.amplifyframework.statemachine.codegen.states.HostedUISignInState import com.amplifyframework.statemachine.codegen.states.MigrateSignInState import com.amplifyframework.statemachine.codegen.states.RefreshSessionState import com.amplifyframework.statemachine.codegen.states.SRPSignInState +import com.amplifyframework.statemachine.codegen.states.SetupTOTPState import com.amplifyframework.statemachine.codegen.states.SignInChallengeState import com.amplifyframework.statemachine.codegen.states.SignInState import com.amplifyframework.statemachine.codegen.states.SignOutState @@ -62,10 +64,11 @@ internal class AuthStateMachine( SignInChallengeState.Resolver(SignInChallengeCognitoActions), HostedUISignInState.Resolver(HostedUICognitoActions), DeviceSRPSignInState.Resolver(DeviceSRPCognitoSignInActions), + SetupTOTPState.Resolver(SetupTOTPCognitoActions), SignInCognitoActions ), SignOutState.Resolver(SignOutCognitoActions), - AuthenticationCognitoActions, + AuthenticationCognitoActions ), AuthorizationState.Resolver( FetchAuthSessionState.Resolver(FetchAuthSessionCognitoActions), @@ -93,10 +96,11 @@ internal class AuthStateMachine( SignInChallengeState.Resolver(SignInChallengeCognitoActions).logging(), HostedUISignInState.Resolver(HostedUICognitoActions).logging(), DeviceSRPSignInState.Resolver(DeviceSRPCognitoSignInActions).logging(), + SetupTOTPState.Resolver(SetupTOTPCognitoActions).logging(), SignInCognitoActions ).logging(), SignOutState.Resolver(SignOutCognitoActions).logging(), - AuthenticationCognitoActions, + AuthenticationCognitoActions ).logging(), AuthorizationState.Resolver( FetchAuthSessionState.Resolver(FetchAuthSessionCognitoActions).logging(), diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/CognitoAuthExceptionConverter.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/CognitoAuthExceptionConverter.kt index a6245b40b8..14c94d8415 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/CognitoAuthExceptionConverter.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/CognitoAuthExceptionConverter.kt @@ -18,6 +18,7 @@ package com.amplifyframework.auth.cognito import aws.sdk.kotlin.services.cognitoidentityprovider.model.AliasExistsException import aws.sdk.kotlin.services.cognitoidentityprovider.model.CodeDeliveryFailureException import aws.sdk.kotlin.services.cognitoidentityprovider.model.CodeMismatchException +import aws.sdk.kotlin.services.cognitoidentityprovider.model.EnableSoftwareTokenMfaException import aws.sdk.kotlin.services.cognitoidentityprovider.model.ExpiredCodeException import aws.sdk.kotlin.services.cognitoidentityprovider.model.InvalidParameterException import aws.sdk.kotlin.services.cognitoidentityprovider.model.InvalidPasswordException @@ -89,6 +90,8 @@ internal class CognitoAuthExceptionConverter { com.amplifyframework.auth.cognito.exceptions.service.TooManyRequestsException(error) is PasswordResetRequiredException -> com.amplifyframework.auth.cognito.exceptions.service.PasswordResetRequiredException(error) + is EnableSoftwareTokenMfaException -> + com.amplifyframework.auth.cognito.exceptions.service.EnableSoftwareTokenMFAException(error) is UserLambdaValidationException -> com.amplifyframework.auth.cognito.exceptions.service.UserLambdaValidationException( error.message, diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/KotlinAuthFacadeInternal.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/KotlinAuthFacadeInternal.kt index ca4b4dc456..f64b110fcb 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/KotlinAuthFacadeInternal.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/KotlinAuthFacadeInternal.kt @@ -24,6 +24,7 @@ import com.amplifyframework.auth.AuthSession import com.amplifyframework.auth.AuthUser import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.AuthUserAttributeKey +import com.amplifyframework.auth.TOTPSetupDetails import com.amplifyframework.auth.cognito.options.FederateToIdentityPoolOptions import com.amplifyframework.auth.cognito.result.FederateToIdentityPoolResult import com.amplifyframework.auth.options.AuthConfirmResetPasswordOptions @@ -38,6 +39,7 @@ import com.amplifyframework.auth.options.AuthSignOutOptions import com.amplifyframework.auth.options.AuthSignUpOptions import com.amplifyframework.auth.options.AuthUpdateUserAttributeOptions import com.amplifyframework.auth.options.AuthUpdateUserAttributesOptions +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions import com.amplifyframework.auth.options.AuthWebUISignInOptions import com.amplifyframework.auth.result.AuthResetPasswordResult import com.amplifyframework.auth.result.AuthSignInResult @@ -520,4 +522,46 @@ internal class KotlinAuthFacadeInternal(private val delegate: RealAWSCognitoAuth ) } } + + suspend fun setUpTOTP(): TOTPSetupDetails { + return suspendCoroutine { continuation -> + delegate.setUpTOTP( + { continuation.resume(it) }, + { continuation.resumeWithException(it) } + ) + } + } + suspend fun verifyTOTPSetup(code: String, options: AuthVerifyTOTPSetupOptions) { + return suspendCoroutine { continuation -> + delegate.verifyTOTPSetup( + code, + options, + { continuation.resume(Unit) }, + { continuation.resumeWithException(it) } + ) + } + } + + suspend fun fetchMFAPreference(): UserMFAPreference { + return suspendCoroutine { continuation -> + delegate.fetchMFAPreference( + { continuation.resume(it) }, + { continuation.resumeWithException(it) } + ) + } + } + + suspend fun updateMFAPreference( + sms: MFAPreference?, + totp: MFAPreference? + ) { + return suspendCoroutine { continuation -> + delegate.updateMFAPreference( + sms, + totp, + { continuation.resume(Unit) }, + { continuation.resumeWithException(it) } + ) + } + } } diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/RealAWSCognitoAuthPlugin.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/RealAWSCognitoAuthPlugin.kt index 24a2abafec..d5a71788d9 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/RealAWSCognitoAuthPlugin.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/RealAWSCognitoAuthPlugin.kt @@ -18,21 +18,29 @@ package com.amplifyframework.auth.cognito import android.app.Activity import android.content.Intent import androidx.annotation.WorkerThread +import aws.sdk.kotlin.services.cognitoidentityprovider.associateSoftwareToken import aws.sdk.kotlin.services.cognitoidentityprovider.confirmForgotPassword import aws.sdk.kotlin.services.cognitoidentityprovider.confirmSignUp +import aws.sdk.kotlin.services.cognitoidentityprovider.getUser import aws.sdk.kotlin.services.cognitoidentityprovider.model.AnalyticsMetadataType import aws.sdk.kotlin.services.cognitoidentityprovider.model.AttributeType +import aws.sdk.kotlin.services.cognitoidentityprovider.model.ChallengeNameType import aws.sdk.kotlin.services.cognitoidentityprovider.model.ChangePasswordRequest import aws.sdk.kotlin.services.cognitoidentityprovider.model.DeviceRememberedStatusType import aws.sdk.kotlin.services.cognitoidentityprovider.model.GetUserAttributeVerificationCodeRequest import aws.sdk.kotlin.services.cognitoidentityprovider.model.GetUserRequest import aws.sdk.kotlin.services.cognitoidentityprovider.model.ListDevicesRequest +import aws.sdk.kotlin.services.cognitoidentityprovider.model.SmsMfaSettingsType +import aws.sdk.kotlin.services.cognitoidentityprovider.model.SoftwareTokenMfaSettingsType import aws.sdk.kotlin.services.cognitoidentityprovider.model.UpdateDeviceStatusRequest import aws.sdk.kotlin.services.cognitoidentityprovider.model.UpdateUserAttributesRequest import aws.sdk.kotlin.services.cognitoidentityprovider.model.UpdateUserAttributesResponse +import aws.sdk.kotlin.services.cognitoidentityprovider.model.VerifySoftwareTokenResponseType import aws.sdk.kotlin.services.cognitoidentityprovider.model.VerifyUserAttributeRequest import aws.sdk.kotlin.services.cognitoidentityprovider.resendConfirmationCode +import aws.sdk.kotlin.services.cognitoidentityprovider.setUserMfaPreference import aws.sdk.kotlin.services.cognitoidentityprovider.signUp +import aws.sdk.kotlin.services.cognitoidentityprovider.verifySoftwareToken import com.amplifyframework.AmplifyException import com.amplifyframework.annotations.InternalAmplifyApi import com.amplifyframework.auth.AWSCognitoAuthMetadataType @@ -48,6 +56,8 @@ import com.amplifyframework.auth.AuthSession import com.amplifyframework.auth.AuthUser import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.AuthUserAttributeKey +import com.amplifyframework.auth.MFAType +import com.amplifyframework.auth.TOTPSetupDetails import com.amplifyframework.auth.cognito.exceptions.configuration.InvalidOauthConfigurationException import com.amplifyframework.auth.cognito.exceptions.configuration.InvalidUserPoolConfigurationException import com.amplifyframework.auth.cognito.exceptions.invalidstate.SignedInException @@ -59,6 +69,7 @@ import com.amplifyframework.auth.cognito.helpers.AuthHelper import com.amplifyframework.auth.cognito.helpers.HostedUIHelper import com.amplifyframework.auth.cognito.helpers.SessionHelper import com.amplifyframework.auth.cognito.helpers.SignInChallengeHelper +import com.amplifyframework.auth.cognito.helpers.getMFAType import com.amplifyframework.auth.cognito.helpers.identityProviderName import com.amplifyframework.auth.cognito.options.AWSCognitoAuthConfirmResetPasswordOptions import com.amplifyframework.auth.cognito.options.AWSCognitoAuthConfirmSignInOptions @@ -70,6 +81,7 @@ import com.amplifyframework.auth.cognito.options.AWSCognitoAuthSignOutOptions import com.amplifyframework.auth.cognito.options.AWSCognitoAuthSignUpOptions import com.amplifyframework.auth.cognito.options.AWSCognitoAuthUpdateUserAttributeOptions import com.amplifyframework.auth.cognito.options.AWSCognitoAuthUpdateUserAttributesOptions +import com.amplifyframework.auth.cognito.options.AWSCognitoAuthVerifyTOTPSetupOptions import com.amplifyframework.auth.cognito.options.AWSCognitoAuthWebUISignInOptions import com.amplifyframework.auth.cognito.options.AuthFlowType import com.amplifyframework.auth.cognito.options.FederateToIdentityPoolOptions @@ -98,6 +110,7 @@ import com.amplifyframework.auth.options.AuthSignOutOptions import com.amplifyframework.auth.options.AuthSignUpOptions import com.amplifyframework.auth.options.AuthUpdateUserAttributeOptions import com.amplifyframework.auth.options.AuthUpdateUserAttributesOptions +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions import com.amplifyframework.auth.options.AuthWebUISignInOptions import com.amplifyframework.auth.result.AuthResetPasswordResult import com.amplifyframework.auth.result.AuthSignInResult @@ -118,6 +131,7 @@ import com.amplifyframework.hub.HubEvent import com.amplifyframework.logging.Logger import com.amplifyframework.statemachine.StateChangeListenerToken import com.amplifyframework.statemachine.codegen.data.AmplifyCredential +import com.amplifyframework.statemachine.codegen.data.AuthChallenge import com.amplifyframework.statemachine.codegen.data.AuthConfiguration import com.amplifyframework.statemachine.codegen.data.DeviceMetadata import com.amplifyframework.statemachine.codegen.data.FederatedToken @@ -131,6 +145,7 @@ import com.amplifyframework.statemachine.codegen.events.AuthenticationEvent import com.amplifyframework.statemachine.codegen.events.AuthorizationEvent import com.amplifyframework.statemachine.codegen.events.DeleteUserEvent import com.amplifyframework.statemachine.codegen.events.HostedUIEvent +import com.amplifyframework.statemachine.codegen.events.SetupTOTPEvent import com.amplifyframework.statemachine.codegen.events.SignInChallengeEvent import com.amplifyframework.statemachine.codegen.events.SignOutEvent import com.amplifyframework.statemachine.codegen.states.AuthState @@ -139,6 +154,7 @@ import com.amplifyframework.statemachine.codegen.states.AuthorizationState import com.amplifyframework.statemachine.codegen.states.DeleteUserState import com.amplifyframework.statemachine.codegen.states.HostedUISignInState import com.amplifyframework.statemachine.codegen.states.SRPSignInState +import com.amplifyframework.statemachine.codegen.states.SetupTOTPState import com.amplifyframework.statemachine.codegen.states.SignInChallengeState import com.amplifyframework.statemachine.codegen.states.SignInState import com.amplifyframework.statemachine.codegen.states.SignOutState @@ -498,7 +514,8 @@ internal class RealAWSCognitoAuthPlugin( ) // Continue sign in is AuthenticationState.SignedOut, - is AuthenticationState.Configured -> { + is AuthenticationState.Configured + -> { _signIn(username, password, signInOptions, onSuccess, onError) } is AuthenticationState.SignedIn -> onError.accept(SignedInException()) @@ -543,6 +560,7 @@ internal class RealAWSCognitoAuthPlugin( val signInState = authNState.signInState val srpSignInState = (signInState as? SignInState.SigningInWithSRP)?.srpSignInState val challengeState = (signInState as? SignInState.ResolvingChallenge)?.challengeState + val totpSetupState = (signInState as? SignInState.ResolvingTOTPSetup)?.setupTOTPState when { srpSignInState is SRPSignInState.Error -> { authStateMachine.cancel(token) @@ -560,6 +578,22 @@ internal class RealAWSCognitoAuthPlugin( authStateMachine.cancel(token) SignInChallengeHelper.getNextStep(challengeState.challenge, onSuccess, onError) } + + totpSetupState is SetupTOTPState.WaitingForAnswer -> { + authStateMachine.cancel(token) + SignInChallengeHelper.getNextStep( + AuthChallenge( + ChallengeNameType.MfaSetup.value, + null, + null, + null + ), + onSuccess, + onError, + totpSetupState.signInTOTPSetupData + ) + totpSetupState?.hasNewResponse = false + } } } authNState is AuthenticationState.SignedIn && @@ -567,7 +601,7 @@ internal class RealAWSCognitoAuthPlugin( authStateMachine.cancel(token) val authSignInResult = AuthSignInResult( true, - AuthNextSignInStep(AuthSignInStep.DONE, mapOf(), null) + AuthNextSignInStep(AuthSignInStep.DONE, mapOf(), null, null, null) ) onSuccess.accept(authSignInResult) sendHubEvent(AuthChannelEventName.SIGNED_IN.toString()) @@ -616,12 +650,19 @@ internal class RealAWSCognitoAuthPlugin( if (signInState is SignInState.ResolvingChallenge) { when (signInState.challengeState) { is SignInChallengeState.WaitingForAnswer, is SignInChallengeState.Error -> { - _confirmSignIn(challengeResponse, options, onSuccess, onError) + _confirmSignIn(signInState, challengeResponse, options, onSuccess, onError) } else -> { onError.accept(InvalidStateException()) } } + } else if (signInState is SignInState.ResolvingTOTPSetup) { + when (signInState.setupTOTPState) { + is SetupTOTPState.WaitingForAnswer, is SetupTOTPState.Error -> { + _confirmSignIn(signInState, challengeResponse, options, onSuccess, onError) + } + else -> onError.accept(InvalidStateException()) + } } else { onError.accept(InvalidStateException()) } @@ -629,6 +670,7 @@ internal class RealAWSCognitoAuthPlugin( } private fun _confirmSignIn( + signInState: SignInState, challengeResponse: String, options: AuthConfirmSignInOptions, onSuccess: Consumer, @@ -641,13 +683,14 @@ internal class RealAWSCognitoAuthPlugin( val authNState = authState.authNState val authZState = authState.authZState val signInState = (authNState as? AuthenticationState.SigningIn)?.signInState + val totpSetupState = (signInState as? SignInState.ResolvingTOTPSetup)?.setupTOTPState when { authNState is AuthenticationState.SignedIn && authZState is AuthorizationState.SessionEstablished -> { authStateMachine.cancel(token) val authSignInResult = AuthSignInResult( true, - AuthNextSignInStep(AuthSignInStep.DONE, mapOf(), null) + AuthNextSignInStep(AuthSignInStep.DONE, mapOf(), null, null, null) ) onSuccess.accept(authSignInResult) sendHubEvent(AuthChannelEventName.SIGNED_IN.toString()) @@ -656,7 +699,8 @@ internal class RealAWSCognitoAuthPlugin( authStateMachine.cancel(token) onError.accept( CognitoAuthExceptionConverter.lookup( - signInState.exception, "Confirm Sign in failed." + signInState.exception, + "Confirm Sign in failed." ) ) } @@ -665,21 +709,64 @@ internal class RealAWSCognitoAuthPlugin( (signInState.challengeState as SignInChallengeState.WaitingForAnswer).hasNewResponse -> { authStateMachine.cancel(token) val signInChallengeState = signInState.challengeState as SignInChallengeState.WaitingForAnswer - var signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE - if (signInChallengeState.challenge.challengeName == "SMS_MFA") { - signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE - } else if (signInChallengeState.challenge.challengeName == "NEW_PASSWORD_REQUIRED") { - signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_NEW_PASSWORD + var allowedMFATypes: Set? = null + val signInStep = when (signInChallengeState.challenge.challengeName) { + "SMS_MFA" -> AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE + "NEW_PASSWORD_REQUIRED" -> AuthSignInStep.CONFIRM_SIGN_IN_WITH_NEW_PASSWORD + "SOFTWARE_TOKEN_MFA" -> AuthSignInStep.CONFIRM_SIGN_IN_WITH_TOTP_CODE + "SELECT_MFA_TYPE" -> { + signInChallengeState.challenge.parameters?.get("MFAS_CAN_CHOOSE")?.let { + allowedMFATypes = SignInChallengeHelper.getAllowedMFATypes(it) + } + AuthSignInStep.CONTINUE_SIGN_IN_WITH_MFA_SELECTION + } + else -> AuthSignInStep.CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE } - val authSignInResult = AuthSignInResult( false, - AuthNextSignInStep(signInStep, signInChallengeState.challenge.parameters ?: mapOf(), null) + AuthNextSignInStep( + signInStep, + signInChallengeState.challenge.parameters ?: mapOf(), + null, + null, + allowedMFATypes + ) ) onSuccess.accept(authSignInResult) (signInState.challengeState as SignInChallengeState.WaitingForAnswer).hasNewResponse = false } + signInState is SignInState.ResolvingTOTPSetup && + totpSetupState is SetupTOTPState.WaitingForAnswer && + totpSetupState.hasNewResponse -> { + authStateMachine.cancel(token) + SignInChallengeHelper.getNextStep( + AuthChallenge( + ChallengeNameType.MfaSetup.value, + null, + null, + null + ), + onSuccess, + onError, + totpSetupState.signInTOTPSetupData + ) + totpSetupState.hasNewResponse = false + } + + signInState is SignInState.ResolvingTOTPSetup && + totpSetupState is SetupTOTPState.Error && + totpSetupState.hasNewResponse -> { + authStateMachine.cancel(token) + onError.accept( + CognitoAuthExceptionConverter.lookup( + totpSetupState.exception, + "Confirm Sign in failed." + ) + ) + totpSetupState.hasNewResponse = false + } + signInState is SignInState.ResolvingChallenge && signInState.challengeState is SignInChallengeState.Error && (signInState.challengeState as SignInChallengeState.Error).hasNewResponse -> { @@ -695,16 +782,60 @@ internal class RealAWSCognitoAuthPlugin( (signInState.challengeState as SignInChallengeState.Error).hasNewResponse = false } } - }, { - val awsCognitoConfirmSignInOptions = options as? AWSCognitoAuthConfirmSignInOptions - val event = SignInChallengeEvent( - SignInChallengeEvent.EventType.VerifyChallengeAnswer( - challengeResponse, - awsCognitoConfirmSignInOptions?.metadata ?: mapOf() - ) - ) - authStateMachine.send(event) - } + }, + { + val awsCognitoConfirmSignInOptions = options as? AWSCognitoAuthConfirmSignInOptions + when (signInState) { + is SignInState.ResolvingChallenge -> { + val event = SignInChallengeEvent( + SignInChallengeEvent.EventType.VerifyChallengeAnswer( + challengeResponse, + awsCognitoConfirmSignInOptions?.metadata ?: mapOf() + ) + ) + authStateMachine.send(event) + } + + is SignInState.ResolvingTOTPSetup -> { + when (signInState.setupTOTPState) { + is SetupTOTPState.WaitingForAnswer -> { + val setupData = + (signInState.setupTOTPState as SetupTOTPState.WaitingForAnswer).signInTOTPSetupData + + val event = SetupTOTPEvent( + SetupTOTPEvent.EventType.VerifyChallengeAnswer( + challengeResponse, + setupData.username, + setupData.session, + awsCognitoConfirmSignInOptions?.friendlyDeviceName + ) + ) + authStateMachine.send(event) + } + is SetupTOTPState.Error -> { + val username = + (signInState.setupTOTPState as SetupTOTPState.Error).username + val session = + (signInState.setupTOTPState as SetupTOTPState.Error).session + + val event = SetupTOTPEvent( + SetupTOTPEvent.EventType.VerifyChallengeAnswer( + challengeResponse, + username, + session, + awsCognitoConfirmSignInOptions?.friendlyDeviceName + ) + ) + authStateMachine.send(event) + } + + else -> onError.accept(InvalidStateException()) + } + } + + else -> onError.accept(InvalidStateException()) + } + } ) } @@ -853,7 +984,7 @@ internal class RealAWSCognitoAuthPlugin( val authSignInResult = AuthSignInResult( true, - AuthNextSignInStep(AuthSignInStep.DONE, mapOf(), null) + AuthNextSignInStep(AuthSignInStep.DONE, mapOf(), null, null, null) ) onSuccess.accept(authSignInResult) sendHubEvent(AuthChannelEventName.SIGNED_IN.toString()) @@ -1001,7 +1132,9 @@ internal class RealAWSCognitoAuthPlugin( ) } _fetchAuthSession(onSuccess, onError) - } else onSuccess.accept(credential.getCognitoSession()) + } else { + onSuccess.accept(credential.getCognitoSession()) + } } is AuthorizationState.Error -> { val error = authZState.exception @@ -1218,7 +1351,8 @@ internal class RealAWSCognitoAuthPlugin( val pinpointEndpointId = authEnvironment.getPinpointEndpointId() ResetPasswordUseCase( - cognitoIdentityProviderClient, appClient, + cognitoIdentityProviderClient, + appClient, configuration.userPool?.appClientSecret ).execute( username, @@ -1360,7 +1494,6 @@ internal class RealAWSCognitoAuthPlugin( when (authState.authNState) { // Check if user signed in is AuthenticationState.SignedIn -> { - GlobalScope.launch { try { val accessToken = getSession().userPoolTokensResult.value?.accessToken @@ -1446,9 +1579,8 @@ internal class RealAWSCognitoAuthPlugin( private suspend fun updateUserAttributes( attributes: List, - userAttributesOptionsMetadata: Map?, + userAttributesOptionsMetadata: Map? ): MutableMap { - return suspendCoroutine { continuation -> authStateMachine.getCurrentState { authState -> @@ -1497,14 +1629,12 @@ internal class RealAWSCognitoAuthPlugin( response: UpdateUserAttributesResponse?, userAttributeList: List ): MutableMap { - val finalResult = HashMap() response?.codeDeliveryDetailsList?.let { val codeDeliveryDetailsList = it for (item in codeDeliveryDetailsList) { item.attributeName?.let { - val deliveryMedium = AuthCodeDeliveryDetails.DeliveryMedium.fromString(item.deliveryMedium?.value) val authCodeDeliveryDetails = AuthCodeDeliveryDetails( item.destination.toString(), @@ -1567,7 +1697,6 @@ internal class RealAWSCognitoAuthPlugin( getUserAttributeVerificationCodeResponse?.codeDeliveryDetails?.let { val codeDeliveryDetails = it codeDeliveryDetails.attributeName?.let { - val deliveryMedium = AuthCodeDeliveryDetails.DeliveryMedium.fromString( codeDeliveryDetails.deliveryMedium?.value ) @@ -1877,7 +2006,6 @@ internal class RealAWSCognitoAuthPlugin( authZState is AuthorizationState.SessionEstablished || authZState is AuthorizationState.Error ) -> { - val existingCredential = when (authZState) { is AuthorizationState.SessionEstablished -> authZState.amplifyCredential is AuthorizationState.Error -> { @@ -1986,6 +2114,200 @@ internal class RealAWSCognitoAuthPlugin( } } + override fun setUpTOTP(onSuccess: Consumer, onError: Consumer) { + authStateMachine.getCurrentState { authState -> + when (authState.authNState) { + is AuthenticationState.SignedIn -> { + GlobalScope.launch { + try { + val accessToken = getSession().userPoolTokensResult.value?.accessToken + accessToken?.let { token -> + SessionHelper.getUsername(token)?.let { username -> + authEnvironment.cognitoAuthService + .cognitoIdentityProviderClient?.associateSoftwareToken { + this.accessToken = token + }?.also { response -> + response.secretCode?.let { secret -> + onSuccess.accept( + TOTPSetupDetails( + secret, + username + ) + ) + } + } + } + } ?: onError.accept(SignedOutException()) + } catch (error: Exception) { + onError.accept( + CognitoAuthExceptionConverter.lookup( + error, + "Cannot find a multi-factor authentication (MFA) method." + ) + ) + } + } + } + + else -> onError.accept(InvalidStateException()) + } + } + } + + override fun verifyTOTPSetup( + code: String, + onSuccess: Action, + onError: Consumer + ) { + verifyTotp(code, null, onSuccess, onError) + } + + override fun verifyTOTPSetup( + code: String, + options: AuthVerifyTOTPSetupOptions, + onSuccess: Action, + onError: Consumer + ) { + val cognitoOptions = options as? AWSCognitoAuthVerifyTOTPSetupOptions + verifyTotp(code, cognitoOptions?.friendlyDeviceName, onSuccess, onError) + } + + fun fetchMFAPreference( + onSuccess: Consumer, + onError: Consumer + ) { + authStateMachine.getCurrentState { authState -> + when (authState.authNState) { + is AuthenticationState.SignedIn -> { + GlobalScope.launch { + try { + val accessToken = getSession().userPoolTokensResult.value?.accessToken + accessToken?.let { token -> + authEnvironment.cognitoAuthService + .cognitoIdentityProviderClient?.getUser { + this.accessToken = token + }?.also { response -> + var enabledSet: MutableSet? = null + var preferred: MFAType? = null + if (!response.userMfaSettingList.isNullOrEmpty()) { + enabledSet = mutableSetOf() + response.userMfaSettingList?.forEach { mfaType -> + enabledSet.add(getMFAType(mfaType)) + } + } + response.preferredMfaSetting?.let { preferredMFA -> + preferred = getMFAType(preferredMFA) + } + onSuccess.accept(UserMFAPreference(enabledSet, preferred)) + } + } ?: onError.accept(SignedOutException()) + } catch (error: Exception) { + onError.accept( + CognitoAuthExceptionConverter.lookup( + error, + "Cannot update the MFA preferences" + ) + ) + } + } + } + + else -> onError.accept(InvalidStateException()) + } + } + } + + fun updateMFAPreference( + sms: MFAPreference?, + totp: MFAPreference?, + onSuccess: Action, + onError: Consumer + ) { + authStateMachine.getCurrentState { authState -> + when (authState.authNState) { + is AuthenticationState.SignedIn -> { + GlobalScope.launch { + try { + val accessToken = getSession().userPoolTokensResult.value?.accessToken + accessToken?.let { token -> + authEnvironment.cognitoAuthService.cognitoIdentityProviderClient?.setUserMfaPreference { + this.accessToken = token + this.smsMfaSettings = sms?.let { + SmsMfaSettingsType.invoke { + enabled = it.mfaEnabled + it.mfaPreferred ?.let { preferred -> preferredMfa = preferred } + } + } + this.softwareTokenMfaSettings = totp?.let { + SoftwareTokenMfaSettingsType.invoke { + enabled = it.mfaEnabled + it.mfaPreferred ?.let { preferred -> preferredMfa = preferred } + } + } + }?.also { + onSuccess.call() + } + } ?: onError.accept(SignedOutException()) + } catch (error: Exception) { + onError.accept( + CognitoAuthExceptionConverter.lookup( + error, + "Amazon Cognito cannot update the MFA preferences" + ) + ) + } + } + } + + else -> onError.accept(InvalidStateException()) + } + } + } + + private fun verifyTotp( + code: String, + friendlyDeviceName: String?, + onSuccess: Action, + onError: Consumer + ) { + authStateMachine.getCurrentState { authState -> + when (authState.authNState) { + is AuthenticationState.SignedIn -> { + GlobalScope.launch { + try { + val accessToken = getSession().userPoolTokensResult.value?.accessToken + accessToken?.let { token -> + authEnvironment.cognitoAuthService + .cognitoIdentityProviderClient?.verifySoftwareToken { + this.userCode = code + this.friendlyDeviceName = friendlyDeviceName + this.accessToken = token + }?.also { + when (it.status) { + is VerifySoftwareTokenResponseType.Success -> onSuccess.call() + else -> throw ServiceException( + message = "An unknown service error has occurred", + recoverySuggestion = AmplifyException.TODO_RECOVERY_SUGGESTION + ) + } + } + } ?: onError.accept(SignedOutException()) + } catch (error: Exception) { + onError.accept( + CognitoAuthExceptionConverter.lookup( + error, + "Amazon Cognito cannot find a multi-factor authentication (MFA) method." + ) + ) + } + } + } + + else -> onError.accept(InvalidStateException()) + } + } + } + private fun _clearFederationToIdentityPool(onSuccess: Action, onError: Consumer) { _signOut(sendHubEvent = false) { when (it) { diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/UserMFAPreference.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/UserMFAPreference.kt new file mode 100644 index 0000000000..8caac44a83 --- /dev/null +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/UserMFAPreference.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.auth.cognito + +import com.amplifyframework.auth.MFAType + +/** + * Output for fetching MFA preference. + * + * @param enabled MFA types + * @param preferred MFA type. null if not set + */ +data class UserMFAPreference( + val enabled: Set?, + val preferred: MFAType? +) + +/** + * Input for updating the MFA preference for a MFA Type + */ +enum class MFAPreference(internal val mfaEnabled: Boolean, internal val mfaPreferred: Boolean? = null) { + /** + * MFA not enabled + */ + DISABLED(false), + + /** + * MFA enabled + */ + ENABLED(true), + + /** + * MFA enabled and preferred + */ + PREFERRED(true, true), + + /** + * MFA enabled and not preferred + */ + NOT_PREFERRED(true, false) +} diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SRPCognitoActions.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SRPCognitoActions.kt index 4e6e211e57..ad756218a7 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SRPCognitoActions.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SRPCognitoActions.kt @@ -83,7 +83,9 @@ internal object SRPCognitoActions : SRPActions { SRPEvent( SRPEvent.EventType.RespondPasswordVerifier( - challengeParams, event.metadata, initiateAuthResponse.session + challengeParams, + event.metadata, + initiateAuthResponse.session ) ) } ?: throw Exception("Auth challenge parameters are empty.") @@ -142,7 +144,9 @@ internal object SRPCognitoActions : SRPActions { SRPEvent( SRPEvent.EventType.RespondPasswordVerifier( - challengeParams, event.metadata, initiateAuthResponse.session + challengeParams, + event.metadata, + initiateAuthResponse.session ) ) } ?: throw ServiceException( @@ -188,7 +192,7 @@ internal object SRPCognitoActions : SRPActions { KEY_USERNAME to username, KEY_PASSWORD_CLAIM_SECRET_BLOCK to secretBlock, KEY_PASSWORD_CLAIM_SIGNATURE to srpHelper.getSignature(salt, srpB, secretBlock), - KEY_TIMESTAMP to srpHelper.dateString, + KEY_TIMESTAMP to srpHelper.dateString ) secretHash?.let { challengeParams[KEY_SECRET_HASH] = it } challengeParams[KEY_DEVICE_KEY] = deviceKey diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SetupTOTPCognitoActions.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SetupTOTPCognitoActions.kt new file mode 100644 index 0000000000..7c00b0892d --- /dev/null +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SetupTOTPCognitoActions.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.auth.cognito.actions + +import aws.sdk.kotlin.services.cognitoidentityprovider.associateSoftwareToken +import aws.sdk.kotlin.services.cognitoidentityprovider.model.ChallengeNameType +import aws.sdk.kotlin.services.cognitoidentityprovider.model.VerifySoftwareTokenResponseType +import aws.sdk.kotlin.services.cognitoidentityprovider.respondToAuthChallenge +import aws.sdk.kotlin.services.cognitoidentityprovider.verifySoftwareToken +import com.amplifyframework.AmplifyException +import com.amplifyframework.auth.cognito.AuthEnvironment +import com.amplifyframework.auth.cognito.helpers.SignInChallengeHelper +import com.amplifyframework.auth.exceptions.ServiceException +import com.amplifyframework.statemachine.Action +import com.amplifyframework.statemachine.codegen.actions.SetupTOTPActions +import com.amplifyframework.statemachine.codegen.data.SignInTOTPSetupData +import com.amplifyframework.statemachine.codegen.events.SetupTOTPEvent + +internal object SetupTOTPCognitoActions : SetupTOTPActions { + private const val KEY_DEVICE_KEY = "DEVICE_KEY" + override fun initiateTOTPSetup(eventType: SetupTOTPEvent.EventType.SetupTOTP): Action = Action( + "InitiateTOTPSetup" + ) { id, dispatcher -> + logger.verbose("$id Starting execution") + val evt = try { + val response = cognitoAuthService.cognitoIdentityProviderClient?.associateSoftwareToken { + session = eventType.totpSetupDetails.session + } + response?.secretCode?.let { secret -> + SetupTOTPEvent( + SetupTOTPEvent.EventType.WaitForAnswer( + SignInTOTPSetupData(secret, response.session, eventType.totpSetupDetails.username) + ) + ) + } ?: SetupTOTPEvent( + SetupTOTPEvent.EventType.ThrowAuthError( + Exception("Software token setup failed"), + eventType.totpSetupDetails.username, + eventType.totpSetupDetails.session + ) + ) + } catch (e: Exception) { + SetupTOTPEvent( + SetupTOTPEvent.EventType.ThrowAuthError( + e, + eventType.totpSetupDetails.username, + eventType.totpSetupDetails.session + ) + ) + } + logger.verbose("$id Sending event ${evt.type}") + dispatcher.send(evt) + } + + override fun verifyChallengeAnswer( + eventType: SetupTOTPEvent.EventType.VerifyChallengeAnswer + ): Action = + Action("verifyChallengeAnswer") { id, dispatcher -> + logger.verbose("$id Starting execution") + val evt = try { + val response = cognitoAuthService.cognitoIdentityProviderClient?.verifySoftwareToken { + userCode = eventType.answer + this.session = eventType.session + this.friendlyDeviceName = eventType.friendlyDeviceName + } + + response?.let { + when (it.status) { + is VerifySoftwareTokenResponseType.Success -> { + SetupTOTPEvent( + SetupTOTPEvent.EventType.RespondToAuthChallenge( + eventType.username, + it.session + ) + ) + } + else -> { + SetupTOTPEvent( + SetupTOTPEvent.EventType.ThrowAuthError( + ServiceException( + message = "An unknown service error has occurred", + recoverySuggestion = AmplifyException.TODO_RECOVERY_SUGGESTION + ), + eventType.username, + eventType.session + ) + ) + } + } + } ?: SetupTOTPEvent( + SetupTOTPEvent.EventType.ThrowAuthError( + Exception("Software token verification failed"), + eventType.username, + eventType.session + ) + ) + } catch (exception: Exception) { + SetupTOTPEvent( + SetupTOTPEvent.EventType.ThrowAuthError( + exception, + eventType.username, + eventType.session + ) + ) + } + logger.verbose("$id Sending event ${evt.type}") + dispatcher.send(evt) + } + + override fun respondToAuthChallenge( + eventType: SetupTOTPEvent.EventType.RespondToAuthChallenge + ): Action = + Action("RespondToAuthChallenge") { id, dispatcher -> + logger.verbose("$id Starting execution") + val evt = try { + val challengeResponses = mutableMapOf() + challengeResponses["USERNAME"] = eventType.username + val deviceMetadata = getDeviceMetadata(eventType.username) + deviceMetadata?.deviceKey?.let { challengeResponses[KEY_DEVICE_KEY] = it } + val encodedContextData = getUserContextData(eventType.username) + val pinpointEndpointId = getPinpointEndpointId() + + val response = cognitoAuthService.cognitoIdentityProviderClient?.respondToAuthChallenge { + this.session = eventType.session + this.challengeResponses = challengeResponses + challengeName = ChallengeNameType.MfaSetup + clientId = configuration.userPool?.appClient + pinpointEndpointId?.let { analyticsMetadata { analyticsEndpointId = it } } + encodedContextData?.let { this.userContextData { encodedData = it } } + } + + response?.let { + SignInChallengeHelper.evaluateNextStep( + username = eventType.username, + challengeNameType = response.challengeName, + session = response.session, + challengeParameters = response.challengeParameters, + authenticationResult = response.authenticationResult + ) + } ?: SetupTOTPEvent( + SetupTOTPEvent.EventType.ThrowAuthError( + Exception("Software token verification failed"), + eventType.username, + eventType.session + ) + ) + } catch (exception: Exception) { + SetupTOTPEvent( + SetupTOTPEvent.EventType.ThrowAuthError(exception, eventType.username, eventType.session) + ) + } + dispatcher.send(evt) + } +} diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SignInChallengeCognitoActions.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SignInChallengeCognitoActions.kt index 71dc49d44f..55c92442a1 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SignInChallengeCognitoActions.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SignInChallengeCognitoActions.kt @@ -56,7 +56,6 @@ internal object SignInChallengeCognitoActions : SignInChallengeActions { val encodedContextData = username?.let { getUserContextData(it) } val pinpointEndpointId = getPinpointEndpointId() - val response = cognitoAuthService.cognitoIdentityProviderClient?.respondToAuthChallenge { clientId = configuration.userPool?.appClient challengeName = ChallengeNameType.fromValue(challenge.challengeName) @@ -90,7 +89,8 @@ internal object SignInChallengeCognitoActions : SignInChallengeActions { return when (ChallengeNameType.fromValue(challengeName)) { is ChallengeNameType.SmsMfa -> "SMS_MFA_CODE" is ChallengeNameType.NewPasswordRequired -> "NEW_PASSWORD" - is ChallengeNameType.CustomChallenge -> "ANSWER" + is ChallengeNameType.CustomChallenge, ChallengeNameType.SelectMfaType -> "ANSWER" + is ChallengeNameType.SoftwareTokenMfa -> "SOFTWARE_TOKEN_MFA_CODE" else -> null } } diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SignInCognitoActions.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SignInCognitoActions.kt index bb92d7db1e..2dfe25a605 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SignInCognitoActions.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/actions/SignInCognitoActions.kt @@ -32,6 +32,7 @@ import com.amplifyframework.statemachine.codegen.events.CustomSignInEvent import com.amplifyframework.statemachine.codegen.events.DeviceSRPSignInEvent import com.amplifyframework.statemachine.codegen.events.HostedUIEvent import com.amplifyframework.statemachine.codegen.events.SRPEvent +import com.amplifyframework.statemachine.codegen.events.SetupTOTPEvent import com.amplifyframework.statemachine.codegen.events.SignInChallengeEvent import com.amplifyframework.statemachine.codegen.events.SignInEvent @@ -137,4 +138,12 @@ internal object SignInCognitoActions : SignInActions { logger.verbose("$id Sending event ${evt.type}") dispatcher.send(evt) } + + override fun initiateTOTPSetupAction(event: SignInEvent.EventType.InitiateTOTPSetup) = + Action("initiateTOTPSetup") { id, dispatcher -> + logger.verbose("$id Starting execution") + val evt = SetupTOTPEvent(SetupTOTPEvent.EventType.SetupTOTP(event.signInTOTPSetupData)) + logger.verbose("$id Sending event ${evt.type}") + dispatcher.send(evt) + } } diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/exceptions/service/EnableSoftwareTokenMFAException.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/exceptions/service/EnableSoftwareTokenMFAException.kt new file mode 100644 index 0000000000..08f623c5d7 --- /dev/null +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/exceptions/service/EnableSoftwareTokenMFAException.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.auth.cognito.exceptions.service + +import com.amplifyframework.auth.AuthException + +/** + * Software Token MFA is not enabled for the user. + * @param cause The underlying cause of this exception + */ +open class EnableSoftwareTokenMFAException(cause: Throwable?) : + AuthException( + "Unable to enable software token MFA", + "Enable the software token MFA for the user.", + cause + ) diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/exceptions/service/SoftwareTokenMFANotFoundException.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/exceptions/service/SoftwareTokenMFANotFoundException.kt index 856d0b5152..25f00dab7e 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/exceptions/service/SoftwareTokenMFANotFoundException.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/exceptions/service/SoftwareTokenMFANotFoundException.kt @@ -21,4 +21,8 @@ import com.amplifyframework.auth.exceptions.ServiceException * @param cause The underlying cause of this exception */ open class SoftwareTokenMFANotFoundException(cause: Throwable?) : - ServiceException("Could not find software token MFA.", "Enable the software token MFA for the user.", cause) + ServiceException( + "Software token TOTP multi-factor authentication (MFA) is not enabled for the user pool.", + "Enable the software token MFA for the user.", + cause + ) diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/helpers/MFAHelper.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/helpers/MFAHelper.kt new file mode 100644 index 0000000000..2b6e29a56e --- /dev/null +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/helpers/MFAHelper.kt @@ -0,0 +1,19 @@ +package com.amplifyframework.auth.cognito.helpers + +import com.amplifyframework.auth.MFAType +import kotlin.jvm.Throws + +@Throws(IllegalArgumentException::class) +internal fun getMFAType(value: String): MFAType { + return when (value) { + "SMS_MFA" -> MFAType.SMS + "SOFTWARE_TOKEN_MFA" -> MFAType.TOTP + else -> throw IllegalArgumentException("Unsupported MFA type") + } +} + +internal val MFAType.value: String + get() = when (this) { + MFAType.SMS -> "SMS_MFA" + MFAType.TOTP -> "SOFTWARE_TOKEN_MFA" + } diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/helpers/SignInChallengeHelper.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/helpers/SignInChallengeHelper.kt index 42c1ac377f..61e3b47345 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/helpers/SignInChallengeHelper.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/helpers/SignInChallengeHelper.kt @@ -20,6 +20,8 @@ import aws.sdk.kotlin.services.cognitoidentityprovider.model.ChallengeNameType import aws.smithy.kotlin.runtime.time.Instant import com.amplifyframework.auth.AuthCodeDeliveryDetails import com.amplifyframework.auth.AuthException +import com.amplifyframework.auth.MFAType +import com.amplifyframework.auth.TOTPSetupDetails import com.amplifyframework.auth.exceptions.UnknownException import com.amplifyframework.auth.result.AuthSignInResult import com.amplifyframework.auth.result.step.AuthNextSignInStep @@ -30,6 +32,7 @@ import com.amplifyframework.statemachine.codegen.data.AuthChallenge import com.amplifyframework.statemachine.codegen.data.CognitoUserPoolTokens import com.amplifyframework.statemachine.codegen.data.DeviceMetadata import com.amplifyframework.statemachine.codegen.data.SignInMethod +import com.amplifyframework.statemachine.codegen.data.SignInTOTPSetupData import com.amplifyframework.statemachine.codegen.data.SignedInData import com.amplifyframework.statemachine.codegen.events.AuthenticationEvent import com.amplifyframework.statemachine.codegen.events.SignInEvent @@ -78,11 +81,27 @@ internal object SignInChallengeHelper { } challengeNameType is ChallengeNameType.SmsMfa || challengeNameType is ChallengeNameType.CustomChallenge || - challengeNameType is ChallengeNameType.NewPasswordRequired -> { + challengeNameType is ChallengeNameType.NewPasswordRequired || + challengeNameType is ChallengeNameType.SoftwareTokenMfa || + challengeNameType is ChallengeNameType.SelectMfaType -> { val challenge = AuthChallenge(challengeNameType.value, username, session, challengeParameters) SignInEvent(SignInEvent.EventType.ReceivedChallenge(challenge)) } + challengeNameType is ChallengeNameType.MfaSetup -> { + val allowedMFASetupTypes = challengeParameters?.get("MFAS_CAN_SETUP") + ?.let { getAllowedMFATypes(it) } ?: emptySet() + if (allowedMFASetupTypes.contains(MFAType.TOTP)) { + val setupTOTPData = SignInTOTPSetupData("", session, username) + SignInEvent(SignInEvent.EventType.InitiateTOTPSetup(setupTOTPData)) + } else { + SignInEvent( + SignInEvent.EventType.ThrowError( + Exception("Cannot initiate MFA setup from available Types: $allowedMFASetupTypes") + ) + ) + } + } challengeNameType is ChallengeNameType.DeviceSrpAuth -> { SignInEvent(SignInEvent.EventType.InitiateSignInWithDeviceSRP(username, mapOf())) } @@ -93,7 +112,9 @@ internal object SignInChallengeHelper { fun getNextStep( challenge: AuthChallenge, onSuccess: Consumer, - onError: Consumer + onError: Consumer, + signInTOTPSetupData: SignInTOTPSetupData? = null, + allowedMFAType: Set? = null ) { val challengeParams = challenge.parameters?.toMutableMap() ?: mapOf() @@ -107,25 +128,96 @@ internal object SignInChallengeHelper { ) val authSignInResult = AuthSignInResult( false, - AuthNextSignInStep(AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE, mapOf(), deliveryDetails) + AuthNextSignInStep( + AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE, + mapOf(), + deliveryDetails, + null, + null + ) ) onSuccess.accept(authSignInResult) } is ChallengeNameType.NewPasswordRequired -> { val authSignInResult = AuthSignInResult( false, - AuthNextSignInStep(AuthSignInStep.CONFIRM_SIGN_IN_WITH_NEW_PASSWORD, challengeParams, null) + AuthNextSignInStep( + AuthSignInStep.CONFIRM_SIGN_IN_WITH_NEW_PASSWORD, + challengeParams, + null, + null, + null + ) ) onSuccess.accept(authSignInResult) } is ChallengeNameType.CustomChallenge -> { val authSignInResult = AuthSignInResult( false, - AuthNextSignInStep(AuthSignInStep.CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE, challengeParams, null) + AuthNextSignInStep( + AuthSignInStep.CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE, + challengeParams, + null, + null, + null + ) + ) + onSuccess.accept(authSignInResult) + } + is ChallengeNameType.SoftwareTokenMfa -> { + val authSignInResult = AuthSignInResult( + false, + AuthNextSignInStep( + AuthSignInStep.CONFIRM_SIGN_IN_WITH_TOTP_CODE, + mapOf(), + null, + null, + null + ) + ) + onSuccess.accept(authSignInResult) + } + is ChallengeNameType.MfaSetup -> { + signInTOTPSetupData?.let { + val authSignInResult = AuthSignInResult( + false, + AuthNextSignInStep( + AuthSignInStep.CONTINUE_SIGN_IN_WITH_TOTP_SETUP, + challengeParams, + null, + TOTPSetupDetails(it.secretCode, it.username), + allowedMFAType + ) + ) + onSuccess.accept(authSignInResult) + } ?: onError.accept(UnknownException(cause = Exception("Challenge type not supported."))) + } + is ChallengeNameType.SelectMfaType -> { + val authSignInResult = AuthSignInResult( + false, + AuthNextSignInStep( + AuthSignInStep.CONTINUE_SIGN_IN_WITH_MFA_SELECTION, + mapOf(), + null, + null, + challengeParams["MFAS_CAN_CHOOSE"]?.let { getAllowedMFATypes(it) } + ) ) onSuccess.accept(authSignInResult) } else -> onError.accept(UnknownException(cause = Exception("Challenge type not supported."))) } } + + fun getAllowedMFATypes(allowedMFAType: String): Set { + val result = mutableSetOf() + allowedMFAType.replace(Regex("\\[|\\]|\""), "").split(",").forEach { + when (it) { + "SMS_MFA" -> result.add(MFAType.SMS) + "SOFTWARE_TOKEN_MFA" -> result.add(MFAType.TOTP) + else -> throw UnknownException(cause = Exception("MFA type not supported.")) + } + } + return result + } } diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/options/AWSCognitoAuthConfirmSignInOptions.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/options/AWSCognitoAuthConfirmSignInOptions.kt index 88bcc66fc1..08cf7fc78d 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/options/AWSCognitoAuthConfirmSignInOptions.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/options/AWSCognitoAuthConfirmSignInOptions.kt @@ -31,7 +31,12 @@ data class AWSCognitoAuthConfirmSignInOptions internal constructor( * Get additional user attributes which should be associated with this user on confirmSignIn. * @return additional user attributes which should be associated with this user on confirmSignIn */ - val userAttributes: List + val userAttributes: List, + /** + * Get the friendly device name used to setup TOTP. + * @return friendly device name + */ + val friendlyDeviceName: String? ) : AuthConfirmSignInOptions() { companion object { @@ -53,6 +58,7 @@ data class AWSCognitoAuthConfirmSignInOptions internal constructor( class CognitoBuilder : Builder() { private var metadata: Map = mapOf() private var userAttributes: List = listOf() + private var friendlyDeviceName: String? = null /** * Returns the type of builder this is to support proper flow with it being an extended class. @@ -77,10 +83,17 @@ data class AWSCognitoAuthConfirmSignInOptions internal constructor( */ fun userAttributes(userAttributes: List) = apply { this.userAttributes = userAttributes } + /** + * Set the friendlyDeviceName field for the object being built. + * @param friendlyDeviceName friendly name of the device used to setup totp. + * @return the instance of the builder. + */ + fun friendlyDeviceName(friendlyDeviceName: String) = apply { this.friendlyDeviceName = friendlyDeviceName } + /** * Construct and return the object with the values set in the builder. * @return a new instance of AWSCognitoAuthConfirmSignInOptions with the values specified in the builder. */ - override fun build() = AWSCognitoAuthConfirmSignInOptions(metadata, userAttributes) + override fun build() = AWSCognitoAuthConfirmSignInOptions(metadata, userAttributes, friendlyDeviceName) } } diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/options/AWSCognitoAuthVerifyTOTPSetupOptions.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/options/AWSCognitoAuthVerifyTOTPSetupOptions.kt new file mode 100644 index 0000000000..b6ece0404c --- /dev/null +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/options/AWSCognitoAuthVerifyTOTPSetupOptions.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.auth.cognito.options + +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions + +/** + * Cognito extension of update verify totp setup options to add the platform specific fields. + */ +class AWSCognitoAuthVerifyTOTPSetupOptions private constructor( + /** + * Return the friendlyDeviceName to set during cognito TOTP setup. + * @return friendlyDeviceName string + */ + val friendlyDeviceName: String? +) : AuthVerifyTOTPSetupOptions() { + + companion object { + /** + * Get a builder object. + * @return a builder object. + */ + @JvmStatic + fun builder(): CognitoBuilder { + return CognitoBuilder() + } + + inline operator fun invoke(block: CognitoBuilder.() -> Unit) = CognitoBuilder() + .apply(block).build() + } + + /** + * The builder for this class. + */ + class CognitoBuilder : Builder() { + private var friendlyDeviceName: String? = null + + /** + * Returns the type of builder this is to support proper flow with it being an extended class. + * @return the type of builder this is to support proper flow with it being an extended class. + */ + override fun getThis(): CognitoBuilder { + return this + } + + /** + * Friendly device name to be set in Cognito. + * @param friendlyDeviceName String input for friendlyDeviceName + * @return current CognitoBuilder instance + */ + fun friendlyDeviceName(friendlyDeviceName: String): CognitoBuilder { + this.friendlyDeviceName = friendlyDeviceName + return this + } + + /** + * Construct and return the object with the values set in the builder. + * @return a new instance of AWSCognitoAuthVerifyTOTPSetupOptions with the values specified in the builder. + */ + override fun build(): AWSCognitoAuthVerifyTOTPSetupOptions { + return AWSCognitoAuthVerifyTOTPSetupOptions(friendlyDeviceName) + } + } +} diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/actions/SetupTOTPActions.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/actions/SetupTOTPActions.kt new file mode 100644 index 0000000000..5d47ddc599 --- /dev/null +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/actions/SetupTOTPActions.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.statemachine.codegen.actions + +import com.amplifyframework.statemachine.Action +import com.amplifyframework.statemachine.codegen.events.SetupTOTPEvent + +internal interface SetupTOTPActions { + fun initiateTOTPSetup(eventType: SetupTOTPEvent.EventType.SetupTOTP): Action + fun verifyChallengeAnswer( + eventType: SetupTOTPEvent.EventType.VerifyChallengeAnswer + ): Action + + fun respondToAuthChallenge( + eventType: SetupTOTPEvent.EventType.RespondToAuthChallenge + ): Action +} diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/actions/SignInActions.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/actions/SignInActions.kt index bdb56e9184..ba8f868f3f 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/actions/SignInActions.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/actions/SignInActions.kt @@ -27,4 +27,5 @@ internal interface SignInActions { fun initResolveChallenge(event: SignInEvent.EventType.ReceivedChallenge): Action fun confirmDevice(event: SignInEvent.EventType.ConfirmDevice): Action fun startHostedUIAuthAction(event: SignInEvent.EventType.InitiateHostedUISignIn): Action + fun initiateTOTPSetupAction(event: SignInEvent.EventType.InitiateTOTPSetup): Action } diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/data/SignInTOTPSetupData.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/data/SignInTOTPSetupData.kt new file mode 100644 index 0000000000..284cd6ce80 --- /dev/null +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/data/SignInTOTPSetupData.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.statemachine.codegen.data + +internal data class SignInTOTPSetupData( + val secretCode: String, + val session: String?, + val username: String +) { + override fun toString(): String { + return "SignInTOTPSetupData(" + + "secretCode = ${mask(secretCode)}, " + + "session = ${mask(session)}, " + + "username = $username}" + + ")" + } + + private fun mask(value: String?): String { + return if (value == null || value.length <= 4) { + "***" + } else { + "${value.substring(0 until 4)}***" + } + } +} diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/events/SetupTOTPEvent.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/events/SetupTOTPEvent.kt new file mode 100644 index 0000000000..9bf7adefae --- /dev/null +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/events/SetupTOTPEvent.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.statemachine.codegen.events + +import com.amplifyframework.statemachine.StateMachineEvent +import com.amplifyframework.statemachine.codegen.data.SignInTOTPSetupData +import java.util.Date + +internal class SetupTOTPEvent(val eventType: EventType, override val time: Date? = null) : + StateMachineEvent { + + sealed class EventType { + data class SetupTOTP(val totpSetupDetails: SignInTOTPSetupData) : EventType() + data class WaitForAnswer(val totpSetupDetails: SignInTOTPSetupData) : EventType() + data class ThrowAuthError(val exception: Exception, val username: String, val session: String?) : EventType() + data class VerifyChallengeAnswer( + val answer: String, + val username: String, + val session: String?, + val friendlyDeviceName: String? + ) : + EventType() + + data class RespondToAuthChallenge(val username: String, val session: String?) : EventType() + data class Verified(val id: String = "") : EventType() + } + + override val type: String = eventType.javaClass.simpleName +} diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/events/SignInEvent.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/events/SignInEvent.kt index 9a2b2d9cdc..7f0c367af3 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/events/SignInEvent.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/events/SignInEvent.kt @@ -19,6 +19,7 @@ import com.amplifyframework.statemachine.StateMachineEvent import com.amplifyframework.statemachine.codegen.data.AuthChallenge import com.amplifyframework.statemachine.codegen.data.DeviceMetadata import com.amplifyframework.statemachine.codegen.data.SignInData +import com.amplifyframework.statemachine.codegen.data.SignInTOTPSetupData import com.amplifyframework.statemachine.codegen.data.SignedInData import java.util.Date @@ -61,6 +62,7 @@ internal class SignInEvent(val eventType: EventType, override val time: Date? = data class FinalizeSignIn(val id: String = "") : EventType() data class ReceivedChallenge(val challenge: AuthChallenge) : EventType() data class ThrowError(val exception: Exception) : EventType() + data class InitiateTOTPSetup(val signInTOTPSetupData: SignInTOTPSetupData) : EventType() } override val type: String = eventType.javaClass.simpleName diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/states/SetupTOTPState.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/states/SetupTOTPState.kt new file mode 100644 index 0000000000..2d9b03bfef --- /dev/null +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/states/SetupTOTPState.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.statemachine.codegen.states + +import com.amplifyframework.statemachine.State +import com.amplifyframework.statemachine.StateMachineEvent +import com.amplifyframework.statemachine.StateMachineResolver +import com.amplifyframework.statemachine.StateResolution +import com.amplifyframework.statemachine.codegen.actions.SetupTOTPActions +import com.amplifyframework.statemachine.codegen.data.SignInTOTPSetupData +import com.amplifyframework.statemachine.codegen.events.SetupTOTPEvent + +internal sealed class SetupTOTPState : State { + data class NotStarted(val id: String = "") : SetupTOTPState() + data class SetupTOTP(val id: String = "") : SetupTOTPState() + data class WaitingForAnswer( + val signInTOTPSetupData: SignInTOTPSetupData, + var hasNewResponse: Boolean = false + ) : SetupTOTPState() + data class Verifying(val id: String = "") : SetupTOTPState() + data class RespondingToAuthChallenge(val id: String = "") : SetupTOTPState() + data class Success(val id: String = "") : SetupTOTPState() + data class Error( + val exception: Exception, + val username: String, + val session: String?, + var hasNewResponse: Boolean = false + ) : SetupTOTPState() + + class Resolver(private val setupTOTPActions: SetupTOTPActions) : StateMachineResolver { + override val defaultState = NotStarted("default") + + override fun resolve(oldState: SetupTOTPState, event: StateMachineEvent): StateResolution { + val defaultResolution = StateResolution(oldState) + val challengeEvent = (event as? SetupTOTPEvent)?.eventType + return when (oldState) { + is NotStarted -> when (challengeEvent) { + is SetupTOTPEvent.EventType.SetupTOTP -> { + StateResolution( + SetupTOTP(), + listOf(setupTOTPActions.initiateTOTPSetup(challengeEvent)) + ) + } + + is SetupTOTPEvent.EventType.ThrowAuthError -> StateResolution( + Error(challengeEvent.exception, challengeEvent.username, challengeEvent.session) + ) + + else -> defaultResolution + } + + is SetupTOTP -> when (challengeEvent) { + is SetupTOTPEvent.EventType.WaitForAnswer -> { + StateResolution(WaitingForAnswer(challengeEvent.totpSetupDetails, true)) + } + + is SetupTOTPEvent.EventType.ThrowAuthError -> StateResolution( + Error(challengeEvent.exception, challengeEvent.username, challengeEvent.session) + ) + + else -> defaultResolution + } + + is WaitingForAnswer -> when (challengeEvent) { + is SetupTOTPEvent.EventType.VerifyChallengeAnswer -> { + StateResolution( + Verifying(), + listOf(setupTOTPActions.verifyChallengeAnswer(challengeEvent)) + ) + } + + is SetupTOTPEvent.EventType.ThrowAuthError -> StateResolution( + Error(challengeEvent.exception, challengeEvent.username, challengeEvent.session) + ) + + else -> defaultResolution + } + + is Verifying -> when (challengeEvent) { + is SetupTOTPEvent.EventType.RespondToAuthChallenge -> { + StateResolution( + RespondingToAuthChallenge(), + listOf( + setupTOTPActions.respondToAuthChallenge( + challengeEvent + ) + ) + ) + } + + is SetupTOTPEvent.EventType.ThrowAuthError -> StateResolution( + Error(challengeEvent.exception, challengeEvent.username, challengeEvent.session, true) + ) + + else -> defaultResolution + } + + is RespondingToAuthChallenge -> when (challengeEvent) { + is SetupTOTPEvent.EventType.Verified -> { + StateResolution( + Success() + ) + } + + is SetupTOTPEvent.EventType.ThrowAuthError -> StateResolution( + Error(challengeEvent.exception, challengeEvent.username, challengeEvent.session) + ) + + else -> defaultResolution + } + + is Error -> when (challengeEvent) { + is SetupTOTPEvent.EventType.VerifyChallengeAnswer -> { + StateResolution( + Verifying(), + listOf(setupTOTPActions.verifyChallengeAnswer(challengeEvent)) + ) + } + + is SetupTOTPEvent.EventType.WaitForAnswer -> { + StateResolution(WaitingForAnswer(challengeEvent.totpSetupDetails, true)) + } + + else -> defaultResolution + } + + else -> defaultResolution + } + } + } +} diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/states/SignInState.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/states/SignInState.kt index 5fecde353d..b5317633b1 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/states/SignInState.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/states/SignInState.kt @@ -31,6 +31,7 @@ internal sealed class SignInState : State { data class SigningInViaMigrateAuth(override var migrateSignInState: MigrateSignInState?) : SignInState() data class ResolvingDeviceSRP(override var deviceSRPSignInState: DeviceSRPSignInState?) : SignInState() data class ResolvingChallenge(override var challengeState: SignInChallengeState?) : SignInState() + data class ResolvingTOTPSetup(override var setupTOTPState: SetupTOTPState?) : SignInState() data class ConfirmingDevice(val id: String = "") : SignInState() data class Done(val id: String = "") : SignInState() data class Error(val exception: Exception) : SignInState() @@ -42,6 +43,7 @@ internal sealed class SignInState : State { open var migrateSignInState: MigrateSignInState? = MigrateSignInState.NotStarted() open var hostedUISignInState: HostedUISignInState? = HostedUISignInState.NotStarted() open var deviceSRPSignInState: DeviceSRPSignInState? = DeviceSRPSignInState.NotStarted() + open var setupTOTPState: SetupTOTPState? = SetupTOTPState.NotStarted() class Resolver( private val srpSignInResolver: StateMachineResolver, @@ -50,7 +52,8 @@ internal sealed class SignInState : State { private val challengeResolver: StateMachineResolver, private val hostedUISignInResolver: StateMachineResolver, private val deviceSRPSignInResolver: StateMachineResolver, - private val signInActions: SignInActions, + private val setupTOTPResolver: StateMachineResolver, + private val signInActions: SignInActions ) : StateMachineResolver { override val defaultState = NotStarted() @@ -94,6 +97,10 @@ internal sealed class SignInState : State { actions += it.actions } + oldState.setupTOTPState?.let { setupTOTPResolver.resolve(it, event) }?.let { + builder.setupTOTPState = it.newState + actions += it.actions + } return StateResolution(builder.build(), actions) } @@ -109,73 +116,131 @@ internal sealed class SignInState : State { SigningInWithSRP(oldState.srpSignInState), listOf(signInActions.startSRPAuthAction(signInEvent)) ) + is SignInEvent.EventType.InitiateSignInWithCustom -> StateResolution( SigningInWithCustom(oldState.customSignInState), listOf(signInActions.startCustomAuthAction(signInEvent)) ) + is SignInEvent.EventType.InitiateHostedUISignIn -> StateResolution( SigningInWithHostedUI(HostedUISignInState.NotStarted()), listOf(signInActions.startHostedUIAuthAction(signInEvent)) ) + is SignInEvent.EventType.InitiateMigrateAuth -> StateResolution( SigningInViaMigrateAuth(MigrateSignInState.NotStarted()), listOf(signInActions.startMigrationAuthAction(signInEvent)) ) + is SignInEvent.EventType.InitiateCustomSignInWithSRP -> StateResolution( SigningInWithSRPCustom(oldState.srpSignInState), listOf(signInActions.startCustomAuthWithSRPAction(signInEvent)) ) + else -> defaultResolution } + is SigningInWithSRP, is SigningInWithCustom, is SigningInViaMigrateAuth, - is SigningInWithSRPCustom -> when (signInEvent) { + is SigningInWithSRPCustom + -> when (signInEvent) { is SignInEvent.EventType.ReceivedChallenge -> { val action = signInActions.initResolveChallenge(signInEvent) StateResolution(ResolvingChallenge(oldState.challengeState), listOf(action)) } + is SignInEvent.EventType.InitiateSignInWithDeviceSRP -> StateResolution( ResolvingDeviceSRP(DeviceSRPSignInState.NotStarted()), listOf(signInActions.startDeviceSRPAuthAction(signInEvent)) ) + is SignInEvent.EventType.ConfirmDevice -> { val action = signInActions.confirmDevice(signInEvent) StateResolution(ConfirmingDevice(), listOf(action)) } + + is SignInEvent.EventType.InitiateTOTPSetup -> StateResolution( + ResolvingTOTPSetup(oldState.setupTOTPState), + listOf(signInActions.initiateTOTPSetupAction(signInEvent)) + ) + is SignInEvent.EventType.ThrowError -> StateResolution(Error(signInEvent.exception)) else -> defaultResolution } + is ResolvingChallenge -> when (signInEvent) { is SignInEvent.EventType.ConfirmDevice -> { val action = signInActions.confirmDevice(signInEvent) StateResolution(ConfirmingDevice(), listOf(action)) } + is SignInEvent.EventType.ReceivedChallenge -> { val action = signInActions.initResolveChallenge(signInEvent) StateResolution(ResolvingChallenge(oldState.challengeState), listOf(action)) } + + is SignInEvent.EventType.InitiateTOTPSetup -> StateResolution( + ResolvingTOTPSetup(oldState.setupTOTPState), + listOf(signInActions.initiateTOTPSetupAction(signInEvent)) + ) + is SignInEvent.EventType.ThrowError -> StateResolution(Error(signInEvent.exception)) else -> defaultResolution } + + is ResolvingTOTPSetup -> when (signInEvent) { + is SignInEvent.EventType.ReceivedChallenge -> { + val action = signInActions.initResolveChallenge(signInEvent) + StateResolution(ResolvingChallenge(oldState.challengeState), listOf(action)) + } + + is SignInEvent.EventType.ConfirmDevice -> { + val action = signInActions.confirmDevice(signInEvent) + StateResolution(ConfirmingDevice(), listOf(action)) + } + + is SignInEvent.EventType.InitiateSignInWithDeviceSRP -> StateResolution( + ResolvingDeviceSRP(DeviceSRPSignInState.NotStarted()), + listOf(signInActions.startDeviceSRPAuthAction(signInEvent)) + ) + + is SignInEvent.EventType.FinalizeSignIn -> { + StateResolution(SignedIn()) + } + + else -> defaultResolution + } + is ResolvingDeviceSRP -> when (signInEvent) { is SignInEvent.EventType.ReceivedChallenge -> { val action = signInActions.initResolveChallenge(signInEvent) StateResolution(ResolvingChallenge(oldState.challengeState), listOf(action)) } + + is SignInEvent.EventType.InitiateTOTPSetup -> StateResolution( + ResolvingTOTPSetup(SetupTOTPState.NotStarted()), + listOf(signInActions.initiateTOTPSetupAction(signInEvent)) + ) + is SignInEvent.EventType.ThrowError -> StateResolution(Error(signInEvent.exception)) + else -> defaultResolution } + is ConfirmingDevice -> when (signInEvent) { is SignInEvent.EventType.FinalizeSignIn -> { StateResolution(SignedIn()) } + is SignInEvent.EventType.ThrowError -> StateResolution(Error(signInEvent.exception)) else -> defaultResolution } + is SigningInWithHostedUI -> when (signInEvent) { is SignInEvent.EventType.SignedIn -> StateResolution(Done()) is SignInEvent.EventType.ThrowError -> StateResolution(Error(signInEvent.exception)) else -> defaultResolution } + else -> defaultResolution } } @@ -189,6 +254,7 @@ internal sealed class SignInState : State { var migrateSignInState: MigrateSignInState? = null var hostedUISignInState: HostedUISignInState? = null var deviceSRPSignInState: DeviceSRPSignInState? = null + var setupTOTPState: SetupTOTPState? = null override fun build(): SignInState = when (signInState) { is SigningInWithSRP -> SigningInWithSRP(srpSignInState) @@ -198,6 +264,7 @@ internal sealed class SignInState : State { is SigningInWithHostedUI -> SigningInWithHostedUI(hostedUISignInState) is SigningInWithSRPCustom -> SigningInWithSRPCustom(srpSignInState) is ResolvingDeviceSRP -> ResolvingDeviceSRP(deviceSRPSignInState) + is ResolvingTOTPSetup -> ResolvingTOTPSetup(setupTOTPState) else -> signInState } } diff --git a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/TOTPSetupDetailsTest.kt b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/TOTPSetupDetailsTest.kt new file mode 100644 index 0000000000..11b2197955 --- /dev/null +++ b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/TOTPSetupDetailsTest.kt @@ -0,0 +1,35 @@ +package com.amplifyframework.auth + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TOTPSetupDetailsTest { + + @Test + fun getSetupURI() { + val ss = "SS123" + val username = "User123" + val appName = "MyApp" + val expectedSetupURI = "otpauth://totp/MyApp:User123?secret=SS123&issuer=MyApp" + + val actual = TOTPSetupDetails(ss, username).getSetupURI(appName) + + assertEquals(expectedSetupURI, actual.toString()) + } + + @Test + fun getSetupURIWithAccountNameOverride() { + val ss = "SS123" + val username = "User123" + val accountNameOverride = "AccountOverride" + val appName = "MyApp" + val expectedSetupURI = "otpauth://totp/MyApp:AccountOverride?secret=SS123&issuer=MyApp" + + val actual = TOTPSetupDetails(ss, username).getSetupURI(appName, accountNameOverride) + + assertEquals(expectedSetupURI, actual.toString()) + } +} diff --git a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPluginTest.kt b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPluginTest.kt index 4a7738e2be..8afb3f53c5 100644 --- a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPluginTest.kt +++ b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPluginTest.kt @@ -25,6 +25,8 @@ import com.amplifyframework.auth.AuthSession import com.amplifyframework.auth.AuthUser import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.AuthUserAttributeKey +import com.amplifyframework.auth.TOTPSetupDetails +import com.amplifyframework.auth.cognito.options.AWSCognitoAuthVerifyTOTPSetupOptions import com.amplifyframework.auth.cognito.options.FederateToIdentityPoolOptions import com.amplifyframework.auth.cognito.result.FederateToIdentityPoolResult import com.amplifyframework.auth.options.AuthConfirmResetPasswordOptions @@ -681,6 +683,53 @@ class AWSCognitoAuthPluginTest { verify(timeout = CHANNEL_TIMEOUT) { realPlugin.clearFederationToIdentityPool(any(), any()) } } + @Test + fun setUpTOTP() { + val expectedOnSuccess = Consumer { } + val expectedOnError = Consumer { } + authPlugin.setUpTOTP(expectedOnSuccess, expectedOnError) + verify(timeout = CHANNEL_TIMEOUT) { realPlugin.setUpTOTP(any(), any()) } + } + + @Test + fun verifyTOTPSetup() { + val code = "123456" + val expectedOnSuccess = Action { } + val expectedOnError = Consumer { } + authPlugin.verifyTOTPSetup(code, expectedOnSuccess, expectedOnError) + verify(timeout = CHANNEL_TIMEOUT) { realPlugin.verifyTOTPSetup(code, any(), any(), any()) } + } + + @Test + fun verifyTOTPSetupWithOptions() { + val code = "123456" + val options = AWSCognitoAuthVerifyTOTPSetupOptions.CognitoBuilder().friendlyDeviceName("DEVICE_NAME").build() + val expectedOnSuccess = Action { } + val expectedOnError = Consumer { } + authPlugin.verifyTOTPSetup(code, options, expectedOnSuccess, expectedOnError) + verify(timeout = CHANNEL_TIMEOUT) { realPlugin.verifyTOTPSetup(code, options, any(), any()) } + } + + @Test + fun fetchMFAPreferences() { + val expectedOnSuccess = Consumer { } + val expectedOnError = Consumer { } + authPlugin.fetchMFAPreference(expectedOnSuccess, expectedOnError) + verify(timeout = CHANNEL_TIMEOUT) { realPlugin.fetchMFAPreference(any(), any()) } + } + + @Test + fun updateMFAPreferences() { + val smsPreference = MFAPreference.ENABLED + val totpPreference = MFAPreference.PREFERRED + val onSuccess = Action { } + val onError = Consumer { } + authPlugin.updateMFAPreference(smsPreference, totpPreference, onSuccess, onError) + verify(timeout = CHANNEL_TIMEOUT) { + realPlugin.updateMFAPreference(smsPreference, totpPreference, any(), any()) + } + } + @Test fun verifyPluginKey() { assertEquals("awsCognitoAuthPlugin", authPlugin.pluginKey) diff --git a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/RealAWSCognitoAuthPluginTest.kt b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/RealAWSCognitoAuthPluginTest.kt index d1053e6c03..1f73082a4f 100644 --- a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/RealAWSCognitoAuthPluginTest.kt +++ b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/RealAWSCognitoAuthPluginTest.kt @@ -16,10 +16,13 @@ package com.amplifyframework.auth.cognito import aws.sdk.kotlin.services.cognitoidentityprovider.CognitoIdentityProviderClient +import aws.sdk.kotlin.services.cognitoidentityprovider.getUser import aws.sdk.kotlin.services.cognitoidentityprovider.model.AnalyticsMetadataType +import aws.sdk.kotlin.services.cognitoidentityprovider.model.AssociateSoftwareTokenResponse import aws.sdk.kotlin.services.cognitoidentityprovider.model.AttributeType import aws.sdk.kotlin.services.cognitoidentityprovider.model.ChangePasswordResponse import aws.sdk.kotlin.services.cognitoidentityprovider.model.CodeDeliveryDetailsType +import aws.sdk.kotlin.services.cognitoidentityprovider.model.CodeMismatchException import aws.sdk.kotlin.services.cognitoidentityprovider.model.CognitoIdentityProviderException import aws.sdk.kotlin.services.cognitoidentityprovider.model.ConfirmForgotPasswordRequest import aws.sdk.kotlin.services.cognitoidentityprovider.model.ConfirmForgotPasswordResponse @@ -32,10 +35,18 @@ import aws.sdk.kotlin.services.cognitoidentityprovider.model.GetUserRequest import aws.sdk.kotlin.services.cognitoidentityprovider.model.GetUserResponse import aws.sdk.kotlin.services.cognitoidentityprovider.model.ResendConfirmationCodeRequest import aws.sdk.kotlin.services.cognitoidentityprovider.model.ResendConfirmationCodeResponse +import aws.sdk.kotlin.services.cognitoidentityprovider.model.SetUserMfaPreferenceRequest +import aws.sdk.kotlin.services.cognitoidentityprovider.model.SetUserMfaPreferenceResponse import aws.sdk.kotlin.services.cognitoidentityprovider.model.SignUpRequest import aws.sdk.kotlin.services.cognitoidentityprovider.model.SignUpResponse +import aws.sdk.kotlin.services.cognitoidentityprovider.model.SmsMfaSettingsType +import aws.sdk.kotlin.services.cognitoidentityprovider.model.SoftwareTokenMfaNotFoundException +import aws.sdk.kotlin.services.cognitoidentityprovider.model.SoftwareTokenMfaSettingsType import aws.sdk.kotlin.services.cognitoidentityprovider.model.UpdateUserAttributesRequest import aws.sdk.kotlin.services.cognitoidentityprovider.model.UpdateUserAttributesResponse +import aws.sdk.kotlin.services.cognitoidentityprovider.model.VerifySoftwareTokenRequest +import aws.sdk.kotlin.services.cognitoidentityprovider.model.VerifySoftwareTokenResponse +import aws.sdk.kotlin.services.cognitoidentityprovider.model.VerifySoftwareTokenResponseType import aws.sdk.kotlin.services.cognitoidentityprovider.model.VerifyUserAttributeRequest import aws.sdk.kotlin.services.cognitoidentityprovider.model.VerifyUserAttributeResponse import com.amplifyframework.auth.AuthCodeDeliveryDetails @@ -43,6 +54,8 @@ import com.amplifyframework.auth.AuthException import com.amplifyframework.auth.AuthSession import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.AuthUserAttributeKey +import com.amplifyframework.auth.MFAType +import com.amplifyframework.auth.TOTPSetupDetails import com.amplifyframework.auth.cognito.exceptions.configuration.InvalidUserPoolConfigurationException import com.amplifyframework.auth.cognito.helpers.AuthHelper import com.amplifyframework.auth.cognito.helpers.SRPHelper @@ -50,6 +63,7 @@ import com.amplifyframework.auth.cognito.options.AWSCognitoAuthResendUserAttribu import com.amplifyframework.auth.cognito.options.AWSCognitoAuthSignInOptions import com.amplifyframework.auth.cognito.options.AWSCognitoAuthUpdateUserAttributeOptions import com.amplifyframework.auth.cognito.options.AWSCognitoAuthUpdateUserAttributesOptions +import com.amplifyframework.auth.cognito.options.AWSCognitoAuthVerifyTOTPSetupOptions import com.amplifyframework.auth.cognito.options.AuthFlowType import com.amplifyframework.auth.cognito.usecases.ResetPasswordUseCase import com.amplifyframework.auth.exceptions.InvalidStateException @@ -1625,4 +1639,227 @@ class RealAWSCognitoAuthPluginTest { "Auth flow types do not match expected" ) } + + @Test + fun `setupTOTP on success`() { + val currentAuthState = mockk { + every { authNState } returns AuthenticationState.SignedIn(mockk(), mockk()) + every { authZState } returns AuthorizationState.SessionEstablished(credentials) + } + every { authStateMachine.getCurrentState(captureLambda()) } answers { + lambda<(AuthState) -> Unit>().invoke(currentAuthState) + } + val listenLatch = CountDownLatch(1) + val onSuccess = mockk>() + val totpSetupDetails = slot() + every { onSuccess.accept(capture(totpSetupDetails)) }.answers { listenLatch.countDown() } + val onError = mockk>() + + val session = "SESSION" + val secretCode = "SECRET_CODE" + coEvery { mockCognitoIPClient.associateSoftwareToken(any()) }.answers { + AssociateSoftwareTokenResponse.invoke { + this.session = session + this.secretCode = secretCode + } + } + + plugin.setUpTOTP(onSuccess, onError) + + assertTrue { listenLatch.await(5, TimeUnit.SECONDS) } + assertTrue(totpSetupDetails.isCaptured) + verify(exactly = 1) { onSuccess.accept(any()) } + assertEquals(secretCode, totpSetupDetails.captured.sharedSecret) + } + + @Test + fun `setupTOTP on error`() { + val currentAuthState = mockk { + every { authNState } returns AuthenticationState.SignedIn(mockk(), mockk()) + every { authZState } returns AuthorizationState.SessionEstablished(credentials) + } + every { authStateMachine.getCurrentState(captureLambda()) } answers { + lambda<(AuthState) -> Unit>().invoke(currentAuthState) + } + + val listenLatch = CountDownLatch(1) + val onSuccess = mockk>() + val onError = mockk>() + val authException = slot() + every { onError.accept(capture(authException)) }.answers { listenLatch.countDown() } + + val expectedErrorMessage = "Software token MFA not enabled" + coEvery { mockCognitoIPClient.associateSoftwareToken(any()) }.answers { + throw SoftwareTokenMfaNotFoundException.invoke { + message = expectedErrorMessage + } + } + + plugin.setUpTOTP(onSuccess, onError) + + assertTrue { listenLatch.await(5, TimeUnit.SECONDS) } + assertTrue(authException.isCaptured) + verify(exactly = 1) { onError.accept(any()) } + assertEquals(expectedErrorMessage, authException.captured.cause?.message) + } + + @Test + fun `verifyTOTP on success`() { + val currentAuthState = mockk { + every { authNState } returns AuthenticationState.SignedIn(mockk(), mockk()) + every { authZState } returns AuthorizationState.SessionEstablished(credentials) + } + every { authStateMachine.getCurrentState(captureLambda()) } answers { + lambda<(AuthState) -> Unit>().invoke(currentAuthState) + } + val listenLatch = CountDownLatch(1) + val onSuccess = mockk() + every { onSuccess.call() }.answers { listenLatch.countDown() } + val onError = mockk>() + + val session = "SESSION" + val code = "123456" + val friendlyDeviceName = "DEVICE_NAME" + coEvery { + mockCognitoIPClient.verifySoftwareToken( + VerifySoftwareTokenRequest.invoke { + userCode = code + this.friendlyDeviceName = friendlyDeviceName + accessToken = credentials.signedInData.cognitoUserPoolTokens.accessToken + } + ) + }.answers { + VerifySoftwareTokenResponse.invoke { + status = VerifySoftwareTokenResponseType.Success + } + } + + plugin.verifyTOTPSetup( + code, + AWSCognitoAuthVerifyTOTPSetupOptions.CognitoBuilder().friendlyDeviceName(friendlyDeviceName).build(), + onSuccess, + onError + ) + + assertTrue { listenLatch.await(5, TimeUnit.SECONDS) } + verify(exactly = 1) { onSuccess.call() } + } + + @Test + fun `verifyTOTP on error`() { + val currentAuthState = mockk { + every { authNState } returns AuthenticationState.SignedIn(mockk(), mockk()) + every { authZState } returns AuthorizationState.SessionEstablished(credentials) + } + every { authStateMachine.getCurrentState(captureLambda()) } answers { + lambda<(AuthState) -> Unit>().invoke(currentAuthState) + } + val listenLatch = CountDownLatch(1) + val onSuccess = mockk() + val onError = mockk>() + val authException = slot() + every { onError.accept(capture(authException)) }.answers { listenLatch.countDown() } + + val session = "SESSION" + val code = "123456" + val friendlyDeviceName = "DEVICE_NAME" + val errorMessage = "Invalid code" + coEvery { + mockCognitoIPClient.verifySoftwareToken( + VerifySoftwareTokenRequest.invoke { + userCode = code + accessToken = credentials.signedInData.cognitoUserPoolTokens.accessToken + } + ) + }.answers { + VerifySoftwareTokenResponse.invoke { + throw CodeMismatchException.invoke { + message = errorMessage + } + } + } + + plugin.verifyTOTPSetup( + code, + AWSCognitoAuthVerifyTOTPSetupOptions.CognitoBuilder().build(), + onSuccess, + onError + ) + + assertTrue { listenLatch.await(5, TimeUnit.SECONDS) } + assertTrue(authException.isCaptured) + assertEquals(errorMessage, authException.captured.cause?.message) + verify(exactly = 1) { onError.accept(any()) } + } + + @Test + fun fetchMFAPreferences() { + val currentAuthState = mockk { + every { authNState } returns AuthenticationState.SignedIn(mockk(), mockk()) + every { authZState } returns AuthorizationState.SessionEstablished(credentials) + } + every { authStateMachine.getCurrentState(captureLambda()) } answers { + lambda<(AuthState) -> Unit>().invoke(currentAuthState) + } + val listenLatch = CountDownLatch(1) + val userMFAPreference = slot() + val onSuccess = mockk>() + every { onSuccess.accept(capture(userMFAPreference)) }.answers { listenLatch.countDown() } + val onError = mockk>() + + coEvery { + mockCognitoIPClient.getUser { + accessToken = credentials.signedInData.cognitoUserPoolTokens.accessToken + } + }.answers { + GetUserResponse.invoke { + userMfaSettingList = listOf("SMS_MFA", "SOFTWARE_TOKEN_MFA") + preferredMfaSetting = "SOFTWARE_TOKEN_MFA" + } + } + plugin.fetchMFAPreference(onSuccess, onError) + + assertTrue { listenLatch.await(5, TimeUnit.SECONDS) } + assertTrue(userMFAPreference.isCaptured) + assertEquals(setOf(MFAType.SMS, MFAType.TOTP), userMFAPreference.captured.enabled) + assertEquals(MFAType.TOTP, userMFAPreference.captured.preferred) + } + + @Test + fun updateMFAPreferences() { + val currentAuthState = mockk { + every { authNState } returns AuthenticationState.SignedIn(mockk(), mockk()) + every { authZState } returns AuthorizationState.SessionEstablished(credentials) + } + every { authStateMachine.getCurrentState(captureLambda()) } answers { + lambda<(AuthState) -> Unit>().invoke(currentAuthState) + } + val listenLatch = CountDownLatch(1) + val onSuccess = mockk() + every { onSuccess.call() }.answers { listenLatch.countDown() } + val onError = mockk>() + val setUserMFAPreferenceRequest = slot() + coEvery { mockCognitoIPClient.setUserMfaPreference(capture(setUserMFAPreferenceRequest)) }.answers { + SetUserMfaPreferenceResponse.invoke { + } + } + plugin.updateMFAPreference(MFAPreference.ENABLED, MFAPreference.PREFERRED, onSuccess, onError) + + assertTrue { listenLatch.await(5, TimeUnit.SECONDS) } + assertTrue(setUserMFAPreferenceRequest.isCaptured) + assertEquals( + SmsMfaSettingsType.invoke { + enabled = true + preferredMfa = false + }, + setUserMFAPreferenceRequest.captured.smsMfaSettings + ) + assertEquals( + SoftwareTokenMfaSettingsType.invoke { + enabled = true + preferredMfa = true + }, + setUserMFAPreferenceRequest.captured.softwareTokenMfaSettings + ) + } } diff --git a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/StateTransitionTestBase.kt b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/StateTransitionTestBase.kt index b5f2e803a2..22f250799c 100644 --- a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/StateTransitionTestBase.kt +++ b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/StateTransitionTestBase.kt @@ -27,6 +27,7 @@ import com.amplifyframework.statemachine.codegen.actions.FetchAuthSessionActions import com.amplifyframework.statemachine.codegen.actions.HostedUIActions import com.amplifyframework.statemachine.codegen.actions.MigrateAuthActions import com.amplifyframework.statemachine.codegen.actions.SRPActions +import com.amplifyframework.statemachine.codegen.actions.SetupTOTPActions import com.amplifyframework.statemachine.codegen.actions.SignInActions import com.amplifyframework.statemachine.codegen.actions.SignInChallengeActions import com.amplifyframework.statemachine.codegen.actions.SignOutActions @@ -117,13 +118,16 @@ open class StateTransitionTestBase { @Mock internal lateinit var mockDeleteUserActions: DeleteUserCognitoActions + @Mock + internal lateinit var mockSetupTOTPActions: SetupTOTPActions + private val dummyCredential = AmplifyCredential.UserAndIdentityPool( SignedInData( "userId", "username", Date(0), SignInMethod.ApiBased(SignInMethod.ApiBased.AuthType.USER_SRP_AUTH), - CognitoUserPoolTokens("idToken", "accessToken", "refreshToken", 123123L), + CognitoUserPoolTokens("idToken", "accessToken", "refreshToken", 123123L) ), "identityPool", AWSCredentials( @@ -342,7 +346,9 @@ open class StateTransitionTestBase { dispatcher.send( SRPEvent( SRPEvent.EventType.RespondPasswordVerifier( - mapOf(), mapOf(), "sample_session" + mapOf(), + mapOf(), + "sample_session" ) ) ) diff --git a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/StateTransitionTests.kt b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/StateTransitionTests.kt index f8a09bbbb3..0dbe3ae41f 100644 --- a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/StateTransitionTests.kt +++ b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/StateTransitionTests.kt @@ -38,6 +38,7 @@ import com.amplifyframework.statemachine.codegen.states.HostedUISignInState import com.amplifyframework.statemachine.codegen.states.MigrateSignInState import com.amplifyframework.statemachine.codegen.states.RefreshSessionState import com.amplifyframework.statemachine.codegen.states.SRPSignInState +import com.amplifyframework.statemachine.codegen.states.SetupTOTPState import com.amplifyframework.statemachine.codegen.states.SignInChallengeState import com.amplifyframework.statemachine.codegen.states.SignInState import com.amplifyframework.statemachine.codegen.states.SignOutState @@ -92,6 +93,7 @@ class StateTransitionTests : StateTransitionTestBase() { SignInChallengeState.Resolver(mockSignInChallengeActions), HostedUISignInState.Resolver(mockHostedUIActions), DeviceSRPSignInState.Resolver(mockDeviceSRPSignInActions), + SetupTOTPState.Resolver(mockSetupTOTPActions), mockSignInActions ), SignOutState.Resolver(mockSignOutActions), diff --git a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/actions/SetupTOTPCognitoActionsTest.kt b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/actions/SetupTOTPCognitoActionsTest.kt new file mode 100644 index 0000000000..d45ac34f64 --- /dev/null +++ b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/actions/SetupTOTPCognitoActionsTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.auth.cognito.actions + +import androidx.test.core.app.ApplicationProvider +import aws.sdk.kotlin.services.cognitoidentityprovider.CognitoIdentityProviderClient +import aws.sdk.kotlin.services.cognitoidentityprovider.model.AssociateSoftwareTokenRequest +import aws.sdk.kotlin.services.cognitoidentityprovider.model.AssociateSoftwareTokenResponse +import aws.sdk.kotlin.services.cognitoidentityprovider.model.CodeMismatchException +import aws.sdk.kotlin.services.cognitoidentityprovider.model.SoftwareTokenMfaNotFoundException +import aws.sdk.kotlin.services.cognitoidentityprovider.model.VerifySoftwareTokenResponse +import aws.sdk.kotlin.services.cognitoidentityprovider.model.VerifySoftwareTokenResponseType +import aws.sdk.kotlin.services.cognitoidentityprovider.verifySoftwareToken +import com.amplifyframework.auth.cognito.AWSCognitoAuthService +import com.amplifyframework.auth.cognito.AuthEnvironment +import com.amplifyframework.auth.cognito.StoreClientBehavior +import com.amplifyframework.logging.Logger +import com.amplifyframework.statemachine.EventDispatcher +import com.amplifyframework.statemachine.StateMachineEvent +import com.amplifyframework.statemachine.codegen.data.AuthConfiguration +import com.amplifyframework.statemachine.codegen.data.SignInTOTPSetupData +import com.amplifyframework.statemachine.codegen.events.SetupTOTPEvent +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlin.test.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class SetupTOTPCognitoActionsTest { + + private val configuration = mockk() + private val cognitoAuthService = mockk() + private val credentialStoreClient = mockk() + private val logger = mockk() + private val cognitoIdentityProviderClientMock = mockk() + private val dispatcher = mockk() + + private val capturedEvent = slot() + + private lateinit var authEnvironment: AuthEnvironment + + @Before + fun setup() { + every { logger.verbose(any()) }.answers {} + every { dispatcher.send(capture(capturedEvent)) }.answers { } + every { cognitoAuthService.cognitoIdentityProviderClient }.answers { cognitoIdentityProviderClientMock } + authEnvironment = AuthEnvironment( + ApplicationProvider.getApplicationContext(), + configuration, + cognitoAuthService, + credentialStoreClient, + null, + null, + logger + ) + } + + @Test + fun `initiateTOTPSetup send waitForAnswer on success`() = runTest { + val secretCode = "SECRET_CODE" + val session = "SESSION" + val username = "USERNAME" + coEvery { + cognitoIdentityProviderClientMock.associateSoftwareToken(any()) + }.answers { + AssociateSoftwareTokenResponse.invoke { + this.secretCode = secretCode + this.session = session + } + } + val initiateAction = SetupTOTPCognitoActions.initiateTOTPSetup( + SetupTOTPEvent.EventType.SetupTOTP( + SignInTOTPSetupData("", "SESSION", "USERNAME") + ) + ) + initiateAction.execute(dispatcher, authEnvironment) + + val expectedEvent = SetupTOTPEvent( + SetupTOTPEvent.EventType.WaitForAnswer(SignInTOTPSetupData(secretCode, session, username)) + ) + assertEquals( + expectedEvent.type, + capturedEvent.captured.type + ) + assertEquals( + secretCode, + ( + (capturedEvent.captured as SetupTOTPEvent).eventType as SetupTOTPEvent.EventType.WaitForAnswer + ).totpSetupDetails.secretCode + ) + } + + @Test + fun `initiateTOTPSetup send waitForAnswer on failure`() = runTest { + val session = "SESSION" + val serviceException = SoftwareTokenMfaNotFoundException { + message = "TOTP is not enabled" + } + coEvery { + cognitoIdentityProviderClientMock.associateSoftwareToken( + AssociateSoftwareTokenRequest.invoke { + this.session = session + } + ) + }.answers { + throw serviceException + } + val initiateAction = SetupTOTPCognitoActions.initiateTOTPSetup( + SetupTOTPEvent.EventType.SetupTOTP( + SignInTOTPSetupData("", "SESSION", "USERNAME") + ) + ) + initiateAction.execute(dispatcher, authEnvironment) + + val expectedEvent = SetupTOTPEvent( + SetupTOTPEvent.EventType.ThrowAuthError(serviceException, "USERNAME", "SESSION") + ) + assertEquals( + expectedEvent.type, + capturedEvent.captured.type + ) + assertEquals( + serviceException, + ((capturedEvent.captured as SetupTOTPEvent).eventType as SetupTOTPEvent.EventType.ThrowAuthError).exception + ) + } + + @Test + fun `verifyChallengeAnswer send RespondToAuthChallenge on success`() = runTest { + val answer = "123456" + val session = "SESSION" + val username = "USERNAME" + val friendlyDeviceName = "TEST_DEVICE" + coEvery { + cognitoIdentityProviderClientMock.verifySoftwareToken { + this.userCode = answer + this.session = session + this.friendlyDeviceName = friendlyDeviceName + } + }.answers { + VerifySoftwareTokenResponse.invoke { + this.session = session + this.status = VerifySoftwareTokenResponseType.Success + } + } + val expectedEvent = SetupTOTPEvent( + SetupTOTPEvent.EventType.RespondToAuthChallenge(username, session) + ) + + val verifyChallengeAnswerAction = SetupTOTPCognitoActions.verifyChallengeAnswer( + SetupTOTPEvent.EventType.VerifyChallengeAnswer( + answer, + username, + session, + friendlyDeviceName + ) + ) + verifyChallengeAnswerAction.execute(dispatcher, authEnvironment) + + assertEquals( + expectedEvent.type, + capturedEvent.captured.type + ) + + assertEquals( + session, + ( + (capturedEvent.captured as SetupTOTPEvent).eventType as SetupTOTPEvent.EventType.RespondToAuthChallenge + ).session + ) + } + + @Test + fun `verifyChallengeAnswer send RespondToAuthChallenge on Error`() = runTest { + val answer = "123456" + val session = "SESSION" + val username = "USERNAME" + val friendlyDeviceName = "TEST_DEVICE" + coEvery { + cognitoIdentityProviderClientMock.verifySoftwareToken { + this.userCode = answer + this.session = session + this.friendlyDeviceName = friendlyDeviceName + } + }.answers { + VerifySoftwareTokenResponse.invoke { + this.session = session + this.status = VerifySoftwareTokenResponseType.Error + } + } + val expectedEvent = SetupTOTPEvent( + SetupTOTPEvent.EventType.ThrowAuthError( + Exception("An unknown service error has occurred"), + "USERNAME", + "SESSION" + ) + ) + + val verifyChallengeAnswerAction = SetupTOTPCognitoActions.verifyChallengeAnswer( + SetupTOTPEvent.EventType.VerifyChallengeAnswer( + answer, + username, + session, + friendlyDeviceName + ) + ) + verifyChallengeAnswerAction.execute(dispatcher, authEnvironment) + + assertEquals( + expectedEvent.type, + capturedEvent.captured.type + ) + + assertEquals( + (expectedEvent.eventType as SetupTOTPEvent.EventType.ThrowAuthError).exception.message, + ( + (capturedEvent.captured as SetupTOTPEvent).eventType as SetupTOTPEvent.EventType.ThrowAuthError + ).exception.message + ) + } + + @Test + fun `verifyChallengeAnswer send RespondToAuthChallenge on exception`() = runTest { + val answer = "123456" + val session = "SESSION" + val username = "USERNAME" + val friendlyDeviceName = "TEST_DEVICE" + val serviceException = CodeMismatchException { + message = "Invalid Code" + } + coEvery { + cognitoIdentityProviderClientMock.verifySoftwareToken { + this.userCode = answer + this.session = session + this.friendlyDeviceName = friendlyDeviceName + } + }.answers { + throw serviceException + } + val expectedEvent = SetupTOTPEvent( + SetupTOTPEvent.EventType.ThrowAuthError(serviceException, "USERNAME", "SESSION") + ) + + val verifyChallengeAnswerAction = SetupTOTPCognitoActions.verifyChallengeAnswer( + SetupTOTPEvent.EventType.VerifyChallengeAnswer( + answer, + username, + session, + friendlyDeviceName + ) + ) + verifyChallengeAnswerAction.execute(dispatcher, authEnvironment) + + assertEquals( + expectedEvent.type, + capturedEvent.captured.type + ) + + assertEquals( + (expectedEvent.eventType as SetupTOTPEvent.EventType.ThrowAuthError).exception.message, + ( + (capturedEvent.captured as SetupTOTPEvent).eventType as SetupTOTPEvent.EventType.ThrowAuthError + ).exception.message + ) + } +} diff --git a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/featuretest/serializers/AuthStatesSerializer.kt b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/featuretest/serializers/AuthStatesSerializer.kt index f3415d6609..d3a454353d 100644 --- a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/featuretest/serializers/AuthStatesSerializer.kt +++ b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/featuretest/serializers/AuthStatesSerializer.kt @@ -44,13 +44,17 @@ import kotlinx.serialization.modules.contextual @Serializable internal data class AuthStatesProxy( val type: String = "AuthState", - @Contextual @SerialName("AuthenticationState") + @Contextual + @SerialName("AuthenticationState") val authNState: AuthenticationState? = null, - @Contextual @SerialName("AuthorizationState") + @Contextual + @SerialName("AuthorizationState") val authZState: AuthorizationState? = null, - @Contextual @SerialName("SignInState") + @Contextual + @SerialName("SignInState") val signInState: SignInState = SignInState.NotStarted(), - @Contextual @SerialName("SignInChallengeState") + @Contextual + @SerialName("SignInChallengeState") val signInChallengeState: SignInChallengeState? = null, @Contextual val signedInData: SignedInData? = null, @@ -142,7 +146,7 @@ internal data class AuthStatesProxy( amplifyCredential = authState.amplifyCredential ) is AuthorizationState.SigningIn -> AuthStatesProxy( - type = "AuthorizationState.SigningIn", + type = "AuthorizationState.SigningIn" ) is AuthorizationState.SigningOut -> TODO() is AuthorizationState.StoringCredentials -> TODO() @@ -166,6 +170,7 @@ internal data class AuthStatesProxy( is SignInState.SigningInWithHostedUI -> TODO() is SignInState.SigningInWithSRP -> TODO() is SignInState.SigningInWithSRPCustom -> TODO() + is SignInState.ResolvingTOTPSetup -> TODO() } } is SignInChallengeState -> { diff --git a/core-kotlin/src/main/java/com/amplifyframework/kotlin/auth/Auth.kt b/core-kotlin/src/main/java/com/amplifyframework/kotlin/auth/Auth.kt index a6e80ef849..8b66c28bba 100644 --- a/core-kotlin/src/main/java/com/amplifyframework/kotlin/auth/Auth.kt +++ b/core-kotlin/src/main/java/com/amplifyframework/kotlin/auth/Auth.kt @@ -25,6 +25,7 @@ import com.amplifyframework.auth.AuthSession import com.amplifyframework.auth.AuthUser import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.AuthUserAttributeKey +import com.amplifyframework.auth.TOTPSetupDetails import com.amplifyframework.auth.options.AuthConfirmResetPasswordOptions import com.amplifyframework.auth.options.AuthConfirmSignInOptions import com.amplifyframework.auth.options.AuthConfirmSignUpOptions @@ -37,6 +38,7 @@ import com.amplifyframework.auth.options.AuthSignOutOptions import com.amplifyframework.auth.options.AuthSignUpOptions import com.amplifyframework.auth.options.AuthUpdateUserAttributeOptions import com.amplifyframework.auth.options.AuthUpdateUserAttributesOptions +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions import com.amplifyframework.auth.options.AuthWebUISignInOptions import com.amplifyframework.auth.result.AuthResetPasswordResult import com.amplifyframework.auth.result.AuthSignInResult @@ -327,4 +329,22 @@ interface Auth { */ @Throws(AuthException::class) suspend fun deleteUser() + + /** + * Setup TOTP for the currently signed in user. + * @return TOTP Setup details + */ + @Throws(AuthException::class) + suspend fun setUpTOTP(): TOTPSetupDetails + + /** + * Verify TOTP setup for the currently signed in user. + * @param code TOTP code to verify TOTP setup + * @param options additional options to verify totp setup + */ + @Throws(AuthException::class) + suspend fun verifyTOTPSetup( + code: String, + options: AuthVerifyTOTPSetupOptions = AuthVerifyTOTPSetupOptions.defaults() + ) } diff --git a/core-kotlin/src/main/java/com/amplifyframework/kotlin/auth/KotlinAuthFacade.kt b/core-kotlin/src/main/java/com/amplifyframework/kotlin/auth/KotlinAuthFacade.kt index 41a3d03bce..ab9ab28267 100644 --- a/core-kotlin/src/main/java/com/amplifyframework/kotlin/auth/KotlinAuthFacade.kt +++ b/core-kotlin/src/main/java/com/amplifyframework/kotlin/auth/KotlinAuthFacade.kt @@ -25,6 +25,7 @@ import com.amplifyframework.auth.AuthSession import com.amplifyframework.auth.AuthUser import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.AuthUserAttributeKey +import com.amplifyframework.auth.TOTPSetupDetails import com.amplifyframework.auth.options.AuthConfirmResetPasswordOptions import com.amplifyframework.auth.options.AuthConfirmSignInOptions import com.amplifyframework.auth.options.AuthConfirmSignUpOptions @@ -37,6 +38,7 @@ import com.amplifyframework.auth.options.AuthSignOutOptions import com.amplifyframework.auth.options.AuthSignUpOptions import com.amplifyframework.auth.options.AuthUpdateUserAttributeOptions import com.amplifyframework.auth.options.AuthUpdateUserAttributesOptions +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions import com.amplifyframework.auth.options.AuthWebUISignInOptions import com.amplifyframework.auth.result.AuthResetPasswordResult import com.amplifyframework.auth.result.AuthSignInResult @@ -336,4 +338,24 @@ class KotlinAuthFacade(private val delegate: Delegate = Amplify.Auth) : Auth { ) } } + + override suspend fun setUpTOTP(): TOTPSetupDetails { + return suspendCoroutine { continuation -> + delegate.setUpTOTP({ + continuation.resume(it) + }, { + continuation.resumeWithException(it) + }) + } + } + + override suspend fun verifyTOTPSetup(code: String, options: AuthVerifyTOTPSetupOptions) { + return suspendCoroutine { continuation -> + delegate.verifyTOTPSetup(code, options, { + continuation.resume(Unit) + }, { + continuation.resumeWithException(it) + }) + } + } } diff --git a/core-kotlin/src/test/java/com/amplifyframework/kotlin/auth/KotlinAuthFacadeTest.kt b/core-kotlin/src/test/java/com/amplifyframework/kotlin/auth/KotlinAuthFacadeTest.kt index 9825263b43..f651f409e6 100644 --- a/core-kotlin/src/test/java/com/amplifyframework/kotlin/auth/KotlinAuthFacadeTest.kt +++ b/core-kotlin/src/test/java/com/amplifyframework/kotlin/auth/KotlinAuthFacadeTest.kt @@ -27,9 +27,11 @@ import com.amplifyframework.auth.AuthSession import com.amplifyframework.auth.AuthUser import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.AuthUserAttributeKey +import com.amplifyframework.auth.TOTPSetupDetails import com.amplifyframework.auth.exceptions.SessionExpiredException import com.amplifyframework.auth.exceptions.SignedOutException import com.amplifyframework.auth.options.AuthFetchSessionOptions +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions import com.amplifyframework.auth.result.AuthResetPasswordResult import com.amplifyframework.auth.result.AuthSignInResult import com.amplifyframework.auth.result.AuthSignOutResult @@ -964,4 +966,75 @@ class KotlinAuthFacadeTest { } auth.deleteUser() } + + /** + * When the setUpTOTP() delegate emits a success, it should + * be bubbled up through the coroutine API as well. + */ + @Test + fun setUpTOTPSucceeds(): Unit = runBlocking { + val setupDetails = TOTPSetupDetails("ss", "u") + every { + delegate.setUpTOTP(any(), any()) + } answers { + val indexOfErrorConsumer = 0 + val onComplete = it.invocation.args[indexOfErrorConsumer] as Consumer + onComplete.accept(setupDetails) + } + auth.setUpTOTP() + } + + /** + * When the setUpTOTP() delegate emits an error, it should + * be bubbled up through the coroutine API as well. + */ + @Test(expected = AuthException::class) + fun setUpTOTPThrows(): Unit = runBlocking { + val error = AuthException("uh", "oh") + every { + delegate.setUpTOTP(any(), any()) + } answers { + val indexOfErrorConsumer = 1 + val onError = it.invocation.args[indexOfErrorConsumer] as Consumer + onError.accept(error) + } + auth.setUpTOTP() + } + + /** + * When the verifyTOTPSetup() delegate emits a success, it should + * be bubbled up through the coroutine API as well. + */ + @Test + fun verifyTOTPSetupSucceeds(): Unit = runBlocking { + val code = "abc123" + val options = AuthVerifyTOTPSetupOptions.defaults() + every { + delegate.verifyTOTPSetup(code, options, any(), any()) + } answers { + val indexOfErrorConsumer = 2 + val onComplete = it.invocation.args[indexOfErrorConsumer] as Action + onComplete.call() + } + auth.verifyTOTPSetup(code, options) + } + + /** + * When the verifyTOTPSetup() delegate emits an error, it should + * be bubbled up through the coroutine API as well. + */ + @Test(expected = AuthException::class) + fun verifyTOTPSetupThrows(): Unit = runBlocking { + val code = "abc123" + val options = AuthVerifyTOTPSetupOptions.defaults() + val error = AuthException("uh", "oh") + every { + delegate.verifyTOTPSetup(code, options, any(), any()) + } answers { + val indexOfErrorConsumer = 3 + val onError = it.invocation.args[indexOfErrorConsumer] as Consumer + onError.accept(error) + } + auth.verifyTOTPSetup(code) + } } diff --git a/core/src/main/java/com/amplifyframework/auth/AuthCategory.java b/core/src/main/java/com/amplifyframework/auth/AuthCategory.java index 51d144e202..12bab4b2a8 100644 --- a/core/src/main/java/com/amplifyframework/auth/AuthCategory.java +++ b/core/src/main/java/com/amplifyframework/auth/AuthCategory.java @@ -32,6 +32,7 @@ import com.amplifyframework.auth.options.AuthSignUpOptions; import com.amplifyframework.auth.options.AuthUpdateUserAttributeOptions; import com.amplifyframework.auth.options.AuthUpdateUserAttributesOptions; +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions; import com.amplifyframework.auth.options.AuthWebUISignInOptions; import com.amplifyframework.auth.result.AuthResetPasswordResult; import com.amplifyframework.auth.result.AuthSignInResult; @@ -387,12 +388,29 @@ public void signOut(@NonNull Consumer onComplete) { ) { getSelectedPlugin().signOut(options, onComplete); } - + @Override public void deleteUser( @NonNull Action onSuccess, @NonNull Consumer onError) { getSelectedPlugin().deleteUser(onSuccess, onError); } + + @Override + public void setUpTOTP(@NonNull Consumer onSuccess, @NonNull Consumer onError) { + getSelectedPlugin().setUpTOTP(onSuccess, onError); + } + + @Override + public void verifyTOTPSetup(@NonNull String code, @NonNull Action onSuccess, + @NonNull Consumer onError) { + getSelectedPlugin().verifyTOTPSetup(code, onSuccess, onError); + } + + @Override + public void verifyTOTPSetup(@NonNull String code, @NonNull AuthVerifyTOTPSetupOptions options, + @NonNull Action onSuccess, @NonNull Consumer onError) { + getSelectedPlugin().verifyTOTPSetup(code, options, onSuccess, onError); + } } diff --git a/core/src/main/java/com/amplifyframework/auth/AuthCategoryBehavior.java b/core/src/main/java/com/amplifyframework/auth/AuthCategoryBehavior.java index 82d7254ca7..6ccebd4c83 100644 --- a/core/src/main/java/com/amplifyframework/auth/AuthCategoryBehavior.java +++ b/core/src/main/java/com/amplifyframework/auth/AuthCategoryBehavior.java @@ -32,6 +32,7 @@ import com.amplifyframework.auth.options.AuthSignUpOptions; import com.amplifyframework.auth.options.AuthUpdateUserAttributeOptions; import com.amplifyframework.auth.options.AuthUpdateUserAttributesOptions; +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions; import com.amplifyframework.auth.options.AuthWebUISignInOptions; import com.amplifyframework.auth.result.AuthResetPasswordResult; import com.amplifyframework.auth.result.AuthSignInResult; @@ -512,4 +513,39 @@ void signOut( void deleteUser( @NonNull Action onSuccess, @NonNull Consumer onError); + + /** + * Setup TOTP for the currently signed in user. + * @param onSuccess Success callback + * @param onError Error callback + */ + void setUpTOTP( + @NonNull Consumer onSuccess, + @NonNull Consumer onError); + + /** + * Verify TOTP setup for the currently signed in user. + * @param code TOTP code to verify TOTP setup + * @param onSuccess Success callback + * @param onError Error callback + */ + void verifyTOTPSetup( + @NonNull String code, + @NonNull Action onSuccess, + @NonNull Consumer onError + ); + + /** + * Verify TOTP setup for the currently signed in user. + * @param code TOTP code to verify TOTP setup + * @param options additional options to verify totp setup + * @param onSuccess Success callback + * @param onError Error callback + */ + void verifyTOTPSetup( + @NonNull String code, + @NonNull AuthVerifyTOTPSetupOptions options, + @NonNull Action onSuccess, + @NonNull Consumer onError + ); } diff --git a/core/src/main/java/com/amplifyframework/auth/AuthPlugin.java b/core/src/main/java/com/amplifyframework/auth/AuthPlugin.java index bec4eb7a06..b1f5556e32 100644 --- a/core/src/main/java/com/amplifyframework/auth/AuthPlugin.java +++ b/core/src/main/java/com/amplifyframework/auth/AuthPlugin.java @@ -20,6 +20,9 @@ import androidx.annotation.WorkerThread; import com.amplifyframework.AmplifyException; +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions; +import com.amplifyframework.core.Action; +import com.amplifyframework.core.Consumer; import com.amplifyframework.core.category.CategoryType; import com.amplifyframework.core.plugin.Plugin; @@ -39,4 +42,46 @@ public final CategoryType getCategoryType() { @WorkerThread @Override public void initialize(@NonNull Context context) throws AmplifyException {} + + /** + * Default implementation that throws UnsupportedOperationException. + * @param onSuccess Success callback + * @param onError Error callback + */ + @Override + public void setUpTOTP(@NonNull Consumer onSuccess, @NonNull Consumer onError) { + throw new UnsupportedOperationException("TOTP is not implemented in this plugin"); + } + + /** + * Default implementation that throws UnsupportedOperationException. + * @param code TOTP code to verify TOTP setup + * @param onSuccess Success callback + * @param onError Error callback + */ + @Override + public void verifyTOTPSetup( + @NonNull String code, + @NonNull Action onSuccess, + @NonNull Consumer onError + ) { + throw new UnsupportedOperationException("TOTP is not implemented in this plugin"); + } + + /** + * Default implementation that throws UnsupportedOperationException. + * @param code TOTP code to verify TOTP setup + * @param options additional options to verify totp setup + * @param onSuccess Success callback + * @param onError Error callback + */ + @Override + public void verifyTOTPSetup( + @NonNull String code, + @NonNull AuthVerifyTOTPSetupOptions options, + @NonNull Action onSuccess, + @NonNull Consumer onError + ) { + throw new UnsupportedOperationException("TOTP is not implemented in this plugin"); + } } diff --git a/core/src/main/java/com/amplifyframework/auth/MFAType.kt b/core/src/main/java/com/amplifyframework/auth/MFAType.kt new file mode 100644 index 0000000000..c57472a3c8 --- /dev/null +++ b/core/src/main/java/com/amplifyframework/auth/MFAType.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.auth + +/** + * Type of MFA for authentication. + */ +enum class MFAType { + + /** + * Short Messaging Service linked with a phone number + */ + SMS, + + /** + * Time-based One Time Password linked with an authenticator app + */ + TOTP; +} diff --git a/core/src/main/java/com/amplifyframework/auth/TOTPSetupDetails.kt b/core/src/main/java/com/amplifyframework/auth/TOTPSetupDetails.kt new file mode 100644 index 0000000000..debb9444a0 --- /dev/null +++ b/core/src/main/java/com/amplifyframework/auth/TOTPSetupDetails.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.amplifyframework.auth + +import android.net.Uri + +/** + * Details of TOTP Setup that help launch into TOTP manager. + * + * @param sharedSecret Secret code returned by the service to help setting up TOTP + * @param username username that will be used to construct the URI + */ +data class TOTPSetupDetails( + val sharedSecret: String, + val username: String +) { + + /** + * Returns a TOTP setup URI that can help avoid barcode scanning and use native password manager + * to handle TOTP association. + * + * @param appName of TOTP manager + * @param accountName for TOTP manager. Defaults to stored username value. + */ + @JvmOverloads + fun getSetupURI( + appName: String, + accountName: String = username + ): Uri { + return Uri.parse("otpauth://totp/$appName:$accountName?secret=$sharedSecret&issuer=$appName") + } +} diff --git a/core/src/main/java/com/amplifyframework/auth/options/AuthVerifyTOTPSetupOptions.java b/core/src/main/java/com/amplifyframework/auth/options/AuthVerifyTOTPSetupOptions.java new file mode 100644 index 0000000000..6d39a494de --- /dev/null +++ b/core/src/main/java/com/amplifyframework/auth/options/AuthVerifyTOTPSetupOptions.java @@ -0,0 +1,77 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.amplifyframework.auth.options; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * The shared options among all Auth plugins. + * Note: This is currently empty but exists here to support common verify totp setup options. + */ +public abstract class AuthVerifyTOTPSetupOptions { + + /** + * Use the default verify totp setup options. + * @return Default verify totp setup options. + */ + public static DefaultAuthVerifyTOTPSetupOptions defaults() { + return new DefaultAuthVerifyTOTPSetupOptions(); + } + + /** + * The builder for this class. + * @param The type of builder - used to support plugin extensions of this. + */ + public abstract static class Builder> { + + /** + * Return the type of builder this is so that chaining can work correctly without implicit casting. + * @return the type of builder this is + */ + public abstract T getThis(); + + /** + * Build an instance of AuthVerifyTOTPSetupOptions (or one of its subclasses). + * @return an instance of AuthVerifyTOTPSetupOptions (or one of its subclasses) + */ + public abstract AuthVerifyTOTPSetupOptions build(); + } + + /** + * Default verify totp setup options. This works like a sentinel, to be used instead of "null". + * The only way to create this is by calling {@link AuthVerifyTOTPSetupOptions#defaults()}. + */ + public static final class DefaultAuthVerifyTOTPSetupOptions extends AuthVerifyTOTPSetupOptions { + private DefaultAuthVerifyTOTPSetupOptions() {} + + @Override + public int hashCode() { + return DefaultAuthVerifyTOTPSetupOptions.class.hashCode(); + } + + @Override + public boolean equals(@Nullable Object obj) { + return obj instanceof DefaultAuthVerifyTOTPSetupOptions; + } + + @NonNull + @Override + public String toString() { + return DefaultAuthVerifyTOTPSetupOptions.class.getSimpleName(); + } + } +} diff --git a/core/src/main/java/com/amplifyframework/auth/result/step/AuthNextSignInStep.java b/core/src/main/java/com/amplifyframework/auth/result/step/AuthNextSignInStep.java index ad926c956e..d404ec185c 100644 --- a/core/src/main/java/com/amplifyframework/auth/result/step/AuthNextSignInStep.java +++ b/core/src/main/java/com/amplifyframework/auth/result/step/AuthNextSignInStep.java @@ -20,10 +20,13 @@ import androidx.core.util.ObjectsCompat; import com.amplifyframework.auth.AuthCodeDeliveryDetails; +import com.amplifyframework.auth.MFAType; +import com.amplifyframework.auth.TOTPSetupDetails; import java.util.HashMap; import java.util.Map; import java.util.Objects; +import java.util.Set; /** * This object represents all details around the next step in the sign in process. It holds an instance of the @@ -35,20 +38,29 @@ public final class AuthNextSignInStep { private final Map additionalInfo; private final AuthCodeDeliveryDetails codeDeliveryDetails; + private final TOTPSetupDetails totpSetupDetails; + private final Set allowedMFATypes; + /** * Gives details on the next step, if there is one, in the sign in flow. * @param signInStep the next step in the sign in flow (could be optional or required) * @param additionalInfo possible extra info to go with the next step (refer to plugin documentation) * @param codeDeliveryDetails Details about how a code was sent, if relevant to the current step + * @param totpSetupDetails Details to setup TOTP, if relevant to the current step + * @param allowedMFATypes Set of allowed MFA type, if relevant to the current step */ public AuthNextSignInStep( @NonNull AuthSignInStep signInStep, @NonNull Map additionalInfo, - @Nullable AuthCodeDeliveryDetails codeDeliveryDetails) { + @Nullable AuthCodeDeliveryDetails codeDeliveryDetails, + @Nullable TOTPSetupDetails totpSetupDetails, + @Nullable Set allowedMFATypes) { this.signInStep = Objects.requireNonNull(signInStep); this.additionalInfo = new HashMap<>(); this.additionalInfo.putAll(Objects.requireNonNull(additionalInfo)); this.codeDeliveryDetails = codeDeliveryDetails; + this.totpSetupDetails = totpSetupDetails; + this.allowedMFATypes = allowedMFATypes; } /** @@ -78,6 +90,24 @@ public AuthCodeDeliveryDetails getCodeDeliveryDetails() { return codeDeliveryDetails; } + /** + * Details about how to setup TOTP. + * @return Details about how to setup TOTP, if relevant to the current step - null otherwise + */ + @Nullable + public TOTPSetupDetails getTotpSetupDetails() { + return totpSetupDetails; + } + + /** + * Set of allowed MFA Types. + * @return Set of allowed MFA Types, if relevant to the current step - null otherwise + */ + @Nullable + public Set getAllowedMFATypes() { + return allowedMFATypes; + } + /** * When overriding, be sure to include signInStep, additionalInfo, and codeDeliveryDetails in the hash. * @return Hash code of this object @@ -87,7 +117,9 @@ public int hashCode() { return ObjectsCompat.hash( getSignInStep(), getAdditionalInfo(), - getCodeDeliveryDetails() + getCodeDeliveryDetails(), + getTotpSetupDetails(), + getAllowedMFATypes() ); } @@ -105,7 +137,9 @@ public boolean equals(Object obj) { AuthNextSignInStep authSignUpResult = (AuthNextSignInStep) obj; return ObjectsCompat.equals(getSignInStep(), authSignUpResult.getSignInStep()) && ObjectsCompat.equals(getAdditionalInfo(), authSignUpResult.getAdditionalInfo()) && - ObjectsCompat.equals(getCodeDeliveryDetails(), authSignUpResult.getCodeDeliveryDetails()); + ObjectsCompat.equals(getCodeDeliveryDetails(), authSignUpResult.getCodeDeliveryDetails()) && + ObjectsCompat.equals(getTotpSetupDetails(), authSignUpResult.getTotpSetupDetails()) && + ObjectsCompat.equals(getAllowedMFATypes(), authSignUpResult.getAllowedMFATypes()); } } @@ -119,6 +153,8 @@ public String toString() { "signInStep=" + getSignInStep() + ", additionalInfo=" + getAdditionalInfo() + ", codeDeliveryDetails=" + getCodeDeliveryDetails() + + ", totpSetupDetails=" + getTotpSetupDetails() + + ", allowedMFATypes=" + getAllowedMFATypes() + '}'; } } diff --git a/core/src/main/java/com/amplifyframework/auth/result/step/AuthSignInStep.java b/core/src/main/java/com/amplifyframework/auth/result/step/AuthSignInStep.java index 4b2dd27558..3373104db3 100644 --- a/core/src/main/java/com/amplifyframework/auth/result/step/AuthSignInStep.java +++ b/core/src/main/java/com/amplifyframework/auth/result/step/AuthSignInStep.java @@ -27,7 +27,7 @@ public enum AuthSignInStep { * with the code sent via SMS text message to proceed with the sign in flow. */ CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE, - + /** * Custom multifactor authentication is enabled on this account and requires you to call * {@link com.amplifyframework.auth.AuthCategoryBehavior#confirmSignIn(String, Consumer, Consumer)} @@ -58,6 +58,27 @@ public enum AuthSignInStep { */ CONFIRM_SIGN_UP, + /** + * Admin requires user to setup TOTP. + * Call {@link com.amplifyframework.auth.AuthCategoryBehavior#confirmSignIn(String, Consumer, Consumer)} + * with TOTP code to verify. + */ + CONTINUE_SIGN_IN_WITH_TOTP_SETUP, + + /** + * The user account is required to set MFA selection. + * Call {@link com.amplifyframework.auth.AuthCategoryBehavior#confirmSignIn(String, Consumer, Consumer)} + * with preferred MFA option. + */ + CONTINUE_SIGN_IN_WITH_MFA_SELECTION, + + /** + * TOTP is enabled on this account and requires the user to confirm with the TOTP code. + * Call {@link com.amplifyframework.auth.AuthCategoryBehavior#confirmSignIn(String, Consumer, Consumer)} + * with TOTP Code. + */ + CONFIRM_SIGN_IN_WITH_TOTP_CODE, + /** * No further steps are needed in the sign in flow. */ diff --git a/core/src/test/java/com/amplifyframework/auth/AuthPluginTest.kt b/core/src/test/java/com/amplifyframework/auth/AuthPluginTest.kt new file mode 100644 index 0000000000..5ea3013fcb --- /dev/null +++ b/core/src/test/java/com/amplifyframework/auth/AuthPluginTest.kt @@ -0,0 +1,239 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.amplifyframework.auth + +import android.app.Activity +import android.content.Context +import android.content.Intent +import com.amplifyframework.auth.options.AuthConfirmResetPasswordOptions +import com.amplifyframework.auth.options.AuthConfirmSignInOptions +import com.amplifyframework.auth.options.AuthConfirmSignUpOptions +import com.amplifyframework.auth.options.AuthFetchSessionOptions +import com.amplifyframework.auth.options.AuthResendSignUpCodeOptions +import com.amplifyframework.auth.options.AuthResendUserAttributeConfirmationCodeOptions +import com.amplifyframework.auth.options.AuthResetPasswordOptions +import com.amplifyframework.auth.options.AuthSignInOptions +import com.amplifyframework.auth.options.AuthSignOutOptions +import com.amplifyframework.auth.options.AuthSignUpOptions +import com.amplifyframework.auth.options.AuthUpdateUserAttributeOptions +import com.amplifyframework.auth.options.AuthUpdateUserAttributesOptions +import com.amplifyframework.auth.options.AuthWebUISignInOptions +import com.amplifyframework.auth.result.AuthResetPasswordResult +import com.amplifyframework.auth.result.AuthSignInResult +import com.amplifyframework.auth.result.AuthSignOutResult +import com.amplifyframework.auth.result.AuthSignUpResult +import com.amplifyframework.auth.result.AuthUpdateAttributeResult +import com.amplifyframework.core.Action +import com.amplifyframework.core.Consumer +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Test that ensures new Auth category APIs have a default implementation in the [AuthPlugin] class. This allows + * 3rd party Auth plugins to compile against newer versions of Amplify. + */ +class AuthPluginTest { + + @Test + fun `test plugin compiles`() { + // The purpose of this test is to ensure that TestPlugin compiles, the assertion is irrelevant + val plugin = TestPlugin() + assertEquals("testVersion", plugin.version) + } + + /** + * DO NOT add any implementations to this class. The purpose of this test is to ensure that any new methods added + * to the Auth category have default implementations in AuthPlugin. + */ + private class TestPlugin : AuthPlugin() { + override fun signUp( + username: String, + password: String, + options: AuthSignUpOptions, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun confirmSignUp( + username: String, + confirmationCode: String, + options: AuthConfirmSignUpOptions, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun confirmSignUp( + username: String, + confirmationCode: String, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun resendSignUpCode( + username: String, + options: AuthResendSignUpCodeOptions, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun resendSignUpCode( + username: String, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun signIn( + username: String?, + password: String?, + options: AuthSignInOptions, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun signIn( + username: String?, + password: String?, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun confirmSignIn( + challengeResponse: String, + options: AuthConfirmSignInOptions, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun confirmSignIn( + challengeResponse: String, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun signInWithSocialWebUI( + provider: AuthProvider, + callingActivity: Activity, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun signInWithSocialWebUI( + provider: AuthProvider, + callingActivity: Activity, + options: AuthWebUISignInOptions, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun signInWithWebUI( + callingActivity: Activity, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun signInWithWebUI( + callingActivity: Activity, + options: AuthWebUISignInOptions, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun handleWebUISignInResponse(intent: Intent?) {} + override fun fetchAuthSession(onSuccess: Consumer, onError: Consumer) {} + override fun fetchAuthSession( + options: AuthFetchSessionOptions, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun rememberDevice(onSuccess: Action, onError: Consumer) {} + override fun forgetDevice(onSuccess: Action, onError: Consumer) {} + override fun forgetDevice(device: AuthDevice, onSuccess: Action, onError: Consumer) {} + override fun fetchDevices(onSuccess: Consumer>, onError: Consumer) {} + override fun resetPassword( + username: String, + options: AuthResetPasswordOptions, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun resetPassword( + username: String, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun confirmResetPassword( + username: String, + newPassword: String, + confirmationCode: String, + options: AuthConfirmResetPasswordOptions, + onSuccess: Action, + onError: Consumer + ) {} + override fun confirmResetPassword( + username: String, + newPassword: String, + confirmationCode: String, + onSuccess: Action, + onError: Consumer + ) {} + override fun updatePassword( + oldPassword: String, + newPassword: String, + onSuccess: Action, + onError: Consumer + ) {} + override fun fetchUserAttributes( + onSuccess: Consumer>, + onError: Consumer + ) {} + override fun updateUserAttribute( + attribute: AuthUserAttribute, + options: AuthUpdateUserAttributeOptions, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun updateUserAttribute( + attribute: AuthUserAttribute, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun updateUserAttributes( + attributes: MutableList, + options: AuthUpdateUserAttributesOptions, + onSuccess: Consumer>, + onError: Consumer + ) {} + override fun updateUserAttributes( + attributes: MutableList, + onSuccess: Consumer>, + onError: Consumer + ) {} + override fun resendUserAttributeConfirmationCode( + attributeKey: AuthUserAttributeKey, + options: AuthResendUserAttributeConfirmationCodeOptions, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun resendUserAttributeConfirmationCode( + attributeKey: AuthUserAttributeKey, + onSuccess: Consumer, + onError: Consumer + ) {} + override fun confirmUserAttribute( + attributeKey: AuthUserAttributeKey, + confirmationCode: String, + onSuccess: Action, + onError: Consumer + ) {} + override fun getCurrentUser(onSuccess: Consumer, onError: Consumer) {} + override fun signOut(onComplete: Consumer) {} + override fun signOut(options: AuthSignOutOptions, onComplete: Consumer) {} + override fun deleteUser(onSuccess: Action, onError: Consumer) {} + override fun getPluginKey() = "" + override fun configure(pluginConfiguration: JSONObject?, context: Context) {} + override fun getEscapeHatch() = Unit + override fun getVersion() = "testVersion" + + // DO NOT add any additional overrides. New APIs must have a default implementation in the AuthPlugin base class. + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d6e840a2d7..2059cd784a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,7 @@ slf4j = "2.0.6" sqlcipher = "4.5.4" tensorflow = "2.0.0" uuid = "4.0.1" +totp = "1.0.1" [libraries] android-desugartools = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" } @@ -118,3 +119,4 @@ test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } test-mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } test-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockwebserver" } test-robolectric = { module = "org.robolectric:robolectric", version.ref="robolectric" } +test-totp = { module = "dev.robinohs:totp-kt", version.ref="totp" } \ No newline at end of file diff --git a/rxbindings/src/main/java/com/amplifyframework/rx/RxAuthBinding.java b/rxbindings/src/main/java/com/amplifyframework/rx/RxAuthBinding.java index f0b7d496ae..15d231db83 100644 --- a/rxbindings/src/main/java/com/amplifyframework/rx/RxAuthBinding.java +++ b/rxbindings/src/main/java/com/amplifyframework/rx/RxAuthBinding.java @@ -30,6 +30,7 @@ import com.amplifyframework.auth.AuthUser; import com.amplifyframework.auth.AuthUserAttribute; import com.amplifyframework.auth.AuthUserAttributeKey; +import com.amplifyframework.auth.TOTPSetupDetails; import com.amplifyframework.auth.options.AuthConfirmResetPasswordOptions; import com.amplifyframework.auth.options.AuthConfirmSignInOptions; import com.amplifyframework.auth.options.AuthConfirmSignUpOptions; @@ -42,6 +43,7 @@ import com.amplifyframework.auth.options.AuthSignUpOptions; import com.amplifyframework.auth.options.AuthUpdateUserAttributeOptions; import com.amplifyframework.auth.options.AuthUpdateUserAttributesOptions; +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions; import com.amplifyframework.auth.options.AuthWebUISignInOptions; import com.amplifyframework.auth.result.AuthResetPasswordResult; import com.amplifyframework.auth.result.AuthSignInResult; @@ -308,12 +310,27 @@ public Single signOut() { public Single signOut(@NonNull AuthSignOutOptions options) { return toSingle((onComplete, onError) -> delegate.signOut(options, onComplete)); } - + @Override public Completable deleteUser() { return toCompletable(delegate::deleteUser); } + @Override + public Single setUpTOTP() { + return toSingle(delegate::setUpTOTP); + } + + @Override + public Completable verifyTOTPSetup(@NonNull String code) { + return toCompletable((onComplete, onError) -> delegate.verifyTOTPSetup(code, onComplete, onError)); + } + + @Override + public Completable verifyTOTPSetup(@NonNull String code, @NonNull AuthVerifyTOTPSetupOptions options) { + return toCompletable((onComplete, onError) -> delegate.verifyTOTPSetup(code, options, onComplete, onError)); + } + private Single toSingle(VoidBehaviors.ResultEmitter behavior) { return VoidBehaviors.toSingle(behavior); } diff --git a/rxbindings/src/main/java/com/amplifyframework/rx/RxAuthCategoryBehavior.java b/rxbindings/src/main/java/com/amplifyframework/rx/RxAuthCategoryBehavior.java index bbc810f98d..e751f04a98 100644 --- a/rxbindings/src/main/java/com/amplifyframework/rx/RxAuthCategoryBehavior.java +++ b/rxbindings/src/main/java/com/amplifyframework/rx/RxAuthCategoryBehavior.java @@ -29,6 +29,7 @@ import com.amplifyframework.auth.AuthUser; import com.amplifyframework.auth.AuthUserAttribute; import com.amplifyframework.auth.AuthUserAttributeKey; +import com.amplifyframework.auth.TOTPSetupDetails; import com.amplifyframework.auth.options.AuthConfirmResetPasswordOptions; import com.amplifyframework.auth.options.AuthConfirmSignInOptions; import com.amplifyframework.auth.options.AuthConfirmSignUpOptions; @@ -41,6 +42,7 @@ import com.amplifyframework.auth.options.AuthSignUpOptions; import com.amplifyframework.auth.options.AuthUpdateUserAttributeOptions; import com.amplifyframework.auth.options.AuthUpdateUserAttributesOptions; +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions; import com.amplifyframework.auth.options.AuthWebUISignInOptions; import com.amplifyframework.auth.result.AuthResetPasswordResult; import com.amplifyframework.auth.result.AuthSignInResult; @@ -436,4 +438,28 @@ Single resendUserAttributeConfirmationCode( * emits an {@link AuthException} otherwise */ Completable deleteUser(); + + /** + * Setup TOTP for the currently signed in user. + * @return An Rx {@link Single} which emits {@link TOTPSetupDetails} on completion + */ + Single setUpTOTP(); + + /** + * Verify TOTP setup for the currently signed in user. + * @param code TOTP code to verify TOTP setup + * @return An Rx {@link Completable} which completes upon successfully verifying totp code; + * emits an {@link AuthException} otherwise + */ + Completable verifyTOTPSetup(@NonNull String code); + + /** + * Verify TOTP setup for the currently signed in user. + * @param code TOTP code to verify TOTP setup + * @param options additional options to verify totp setup + * @return An Rx {@link Completable} which completes upon successfully verifying totp code; + * emits an {@link AuthException} otherwise + */ + Completable verifyTOTPSetup(@NonNull String code, @NonNull AuthVerifyTOTPSetupOptions options); + } diff --git a/rxbindings/src/test/java/com/amplifyframework/rx/RxAuthBindingTest.java b/rxbindings/src/test/java/com/amplifyframework/rx/RxAuthBindingTest.java index 5191a2b5a4..c301211fec 100644 --- a/rxbindings/src/test/java/com/amplifyframework/rx/RxAuthBindingTest.java +++ b/rxbindings/src/test/java/com/amplifyframework/rx/RxAuthBindingTest.java @@ -28,7 +28,9 @@ import com.amplifyframework.auth.AuthUser; import com.amplifyframework.auth.AuthUserAttribute; import com.amplifyframework.auth.AuthUserAttributeKey; +import com.amplifyframework.auth.TOTPSetupDetails; import com.amplifyframework.auth.options.AuthSignUpOptions; +import com.amplifyframework.auth.options.AuthVerifyTOTPSetupOptions; import com.amplifyframework.auth.result.AuthResetPasswordResult; import com.amplifyframework.auth.result.AuthSignInResult; import com.amplifyframework.auth.result.AuthSignOutResult; @@ -62,6 +64,7 @@ import static com.amplifyframework.rx.Matchers.anyConsumer; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.matches; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -227,7 +230,7 @@ public void testSignInSucceeds() throws InterruptedException { // Arrange a result on the result consumer AuthCodeDeliveryDetails details = new AuthCodeDeliveryDetails(RandomString.string(), DeliveryMedium.EMAIL); AuthSignInStep step = AuthSignInStep.CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE; - AuthNextSignInStep nextStep = new AuthNextSignInStep(step, Collections.emptyMap(), details); + AuthNextSignInStep nextStep = new AuthNextSignInStep(step, Collections.emptyMap(), details, null, null); AuthSignInResult result = new AuthSignInResult(false, nextStep); doAnswer(invocation -> { // 0 = username, 1 = password, 2 = onResult, 3 = onFailure @@ -289,7 +292,7 @@ public void testConfirmSignInSucceeds() throws InterruptedException { // Arrange a successful result. AuthSignInStep step = AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE; AuthCodeDeliveryDetails details = new AuthCodeDeliveryDetails(RandomString.string(), DeliveryMedium.UNKNOWN); - AuthNextSignInStep nextStep = new AuthNextSignInStep(step, Collections.emptyMap(), details); + AuthNextSignInStep nextStep = new AuthNextSignInStep(step, Collections.emptyMap(), details, null, null); AuthSignInResult expected = new AuthSignInResult(true, nextStep); doAnswer(invocation -> { // 0 = confirm code, 1 = onResult, 2 = onFailure @@ -351,7 +354,7 @@ public void testSignInWithSocialWebUISucceeds() throws InterruptedException { // Arrange a successful result AuthSignInStep step = AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE; AuthCodeDeliveryDetails details = new AuthCodeDeliveryDetails(RandomString.string(), DeliveryMedium.PHONE); - AuthNextSignInStep nextStep = new AuthNextSignInStep(step, Collections.emptyMap(), details); + AuthNextSignInStep nextStep = new AuthNextSignInStep(step, Collections.emptyMap(), details, null, null); AuthSignInResult result = new AuthSignInResult(false, nextStep); doAnswer(invocation -> { // 0 = provider, 1 = activity, 2 = result consumer, 3 = failure consumer @@ -414,7 +417,7 @@ public void testSignInWithWebUISucceeds() throws InterruptedException { // Arrange a result AuthSignInStep step = AuthSignInStep.CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE; AuthCodeDeliveryDetails details = new AuthCodeDeliveryDetails(RandomString.string(), DeliveryMedium.PHONE); - AuthNextSignInStep nextStep = new AuthNextSignInStep(step, Collections.emptyMap(), details); + AuthNextSignInStep nextStep = new AuthNextSignInStep(step, Collections.emptyMap(), details, null, null); AuthSignInResult result = new AuthSignInResult(false, nextStep); doAnswer(invocation -> { // 0 = activity, 1 = result consumer, 2 = failure consumer @@ -1048,10 +1051,10 @@ public void testDeleteUser() throws InterruptedException { onCompletion.call(); return null; }).when(delegate).deleteUser(anyAction(), anyConsumer()); - + // Act: call the binding TestObserver observer = auth.deleteUser().test(); - + // Assert: Completable completes with success observer.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); observer.assertNoErrors() @@ -1073,13 +1076,172 @@ public void testDeleteUserFails() throws InterruptedException { onFailure.accept(failure); return null; }).when(delegate).deleteUser(anyAction(), anyConsumer()); - + // Act: call the binding TestObserver observer = auth.deleteUser().test(); - + // Assert: failure is furnished via Rx Completable. observer.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); observer.assertNotComplete() .assertError(failure); } + + /** + * Tests that a successful request to set up totp will + * propagate a completion back through the binding. + * @throws InterruptedException If test observer is interrupted while awaiting terminal event + */ + @Test + public void testSetUpTOTPSucceeds() throws InterruptedException { + // Arrange an invocation of the success action + doAnswer(invocation -> { + // 0 = onComplete, 1 = onFailure + int positionOfCompletionConsumer = 0; + Consumer onComplete = invocation.getArgument(positionOfCompletionConsumer); + onComplete.accept(new TOTPSetupDetails("ss", "u")); + return null; + }).when(delegate).setUpTOTP(anyConsumer(), anyConsumer()); + + // Act: call the binding + TestObserver observer = auth.setUpTOTP().test(); + + // Assert: Completable completes successfully + observer.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + observer.assertComplete(); + } + + /** + * Validate that a setUp TOTP failure is propagated up through the binding. + * @throws InterruptedException If test observer is interrupted while awaiting terminal event + */ + @Test + public void testSetUpTOTPFails() throws InterruptedException { + AuthException failure = new AuthException("", "", new Exception()); + + // Arrange an invocation of the success action + doAnswer(invocation -> { + // 0 = onComplete, 1 = onFailure + int positionOfCompletionConsumer = 1; + Consumer onError = invocation.getArgument(positionOfCompletionConsumer); + onError.accept(failure); + return null; + }).when(delegate).setUpTOTP(anyConsumer(), anyConsumer()); + + // Act: call the binding + TestObserver observer = auth.setUpTOTP().test(); + + // Assert: Completable completes successfully + observer.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + observer.assertNotComplete() + .assertError(failure); + } + + /** + * Tests that a successful request to verify totp setup will + * propagate a completion back through the binding. + * @throws InterruptedException If test observer is interrupted while awaiting terminal event + */ + @Test + public void testVerifyTOTPSetupSucceeds() throws InterruptedException { + // Arrange an invocation of the success action + String myCode = "123"; + doAnswer(invocation -> { + // 0 = code, 1 = onComplete, 2 = onFailure + int positionOfCompletionConsumer = 1; + Action onComplete = invocation.getArgument(positionOfCompletionConsumer); + onComplete.call(); + return null; + }).when(delegate).verifyTOTPSetup(matches(myCode), anyAction(), anyConsumer()); + + // Act: call the binding + TestObserver observer = auth.verifyTOTPSetup(myCode).test(); + + // Assert: Completable completes successfully + observer.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + observer.assertComplete(); + } + + /** + * Validate that a verify TOTP setup failure is propagated up through the binding. + * @throws InterruptedException If test observer is interrupted while awaiting terminal event + */ + @Test + public void testVerifyTOTPSetupFails() throws InterruptedException { + // Arrange an invocation of the success action + String myCode = "123"; + AuthException failure = new AuthException("", "", new Exception()); + doAnswer(invocation -> { + // 0 = code, 1 = onComplete, 2 = onFailure + int positionOfCompletionConsumer = 2; + Consumer onError = invocation.getArgument(positionOfCompletionConsumer); + onError.accept(failure); + return null; + }).when(delegate).verifyTOTPSetup(matches(myCode), anyAction(), anyConsumer()); + + // Act: call the binding + TestObserver observer = auth.verifyTOTPSetup(myCode).test(); + + // Assert: Completable completes successfully + observer.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + observer.assertNotComplete() + .assertError(failure); + } + + /** + * Tests that a successful request to verify totp setup will + * propagate a completion back through the binding. + * @throws InterruptedException If test observer is interrupted while awaiting terminal event + */ + @Test + public void testVerifyTOTPSetupWithOptionsSucceeds() throws InterruptedException { + // Arrange an invocation of the success action + String myCode = "123"; + AuthVerifyTOTPSetupOptions myOptions = AuthVerifyTOTPSetupOptions.defaults(); + doAnswer(invocation -> { + // 0 = code, 1 = options, 2 = onComplete, 3 = onFailure + int positionOfCompletionConsumer = 2; + Action onComplete = invocation.getArgument(positionOfCompletionConsumer); + onComplete.call(); + return null; + }).when(delegate).verifyTOTPSetup( + matches(myCode), + any(), + anyAction(), + anyConsumer() + ); + + // Act: call the binding + TestObserver observer = auth.verifyTOTPSetup(myCode, myOptions).test(); + + // Assert: Completable completes successfully + observer.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + observer.assertComplete(); + } + + /** + * Validate that a verify TOTP setup failure is propagated up through the binding. + * @throws InterruptedException If test observer is interrupted while awaiting terminal event + */ + @Test + public void testVerifyTOTPSetupWithOptionsFails() throws InterruptedException { + // Arrange an invocation of the success action + String myCode = "123"; + AuthVerifyTOTPSetupOptions myOptions = AuthVerifyTOTPSetupOptions.defaults(); + AuthException failure = new AuthException("", "", new Exception()); + doAnswer(invocation -> { + // 0 = code, 1 = options, 2 = onComplete, 3 = onFailure + int positionOfCompletionConsumer = 3; + Consumer onError = invocation.getArgument(positionOfCompletionConsumer); + onError.accept(failure); + return null; + }).when(delegate).verifyTOTPSetup(matches(myCode), any(), anyAction(), anyConsumer()); + + // Act: call the binding + TestObserver observer = auth.verifyTOTPSetup(myCode, myOptions).test(); + + // Assert: Completable completes successfully + observer.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + observer.assertNotComplete() + .assertError(failure); + } } diff --git a/scripts/pull_backend_config_from_s3 b/scripts/pull_backend_config_from_s3 index c3d2985b63..e95fbcb103 100755 --- a/scripts/pull_backend_config_from_s3 +++ b/scripts/pull_backend_config_from_s3 @@ -53,6 +53,7 @@ readonly config_files=( # Auth "aws-auth-cognito/src/androidTest/res/raw/amplifyconfiguration.json" + "aws-auth-cognito/src/androidTest/res/raw/amplifyconfiguration_totp.json" "aws-auth-cognito/src/androidTest/res/raw/awsconfiguration.json" "aws-auth-cognito/src/androidTest/res/raw/credentials.json" ) diff --git a/testutils/src/main/java/com/amplifyframework/testutils/sync/SynchronousAuth.java b/testutils/src/main/java/com/amplifyframework/testutils/sync/SynchronousAuth.java index 442ee71436..a4a242eeb7 100644 --- a/testutils/src/main/java/com/amplifyframework/testutils/sync/SynchronousAuth.java +++ b/testutils/src/main/java/com/amplifyframework/testutils/sync/SynchronousAuth.java @@ -29,6 +29,7 @@ import com.amplifyframework.auth.AuthPlugin; import com.amplifyframework.auth.AuthProvider; import com.amplifyframework.auth.AuthSession; +import com.amplifyframework.auth.AuthUser; import com.amplifyframework.auth.AuthUserAttribute; import com.amplifyframework.auth.AuthUserAttributeKey; import com.amplifyframework.auth.options.AuthConfirmResetPasswordOptions; @@ -599,4 +600,15 @@ public void deleteUser() throws AuthException { asyncDelegate.deleteUser(() -> onResult.accept(VoidResult.instance()), onError) ); } + + /** + * Get the current signed in user. + * @return current authenticated user + * @throws AuthException exception + */ + public AuthUser getCurrentUser() throws AuthException { + return Await.result(AUTH_OPERATION_TIMEOUT_MS, (onResult, onError) -> + asyncDelegate.getCurrentUser(onResult, onError) + ); + } }