diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorState.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorState.kt index c01445e1..3893a160 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorState.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorState.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import com.amplifyframework.ui.authenticator.data.AuthenticationFlow import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep @@ -29,6 +30,7 @@ import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep import com.amplifyframework.ui.authenticator.forms.SignUpFormBuilder import com.amplifyframework.ui.authenticator.options.TotpOptions import com.amplifyframework.ui.authenticator.util.AuthenticatorMessage +import com.amplifyframework.ui.authenticator.util.findActivity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -52,6 +54,8 @@ fun rememberAuthenticatorState( ): AuthenticatorState { val viewModel = viewModel() val scope = rememberCoroutineScope() + val context = LocalContext.current + return remember { val configuration = AuthenticatorConfiguration( initialStep = initialStep, @@ -60,7 +64,7 @@ fun rememberAuthenticatorState( authenticationFlow = authenticationFlow ) - viewModel.start(configuration) + viewModel.start(configuration, context.findActivity()) AuthenticatorStateImpl(viewModel).also { state -> viewModel.stepState.onEach { state.stepState = it }.launchIn(scope) } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt index ea1fc57b..9f575bb0 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt @@ -40,6 +40,17 @@ interface AuthenticatorStepState { val step: AuthenticatorStep } +/** + * A state holder for the UI that has multiple possible actions that may be in progress. + */ +@Stable +interface AuthenticatorActionState { + /** + * The action in progress, or null if state is idle + */ + val action: T? +} + /** * The Authenticator is loading the current state of the user's Auth session. */ @@ -99,7 +110,17 @@ interface SignInState : AuthenticatorStepState { * The user has entered their username and must select the authentication factor they'd like to use to sign in */ @Stable -interface SignInSelectAuthFactorState : AuthenticatorStepState { +interface SignInSelectAuthFactorState : + AuthenticatorStepState, + AuthenticatorActionState { + + sealed interface Action { + /** + * User has selected an auth factor + */ + data class SelectFactor(val factor: AuthFactor) : Action + } + /** * The input form state holder for this step. */ @@ -115,11 +136,6 @@ interface SignInSelectAuthFactorState : AuthenticatorStepState { */ val availableAuthFactors: Set - /** - * The factor the user selected and is currently being processed - */ - val selectedFactor: AuthFactor? - /** * Move the user to a different [AuthenticatorInitialStep]. */ @@ -530,7 +546,21 @@ interface VerifyUserConfirmState : AuthenticatorStepState { * via biometrics */ @Stable -interface PasskeyCreationPromptState : AuthenticatorStepState { +interface PasskeyCreationPromptState : + AuthenticatorStepState, + AuthenticatorActionState { + sealed interface Action { + /** + * User is creating a passkey + */ + class CreatePasskey : Action + + /** + * User has selected the Skip button + */ + class Skip : Action + } + /** * Create a passkey */ @@ -546,7 +576,16 @@ interface PasskeyCreationPromptState : AuthenticatorStepState { * The user is being shown a confirmation screen after creating a passkey */ @Stable -interface PasskeyCreatedState : AuthenticatorStepState { +interface PasskeyCreatedState : + AuthenticatorStepState, + AuthenticatorActionState { + sealed interface Action { + /** + * User has selected the Done button + */ + class Done : Action + } + /** * A list of existing passkeys for this user, including the one they've just created */ diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt index 1365ad05..831bd446 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt @@ -19,6 +19,7 @@ import android.app.Activity import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.amplifyframework.AmplifyException import com.amplifyframework.auth.AuthChannelEventName import com.amplifyframework.auth.AuthException import com.amplifyframework.auth.AuthUser @@ -34,6 +35,7 @@ import com.amplifyframework.auth.cognito.exceptions.service.InvalidParameterExce import com.amplifyframework.auth.cognito.exceptions.service.InvalidPasswordException import com.amplifyframework.auth.cognito.exceptions.service.LimitExceededException import com.amplifyframework.auth.cognito.exceptions.service.PasswordResetRequiredException +import com.amplifyframework.auth.cognito.exceptions.service.UserCancelledException import com.amplifyframework.auth.cognito.exceptions.service.UserNotConfirmedException import com.amplifyframework.auth.cognito.exceptions.service.UserNotFoundException import com.amplifyframework.auth.cognito.exceptions.service.UsernameExistsException @@ -87,12 +89,15 @@ import com.amplifyframework.ui.authenticator.util.InvalidLoginMessage import com.amplifyframework.ui.authenticator.util.LimitExceededMessage import com.amplifyframework.ui.authenticator.util.MissingConfigurationException import com.amplifyframework.ui.authenticator.util.NetworkErrorMessage +import com.amplifyframework.ui.authenticator.util.PasskeyCreationFailedMessage +import com.amplifyframework.ui.authenticator.util.PasskeyPromptCheck import com.amplifyframework.ui.authenticator.util.PasswordResetMessage import com.amplifyframework.ui.authenticator.util.RealAuthProvider import com.amplifyframework.ui.authenticator.util.UnableToResetPasswordMessage import com.amplifyframework.ui.authenticator.util.UnknownErrorMessage import com.amplifyframework.ui.authenticator.util.authFlow import com.amplifyframework.ui.authenticator.util.callingActivity +import com.amplifyframework.ui.authenticator.util.getOrDefault import com.amplifyframework.ui.authenticator.util.isAuthFlowSessionExpiredError import com.amplifyframework.ui.authenticator.util.isConnectivityIssue import com.amplifyframework.ui.authenticator.util.preferredFirstFactor @@ -107,8 +112,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.annotations.VisibleForTesting -internal class AuthenticatorViewModel(application: Application, private val authProvider: AuthProvider) : - AndroidViewModel(application) { +internal class AuthenticatorViewModel( + application: Application, + private val authProvider: AuthProvider, + private val passkeyCheck: PasskeyPromptCheck = PasskeyPromptCheck(authProvider) +) : AndroidViewModel(application) { // Constructor for compose viewModels provider constructor(application: Application) : this(application, RealAuthProvider()) @@ -140,13 +148,14 @@ internal class AuthenticatorViewModel(application: Application, private val auth // The current activity is used for WebAuthn sign-in when using passwordless functionality private var activityReference: WeakReference = WeakReference(null) - var activity: Activity? + private var activity: Activity? get() = activityReference.get() set(value) { activityReference = WeakReference(value) } - fun start(configuration: AuthenticatorConfiguration) { + fun start(configuration: AuthenticatorConfiguration, activity: Activity?) { + this.activity = activity if (::configuration.isInitialized) { return } @@ -216,7 +225,7 @@ internal class AuthenticatorViewModel(application: Application, private val auth suspend fun signUp(username: String, password: String?, attributes: List) { viewModelScope.launch { val options = AuthSignUpOptions.builder().userAttributes(attributes).build() - val info = UserInfo(username = username, password = password, signInSource = SignInSource.SignUp) + val info = UserInfo(username = username, password = password, signInSource = SignInSource.AutoSignIn) when (val result = authProvider.signUp(username, password, options)) { is AmplifyResult.Error -> handleSignUpFailure(result.error) @@ -350,6 +359,7 @@ internal class AuthenticatorViewModel(application: Application, private val auth // UserNotConfirmed and PasswordResetRequired are special cases where we need // to enter different flows when (error) { + is UserCancelledException -> Unit // This is an expected error, user can simply retry is UserNotConfirmedException -> handleUnconfirmedSignIn(info) is PasswordResetRequiredException -> handleResetRequiredSignIn(info.username) is NotAuthorizedException -> sendMessage(InvalidLoginMessage(error)) @@ -471,7 +481,7 @@ internal class AuthenticatorViewModel(application: Application, private val auth private suspend fun handleSignInSuccess(info: UserInfo, result: AuthSignInResult) { when (val nextStep = result.nextStep.signInStep) { - AuthSignInStep.DONE -> checkVerificationMechanisms() + AuthSignInStep.DONE -> checkForPasskeyPrompt(info) AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE, AuthSignInStep.CONFIRM_SIGN_IN_WITH_OTP -> moveTo( stateFactory.newSignInMfaState( @@ -537,6 +547,53 @@ internal class AuthenticatorViewModel(application: Application, private val auth } } + private suspend fun checkForPasskeyPrompt(info: UserInfo) { + if (passkeyCheck.shouldPromptForPasskey(userInfo = info, config = configuration)) { + moveTo( + stateFactory.newPasskeyPromptState( + onSubmit = { + val activityRef = activity + if (activityRef == null) { + // This shouldn't happen, it indicates a bug. If it does the user can retry or choose to + // skip + sendMessage( + UnknownErrorMessage( + AuthException( + message = "Missing activity reference", + recoverySuggestion = AmplifyException.REPORT_BUG_TO_AWS_SUGGESTION + ) + ) + ) + } else { + createPasskey(activityRef) + } + }, + onSkip = ::checkVerificationMechanisms + ) + ) + } else { + checkVerificationMechanisms() + } + } + + private suspend fun createPasskey(activityRef: Activity) { + when (val result = authProvider.createPasskey(activityRef)) { + is AmplifyResult.Error -> when (result.error) { + is UserCancelledException -> Unit // This is expected, user can retry or skip + else -> sendMessage(PasskeyCreationFailedMessage(result.error)) // User can retry/skip + } + is AmplifyResult.Success -> { + val passkeys = authProvider.getPasskeys().getOrDefault { emptyList() } + moveTo( + stateFactory.newPasskeyCreatedState( + passkeys = passkeys, + onDone = ::checkVerificationMechanisms + ) + ) + } + } + } + private suspend fun checkVerificationMechanisms() { val mechanisms = authConfiguration.verificationMechanisms if (mechanisms.isEmpty()) { diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/SignInSource.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/SignInSource.kt index f29b16cd..7cfad434 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/SignInSource.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/SignInSource.kt @@ -5,8 +5,5 @@ internal enum class SignInSource { SignIn, // Automatic sign in after completing sign up - SignUp, - - // Signed in outside of Authenticator - External + AutoSignIn } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/MutableActionState.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/MutableActionState.kt new file mode 100644 index 00000000..b31d9e91 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/MutableActionState.kt @@ -0,0 +1,13 @@ +package com.amplifyframework.ui.authenticator.states + +import com.amplifyframework.ui.authenticator.AuthenticatorActionState + +internal interface MutableActionState : AuthenticatorActionState { + override var action: T? +} + +internal inline fun MutableActionState.withAction(action: T, func: () -> Unit) { + this.action = action + func() + this.action = null +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreatedStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreatedStateImpl.kt index e7df13e2..8e938b60 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreatedStateImpl.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreatedStateImpl.kt @@ -1,5 +1,8 @@ package com.amplifyframework.ui.authenticator.states +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.amplifyframework.auth.result.AuthWebAuthnCredential import com.amplifyframework.ui.authenticator.PasskeyCreatedState import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep @@ -7,8 +10,13 @@ import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep internal class PasskeyCreatedStateImpl( override val passkeys: List, private val onDone: suspend () -> Unit -) : PasskeyCreatedState { +) : PasskeyCreatedState, + MutableActionState { override val step: AuthenticatorStep = AuthenticatorStep.PasskeyCreated - override suspend fun done() = onDone() + override var action: PasskeyCreatedState.Action? by mutableStateOf(null) + + override suspend fun done() = withAction(PasskeyCreatedState.Action.Done()) { + onDone() + } } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreationPromptStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreationPromptStateImpl.kt index 43bc4ff8..f6f89329 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreationPromptStateImpl.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreationPromptStateImpl.kt @@ -1,21 +1,28 @@ package com.amplifyframework.ui.authenticator.states +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState +import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState.Action import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock class PasskeyCreationPromptStateImpl(private val onSubmit: suspend () -> Unit, private val onSkip: suspend () -> Unit) : - PasskeyCreationPromptState { + PasskeyCreationPromptState, + MutableActionState { private val mutex = Mutex() - override suspend fun createPasskey() { + override val step = AuthenticatorStep.PasskeyCreationPrompt + + override var action: Action? by mutableStateOf(null) + + override suspend fun createPasskey() = withAction(Action.CreatePasskey()) { mutex.withLock { onSubmit() } } - override suspend fun skip() = onSkip() - - override val step = AuthenticatorStep.PasskeyCreationPrompt + override suspend fun skip() = withAction(Action.Skip()) { onSkip() } } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInSelectAuthFactorStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInSelectAuthFactorStateImpl.kt index efc59283..6cf56dcb 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInSelectAuthFactorStateImpl.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInSelectAuthFactorStateImpl.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState +import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState.Action import com.amplifyframework.ui.authenticator.auth.SignInMethod import com.amplifyframework.ui.authenticator.data.AuthFactor import com.amplifyframework.ui.authenticator.data.containsPassword @@ -17,10 +18,11 @@ internal class SignInSelectAuthFactorStateImpl( private val onSubmit: suspend (authFactor: AuthFactor) -> Unit, private val onMoveTo: (step: AuthenticatorInitialStep) -> Unit ) : BaseStateImpl(), - SignInSelectAuthFactorState { + SignInSelectAuthFactorState, + MutableActionState { override val step: AuthenticatorStep = AuthenticatorStep.SignInSelectAuthFactor - override var selectedFactor: AuthFactor? by mutableStateOf(null) + override var action: Action? by mutableStateOf(null) init { if (availableAuthFactors.containsPassword()) { @@ -30,15 +32,13 @@ internal class SignInSelectAuthFactorStateImpl( override fun moveTo(step: AuthenticatorInitialStep) = onMoveTo(step) - override suspend fun select(authFactor: AuthFactor) { + override suspend fun select(authFactor: AuthFactor) = withAction(Action.SelectFactor(authFactor)) { // Clear errors form.fields.values.forEach { it.state.error = null } - selectedFactor = authFactor form.enabled = false onSubmit(authFactor) form.enabled = true - selectedFactor = null } } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/StepStateFactory.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/StepStateFactory.kt index f5f81600..2010d011 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/StepStateFactory.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/StepStateFactory.kt @@ -21,6 +21,7 @@ import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.AuthUserAttributeKey import com.amplifyframework.auth.MFAType import com.amplifyframework.auth.result.AuthSignOutResult +import com.amplifyframework.auth.result.AuthWebAuthnCredential import com.amplifyframework.ui.authenticator.AuthenticatorConfiguration import com.amplifyframework.ui.authenticator.auth.AmplifyAuthConfiguration import com.amplifyframework.ui.authenticator.data.AuthFactor @@ -196,4 +197,16 @@ internal class StepStateFactory( onResendCode = onResendCode, onSkip = onSkip ) + + fun newPasskeyPromptState(onSubmit: suspend () -> Unit, onSkip: suspend () -> Unit) = + PasskeyCreationPromptStateImpl( + onSubmit = onSubmit, + onSkip = onSkip + ) + + fun newPasskeyCreatedState(passkeys: List, onDone: suspend () -> Unit) = + PasskeyCreatedStateImpl( + passkeys = passkeys, + onDone = onDone + ) } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorField.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorField.kt index cf9554fb..18bd2854 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorField.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorField.kt @@ -23,6 +23,8 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import com.amplifyframework.ui.authenticator.forms.FieldConfig @@ -79,13 +81,16 @@ internal fun AuthenticatorFieldError( error: FieldError?, modifier: Modifier = Modifier ) { + var lastError by remember { mutableStateOf(null) } + if (error != null) lastError = error + AnimatedVisibility( modifier = modifier, visible = error != null, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically() ) { - val text = error?.let { StringResolver.error(config = fieldConfig, error = it) } ?: "" + val text = lastError?.let { StringResolver.error(config = fieldConfig, error = it) } ?: "" Text(text = text) } } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreated.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreated.kt index 349f4409..9ab3fe79 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreated.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreated.kt @@ -12,11 +12,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -24,6 +20,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.amplifyframework.auth.result.AuthWebAuthnCredential import com.amplifyframework.ui.authenticator.PasskeyCreatedState +import com.amplifyframework.ui.authenticator.PasskeyCreatedState.Action import com.amplifyframework.ui.authenticator.R import kotlinx.coroutines.launch @@ -71,18 +68,11 @@ fun PasskeyCreated( Spacer(modifier = Modifier.size(16.dp)) } - var enabled by remember { mutableStateOf(true) } AuthenticatorButton( - onClick = { - scope.launch { - enabled = false - state.done() - enabled = true - } - }, - loading = !enabled, - label = stringResource(R.string.amplify_ui_authenticator_button_continue), - modifier = Modifier.testTag(TestTags.ContinueButton) + modifier = Modifier.testTag(TestTags.ContinueButton), + onClick = { scope.launch { state.done() } }, + loading = state.action is Action.Done, + label = stringResource(R.string.amplify_ui_authenticator_button_continue) ) footerContent(state) diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPrompt.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPrompt.kt index bf4a23a9..2bd59cc9 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPrompt.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPrompt.kt @@ -8,25 +8,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState +import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState.Action import com.amplifyframework.ui.authenticator.R import kotlinx.coroutines.launch -private enum class Action { - CreatingPasskey, - Skipping -} - @Composable fun PasskeyPrompt( state: PasskeyCreationPromptState, @@ -37,8 +29,7 @@ fun PasskeyPrompt( footerContent: @Composable (PasskeyCreationPromptState) -> Unit = {} ) { val scope = rememberCoroutineScope() - - var inProgress by remember { mutableStateOf(null) } + val action = state.action Column( modifier = modifier @@ -61,14 +52,10 @@ fun PasskeyPrompt( AuthenticatorButton( onClick = { - scope.launch { - inProgress = Action.CreatingPasskey - state.createPasskey() - inProgress = null - } + scope.launch { state.createPasskey() } }, - loading = inProgress == Action.CreatingPasskey, - enabled = inProgress == null, + loading = action is Action.CreatePasskey, + enabled = action == null, label = stringResource(R.string.amplify_ui_authenticator_button_create_passkey), modifier = Modifier.testTag(TestTags.CreatePasskeyButton) ) @@ -76,14 +63,10 @@ fun PasskeyPrompt( AuthenticatorButton( modifier = Modifier.fillMaxWidth().testTag(TestTags.SkipPasskeyButton), onClick = { - scope.launch { - inProgress = Action.Skipping - state.skip() - inProgress = null - } + scope.launch { state.skip() } }, - loading = inProgress == Action.Skipping, - enabled = inProgress == null, + loading = action is Action.Skip, + enabled = action == null, label = stringResource(R.string.amplify_ui_authenticator_button_skip_passkey), style = ButtonStyle.Secondary ) diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactor.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactor.kt index f5695164..c9f2c9d2 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactor.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactor.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.amplifyframework.ui.authenticator.R import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState +import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState.Action import com.amplifyframework.ui.authenticator.auth.toFieldKey import com.amplifyframework.ui.authenticator.data.AuthFactor import com.amplifyframework.ui.authenticator.data.containsPassword @@ -92,11 +93,12 @@ private fun AuthFactorButton( state: SignInSelectAuthFactorState, modifier: Modifier = Modifier ) { + val action = state.action val scope = rememberCoroutineScope() AuthenticatorButton( onClick = { scope.launch { state.select(authFactor) } }, - loading = state.selectedFactor == authFactor, - enabled = state.selectedFactor == null, + loading = action is Action.SelectFactor && action.factor == authFactor, + enabled = action == null, label = stringResource(authFactor.signInResourceId), modifier = modifier.testTag(authFactor.testTag) ) diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AmplifyResult.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AmplifyResult.kt new file mode 100644 index 00000000..fa1acddc --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AmplifyResult.kt @@ -0,0 +1,13 @@ +package com.amplifyframework.ui.authenticator.util + +import com.amplifyframework.auth.AuthException + +internal sealed interface AmplifyResult { + data class Success(val data: T) : AmplifyResult + data class Error(val error: AuthException) : AmplifyResult +} + +internal inline fun AmplifyResult.getOrDefault(crossinline provider: () -> T) = when (this) { + is AmplifyResult.Error -> provider() + is AmplifyResult.Success -> this.data +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthProvider.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthProvider.kt index c157d086..be6b80e1 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthProvider.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthProvider.kt @@ -15,10 +15,10 @@ package com.amplifyframework.ui.authenticator.util +import android.app.Activity import com.amplifyframework.auth.AWSCognitoAuthMetadataType import com.amplifyframework.auth.AuthChannelEventName import com.amplifyframework.auth.AuthCodeDeliveryDetails -import com.amplifyframework.auth.AuthException import com.amplifyframework.auth.AuthSession import com.amplifyframework.auth.AuthUser import com.amplifyframework.auth.AuthUserAttribute @@ -34,6 +34,7 @@ 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.AuthWebAuthnCredential import com.amplifyframework.core.Amplify import com.amplifyframework.hub.HubChannel import com.amplifyframework.hub.HubEvent @@ -81,6 +82,10 @@ internal interface AuthProvider { suspend fun fetchAuthSession(): AmplifyResult + suspend fun createPasskey(activity: Activity): AmplifyResult + + suspend fun getPasskeys(): AmplifyResult> + suspend fun fetchUserAttributes(): AmplifyResult> suspend fun confirmUserAttribute(key: AuthUserAttributeKey, confirmationCode: String): AmplifyResult @@ -197,6 +202,21 @@ internal class RealAuthProvider : AuthProvider { ) } + override suspend fun createPasskey(activity: Activity) = suspendCoroutine { continuation -> + Amplify.Auth.associateWebAuthnCredential( + activity, + { continuation.resume(AmplifyResult.Success(Unit)) }, + { continuation.resume(AmplifyResult.Error(it)) } + ) + } + + override suspend fun getPasskeys(): AmplifyResult> = suspendCoroutine { continuation -> + Amplify.Auth.listWebAuthnCredentials( + { continuation.resume(AmplifyResult.Success(it.credentials)) }, + { continuation.resume(AmplifyResult.Error(it)) } + ) + } + override suspend fun fetchUserAttributes() = suspendCoroutine { continuation -> Amplify.Auth.fetchUserAttributes( { continuation.resume(AmplifyResult.Success(it)) }, @@ -286,8 +306,3 @@ internal class RealAuthProvider : AuthProvider { requiresLower = requiresLower ) } - -internal sealed interface AmplifyResult { - data class Success(val data: T) : AmplifyResult - data class Error(val error: AuthException) : AmplifyResult -} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorMessage.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorMessage.kt index 8a823ff9..43987acc 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorMessage.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorMessage.kt @@ -134,3 +134,10 @@ internal class LimitExceededMessage(override val cause: AuthException) : internal class AuthFlowSessionExpiredMessage(override val cause: AuthException) : AuthenticatorMessageImpl(R.string.amplify_ui_authenticator_error_session_expired), AuthenticatorMessage.Error + +/** + * The passkey creation failed + */ +internal class PasskeyCreationFailedMessage(override val cause: AuthException) : + AuthenticatorMessageImpl(R.string.amplify_ui_authenticator_error_passkey_creation), + AuthenticatorMessage.Error diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/ContextExtensions.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/ContextExtensions.kt new file mode 100644 index 00000000..a0bba44f --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/ContextExtensions.kt @@ -0,0 +1,14 @@ +package com.amplifyframework.ui.authenticator.util + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +/** + * Allows us to get the Activity reference from Compose LocalContext + */ +internal tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> this.baseContext.findActivity() + else -> null +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/OsBuild.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/OsBuild.kt new file mode 100644 index 00000000..4f9c00d4 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/OsBuild.kt @@ -0,0 +1,9 @@ +package com.amplifyframework.ui.authenticator.util + +import android.os.Build + +// Facade for android.os.Build to facilitate testing +internal class OsBuild { + val sdkInt: Int + get() = Build.VERSION.SDK_INT +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheck.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheck.kt new file mode 100644 index 00000000..09dbc09e --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheck.kt @@ -0,0 +1,36 @@ +package com.amplifyframework.ui.authenticator.util + +import com.amplifyframework.ui.authenticator.AuthenticatorConfiguration +import com.amplifyframework.ui.authenticator.data.AuthenticationFlow +import com.amplifyframework.ui.authenticator.data.PasskeyPrompt +import com.amplifyframework.ui.authenticator.data.UserInfo +import com.amplifyframework.ui.authenticator.enums.SignInSource + +// Utility class for checking whether a user should be shown a passkey prompt +internal class PasskeyPromptCheck(private val authProvider: AuthProvider, private val osBuild: OsBuild = OsBuild()) { + suspend fun shouldPromptForPasskey(userInfo: UserInfo, config: AuthenticatorConfiguration): Boolean { + // Ensure that userHasPasskey is the last check so that the network request can be short-circuited by + // the local-only checks. + val authFlow = config.authenticationFlow + return authFlow is AuthenticationFlow.UserChoice && + deviceSupportsPasskeyCreation() && + passkeyPromptsEnabled(userInfo, authFlow) && + !userHasPasskey() + } + + // Passkey creation supported starting with API 28 + private fun deviceSupportsPasskeyCreation() = osBuild.sdkInt >= 28 + + // Check whether passkey prompts are enabled by configuration + private fun passkeyPromptsEnabled(userInfo: UserInfo, authFlow: AuthenticationFlow.UserChoice): Boolean = + when (userInfo.signInSource) { + SignInSource.SignIn -> authFlow.passkeyPrompts.afterSignIn == PasskeyPrompt.Always + SignInSource.AutoSignIn -> authFlow.passkeyPrompts.afterSignUp == PasskeyPrompt.Always + } + + // Check if the user already has a passkey registered + private suspend fun userHasPasskey() = when (val result = authProvider.getPasskeys()) { + is AmplifyResult.Error -> true // Assume user already has passkey on error so we don't incorrectly prompt them + is AmplifyResult.Success -> result.data.isNotEmpty() + } +} diff --git a/authenticator/src/main/res/values/errors.xml b/authenticator/src/main/res/values/errors.xml index 772529ea..1780d289 100644 --- a/authenticator/src/main/res/values/errors.xml +++ b/authenticator/src/main/res/values/errors.xml @@ -16,6 +16,8 @@ Username or Password is incorrect + + Passkey creation failed User password cannot be reset in the current state Could not send verification code diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt index 247f7c91..7d677495 100644 --- a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt @@ -32,6 +32,7 @@ import com.amplifyframework.auth.result.step.AuthSignInStep import com.amplifyframework.auth.result.step.AuthSignUpStep import com.amplifyframework.hub.HubEvent import com.amplifyframework.ui.authenticator.auth.VerificationMechanism +import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep import com.amplifyframework.ui.authenticator.util.AmplifyResult import com.amplifyframework.ui.authenticator.util.AmplifyResult.Error @@ -85,8 +86,8 @@ class AuthenticatorViewModelTest { @Test fun `start only executes once`() = runTest { - viewModel.start(mockAuthenticatorConfiguration()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() + viewModel.start() advanceUntilIdle() // fetchAuthSession only called by the first start @@ -99,7 +100,7 @@ class AuthenticatorViewModelTest { fun `missing configuration results in an error`() = runTest { coEvery { authProvider.getConfiguration() } returns AuthConfigurationResult.Missing - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 0) { authProvider.fetchAuthSession() } @@ -110,7 +111,7 @@ class AuthenticatorViewModelTest { fun `invalid configuration results in an error`() = runTest { coEvery { authProvider.getConfiguration() } returns AuthConfigurationResult.Invalid("Invalid") - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 0) { authProvider.fetchAuthSession() } @@ -121,7 +122,7 @@ class AuthenticatorViewModelTest { fun `fetchAuthSession error during start results in an error`() = runTest { coEvery { authProvider.fetchAuthSession() } returns AmplifyResult.Error(mockAuthException()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 1) { authProvider.fetchAuthSession() } @@ -133,7 +134,7 @@ class AuthenticatorViewModelTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true)) coEvery { authProvider.getCurrentUser() } returns AmplifyResult.Error(mockAuthException()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 1) { @@ -148,7 +149,7 @@ class AuthenticatorViewModelTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true)) coEvery { authProvider.getCurrentUser() } returns AmplifyResult.Error(SessionExpiredException()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 1) { @@ -163,7 +164,7 @@ class AuthenticatorViewModelTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true)) coEvery { authProvider.getCurrentUser() } returns Success(mockAuthUser()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 1) { @@ -177,7 +178,7 @@ class AuthenticatorViewModelTest { fun `initial step is SignIn`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() advanceUntilIdle() viewModel.currentStep shouldBe AuthenticatorStep.SignIn @@ -196,7 +197,7 @@ class AuthenticatorViewModelTest { ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.Error @@ -212,7 +213,7 @@ class AuthenticatorViewModelTest { ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInContinueWithTotpSetup @@ -225,7 +226,7 @@ class AuthenticatorViewModelTest { mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_TOTP_CODE) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInConfirmTotpCode @@ -238,7 +239,7 @@ class AuthenticatorViewModelTest { mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInConfirmMfa @@ -251,7 +252,7 @@ class AuthenticatorViewModelTest { mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInConfirmCustomAuth @@ -264,7 +265,7 @@ class AuthenticatorViewModelTest { mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_NEW_PASSWORD) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInConfirmNewPassword @@ -276,9 +277,9 @@ class AuthenticatorViewModelTest { coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_UP) ) - coEvery { authProvider.resendSignUpCode(any()) } returns AmplifyResult.Error(mockAuthException()) + coEvery { authProvider.resendSignUpCode(any()) } returns Error(mockAuthException()) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignIn @@ -292,7 +293,7 @@ class AuthenticatorViewModelTest { ) coEvery { authProvider.resendSignUpCode(any()) } returns Success(mockk()) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignUpConfirm @@ -308,7 +309,7 @@ class AuthenticatorViewModelTest { ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.Error @@ -324,7 +325,7 @@ class AuthenticatorViewModelTest { ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.Error @@ -340,7 +341,7 @@ class AuthenticatorViewModelTest { ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInContinueWithMfaSelection @@ -357,7 +358,7 @@ class AuthenticatorViewModelTest { mockUserAttributes(email() to "email", emailVerified() to "false") ) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.VerifyUser @@ -374,7 +375,7 @@ class AuthenticatorViewModelTest { mockUserAttributes(email() to "email", emailVerified() to "true") // email is already verified ) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignedIn @@ -391,7 +392,7 @@ class AuthenticatorViewModelTest { mockUserAttributes(email() to "email", emailVerified() to "false") ) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignedIn @@ -405,9 +406,9 @@ class AuthenticatorViewModelTest { verificationMechanisms = setOf(VerificationMechanism.Email) ) // cannot fetch user attributes - coEvery { authProvider.fetchUserAttributes() } returns AmplifyResult.Error(mockk(relaxed = true)) + coEvery { authProvider.fetchUserAttributes() } returns Error(mockk(relaxed = true)) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignedIn @@ -422,7 +423,7 @@ class AuthenticatorViewModelTest { ) coEvery { authProvider.fetchUserAttributes() } returns Success(mockUserAttributes()) // no email attribute - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignedIn @@ -441,7 +442,7 @@ class AuthenticatorViewModelTest { } ) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.shouldEmitMessage { viewModel.signIn("username", "password") @@ -455,7 +456,7 @@ class AuthenticatorViewModelTest { fun `moves to SignedInState when receiving SignedIn event`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() runCurrent() viewModel.currentStep shouldBe AuthenticatorStep.SignIn @@ -471,7 +472,7 @@ class AuthenticatorViewModelTest { Success(mockSignInResult()) } - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() runCurrent() viewModel.currentStep shouldBe AuthenticatorStep.SignIn @@ -497,7 +498,7 @@ class AuthenticatorViewModelTest { mockUserAttributes(email() to "email", emailVerified() to "false") ) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.VerifyUser @@ -514,7 +515,7 @@ class AuthenticatorViewModelTest { coEvery { authProvider.signUp("username", "password", any()) } returns Success(result) coEvery { authProvider.autoSignIn() } returns Success(mockSignInResult()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signUp("username", "password", emptyList()) advanceUntilIdle() @@ -533,7 +534,7 @@ class AuthenticatorViewModelTest { ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.PasswordReset @@ -548,7 +549,7 @@ class AuthenticatorViewModelTest { AuthNextResetPasswordStep(AuthResetPasswordStep.DONE, emptyMap(), null) ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.resetPassword("username") viewModel.currentStep shouldBe AuthenticatorStep.SignIn @@ -564,7 +565,7 @@ class AuthenticatorViewModelTest { } } ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.resetPassword("username") viewModel.currentStep shouldBe AuthenticatorStep.PasswordReset @@ -582,7 +583,7 @@ class AuthenticatorViewModelTest { coEvery { authProvider.confirmResetPassword(any(), any(), any()) } returns Success(Unit) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.resetPassword("username") viewModel.confirmResetPassword("username", "password", "code") @@ -607,7 +608,7 @@ class AuthenticatorViewModelTest { } ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.resetPassword("username") viewModel.confirmResetPassword("username", "password", "code") @@ -633,7 +634,7 @@ class AuthenticatorViewModelTest { } ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.resetPassword("username") viewModel.confirmResetPassword("username", "password", "code") @@ -645,7 +646,7 @@ class AuthenticatorViewModelTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) coEvery { authProvider.resetPassword(any()) } returns Error(LimitExceededException(null)) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.shouldEmitMessage { viewModel.resetPassword("username") @@ -656,5 +657,10 @@ class AuthenticatorViewModelTest { //region helpers private val AuthenticatorViewModel.currentStep: AuthenticatorStep get() = stepState.value.step + + private fun AuthenticatorViewModel.start(step: AuthenticatorInitialStep = AuthenticatorStep.SignIn) = start( + configuration = mockAuthenticatorConfiguration(initialStep = step), + activity = null + ) //endregion } diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt index 2e1ae9a1..850ff6ea 100644 --- a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt @@ -19,6 +19,8 @@ import com.amplifyframework.auth.AuthCodeDeliveryDetails import com.amplifyframework.auth.AuthUserAttributeKey import com.amplifyframework.auth.MFAType import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.PasskeyCreatedState +import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState import com.amplifyframework.ui.authenticator.auth.PasswordCriteria import com.amplifyframework.ui.authenticator.auth.SignInMethod import com.amplifyframework.ui.authenticator.data.AuthFactor @@ -135,17 +137,21 @@ internal fun mockSignInContinueWithMfaSetupSelectionState( internal fun mockPasskeyCreatedState( passkeys: List = emptyList(), - onDone: suspend () -> Unit = {} + onDone: suspend () -> Unit = {}, + action: PasskeyCreatedState.Action? = null ) = PasskeyCreatedStateImpl( passkeys = passkeys, onDone = onDone -) +).apply { this.action = action } -internal fun mockPasskeyCreationPromptState(onSubmit: suspend () -> Unit = {}, onSkip: suspend () -> Unit = {}) = - PasskeyCreationPromptStateImpl( - onSubmit = onSubmit, - onSkip = onSkip - ) +internal fun mockPasskeyCreationPromptState( + onSubmit: suspend () -> Unit = {}, + onSkip: suspend () -> Unit = {}, + action: PasskeyCreationPromptState.Action? = null +) = PasskeyCreationPromptStateImpl( + onSubmit = onSubmit, + onSkip = onSkip +).apply { this.action = action } internal fun mockSignInConfirmPasswordState( username: String = "testuser", diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreatedTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreatedTest.kt index bd91a9b9..0da31833 100644 --- a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreatedTest.kt +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreatedTest.kt @@ -1,6 +1,7 @@ package com.amplifyframework.ui.authenticator.ui import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.PasskeyCreatedState.Action import com.amplifyframework.ui.authenticator.testUtil.AuthenticatorUiTest import com.amplifyframework.ui.authenticator.testUtil.mockPasskeyCreatedState import com.amplifyframework.ui.authenticator.ui.robots.passkeyCreated @@ -80,4 +81,15 @@ class PasskeyCreatedTest : AuthenticatorUiTest() { PasskeyCreated(state = mockPasskeyCreatedState(passkeys = passkeys)) } } + + @Test + @ScreenshotTest + fun `done selected`() { + val passkey = mockk { + every { friendlyName } returns "Test Passkey" + } + setContent { + PasskeyCreated(state = mockPasskeyCreatedState(passkeys = listOf(passkey), action = Action.Done())) + } + } } diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt index 38001881..c0dedbea 100644 --- a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt @@ -1,5 +1,6 @@ package com.amplifyframework.ui.authenticator.ui +import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState.Action import com.amplifyframework.ui.authenticator.testUtil.AuthenticatorUiTest import com.amplifyframework.ui.authenticator.testUtil.mockPasskeyCreationPromptState import com.amplifyframework.ui.authenticator.ui.robots.passkeyCreationPrompt @@ -71,4 +72,24 @@ class PasskeyCreationPromptTest : AuthenticatorUiTest() { PasskeyPrompt(state = mockPasskeyCreationPromptState()) } } + + @Test + @ScreenshotTest + fun `creating passkey`() { + setContent { + PasskeyPrompt( + state = mockPasskeyCreationPromptState(action = Action.CreatePasskey()) + ) + } + } + + @Test + @ScreenshotTest + fun `skipping passkey creation`() { + setContent { + PasskeyPrompt( + state = mockPasskeyCreationPromptState(action = Action.Skip()) + ) + } + } } diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheckTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheckTest.kt new file mode 100644 index 00000000..8b960a86 --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheckTest.kt @@ -0,0 +1,149 @@ +package com.amplifyframework.ui.authenticator.util + +import com.amplifyframework.auth.exceptions.UnknownException +import com.amplifyframework.ui.authenticator.AuthenticatorConfiguration +import com.amplifyframework.ui.authenticator.data.AuthenticationFlow +import com.amplifyframework.ui.authenticator.data.PasskeyPrompt +import com.amplifyframework.ui.authenticator.data.PasskeyPrompts +import com.amplifyframework.ui.authenticator.data.UserInfo +import com.amplifyframework.ui.authenticator.enums.SignInSource +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PasskeyPromptCheckTest { + + private val authProvider = mockk { + coEvery { getPasskeys() } returns AmplifyResult.Success(emptyList()) + } + private val osBuild = mockk { + every { sdkInt } returns 30 + } + private val passkeyPromptCheck = PasskeyPromptCheck(authProvider, osBuild) + + @Test + fun `shouldPromptForPasskey returns false when auth flow is not UserChoice`() = runTest { + val userInfo = mockUserInfo() + val config = mockAuthenticatorConfiguration(authFlow = AuthenticationFlow.Password) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns false when passkey prompts are disabled for SignIn`() = runTest { + val userInfo = mockUserInfo(source = SignInSource.SignIn) + val config = mockAuthenticatorConfiguration( + authFlow = AuthenticationFlow.UserChoice( + passkeyPrompts = PasskeyPrompts(afterSignIn = PasskeyPrompt.Never) + ) + ) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns false when passkey prompts are disabled for AutoSignIn`() = runTest { + val userInfo = mockUserInfo(source = SignInSource.AutoSignIn) + val config = mockAuthenticatorConfiguration( + authFlow = AuthenticationFlow.UserChoice( + passkeyPrompts = PasskeyPrompts(afterSignUp = PasskeyPrompt.Never) + ) + ) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns false when user already has passkey`() = runTest { + val userInfo = mockUserInfo() + val config = mockAuthenticatorConfiguration() + + coEvery { authProvider.getPasskeys() } returns AmplifyResult.Success(listOf(mockk())) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns false when getPasskeys returns error`() = runTest { + val userInfo = mockUserInfo() + val config = mockAuthenticatorConfiguration() + + coEvery { authProvider.getPasskeys() } returns AmplifyResult.Error( + UnknownException("Network error") + ) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns false when os version is below 28`() = runTest { + val userInfo = mockUserInfo() + val config = mockAuthenticatorConfiguration() + + every { osBuild.sdkInt } returns 27 + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns true when os version is 28`() = runTest { + val userInfo = mockUserInfo() + val config = mockAuthenticatorConfiguration() + + every { osBuild.sdkInt } returns 28 + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeTrue() + } + + @Test + fun `shouldPromptForPasskey returns true for autoSignIn`() = runTest { + val userInfo = mockUserInfo(source = SignInSource.AutoSignIn) + val config = mockAuthenticatorConfiguration( + authFlow = AuthenticationFlow.UserChoice( + passkeyPrompts = PasskeyPrompts( + afterSignIn = PasskeyPrompt.Never, + afterSignUp = PasskeyPrompt.Always + ) + ) + ) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeTrue() + } + + @Test + fun `shouldPromptForPasskey returns true for normal signIn`() = runTest { + val userInfo = mockUserInfo(source = SignInSource.SignIn) + val config = mockAuthenticatorConfiguration( + authFlow = AuthenticationFlow.UserChoice( + passkeyPrompts = PasskeyPrompts( + afterSignIn = PasskeyPrompt.Always, + afterSignUp = PasskeyPrompt.Never + ) + ) + ) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeTrue() + } + + private fun mockUserInfo(source: SignInSource = SignInSource.SignIn) = mockk { + every { signInSource } returns source + } + + private fun mockAuthenticatorConfiguration(authFlow: AuthenticationFlow = AuthenticationFlow.UserChoice()) = + mockk { + every { authenticationFlow } returns authFlow + } +} diff --git a/authenticator/src/test/screenshots/PasskeyCreatedTest_done-selected.png b/authenticator/src/test/screenshots/PasskeyCreatedTest_done-selected.png new file mode 100644 index 00000000..9382b6bf Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreatedTest_done-selected.png differ diff --git a/authenticator/src/test/screenshots/PasskeyCreationPromptTest_creating-passkey.png b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_creating-passkey.png new file mode 100644 index 00000000..6efcecf4 Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_creating-passkey.png differ diff --git a/authenticator/src/test/screenshots/PasskeyCreationPromptTest_skipping-passkey-creation.png b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_skipping-passkey-creation.png new file mode 100644 index 00000000..5c9a9432 Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_skipping-passkey-creation.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_default-state.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_default-state.png index f07df71c..a4bc38db 100644 Binary files a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_default-state.png and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_default-state.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_password-visible.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_password-visible.png index 104df595..5ab509c6 100644 Binary files a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_password-visible.png and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_password-visible.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-email.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-email.png index 2a5102a8..eb1791e9 100644 Binary files a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-email.png and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-email.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-phonenumber.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-phonenumber.png index 2211947f..fdeb4365 100644 Binary files a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-phonenumber.png and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-phonenumber.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit.png index ec72099f..5a27f96b 100644 Binary files a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit.png and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit.png differ diff --git a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-email.png b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-email.png index 02d52420..2ea4c4cd 100644 Binary files a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-email.png and b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-email.png differ diff --git a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-phone-number.png b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-phone-number.png index 4093b162..65297758 100644 Binary files a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-phone-number.png and b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-phone-number.png differ diff --git a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors.png b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors.png index dcda3324..7da1050f 100644 Binary files a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors.png and b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors.png differ