Skip to content

Commit 43a3625

Browse files
authored
feat(authenticator): Hook up passkey prompt (#282)
1 parent 7ec52c3 commit 43a3625

36 files changed

+534
-126
lines changed

authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorState.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ import androidx.compose.runtime.mutableStateOf
2222
import androidx.compose.runtime.remember
2323
import androidx.compose.runtime.rememberCoroutineScope
2424
import androidx.compose.runtime.setValue
25+
import androidx.compose.ui.platform.LocalContext
2526
import androidx.lifecycle.viewmodel.compose.viewModel
2627
import com.amplifyframework.ui.authenticator.data.AuthenticationFlow
2728
import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep
2829
import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep
2930
import com.amplifyframework.ui.authenticator.forms.SignUpFormBuilder
3031
import com.amplifyframework.ui.authenticator.options.TotpOptions
3132
import com.amplifyframework.ui.authenticator.util.AuthenticatorMessage
33+
import com.amplifyframework.ui.authenticator.util.findActivity
3234
import kotlinx.coroutines.flow.Flow
3335
import kotlinx.coroutines.flow.launchIn
3436
import kotlinx.coroutines.flow.onEach
@@ -52,6 +54,8 @@ fun rememberAuthenticatorState(
5254
): AuthenticatorState {
5355
val viewModel = viewModel<AuthenticatorViewModel>()
5456
val scope = rememberCoroutineScope()
57+
val context = LocalContext.current
58+
5559
return remember {
5660
val configuration = AuthenticatorConfiguration(
5761
initialStep = initialStep,
@@ -60,7 +64,7 @@ fun rememberAuthenticatorState(
6064
authenticationFlow = authenticationFlow
6165
)
6266

63-
viewModel.start(configuration)
67+
viewModel.start(configuration, context.findActivity())
6468
AuthenticatorStateImpl(viewModel).also { state ->
6569
viewModel.stepState.onEach { state.stepState = it }.launchIn(scope)
6670
}

authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ interface AuthenticatorStepState {
4040
val step: AuthenticatorStep
4141
}
4242

43+
/**
44+
* A state holder for the UI that has multiple possible actions that may be in progress.
45+
*/
46+
@Stable
47+
interface AuthenticatorActionState<T> {
48+
/**
49+
* The action in progress, or null if state is idle
50+
*/
51+
val action: T?
52+
}
53+
4354
/**
4455
* The Authenticator is loading the current state of the user's Auth session.
4556
*/
@@ -99,7 +110,17 @@ interface SignInState : AuthenticatorStepState {
99110
* The user has entered their username and must select the authentication factor they'd like to use to sign in
100111
*/
101112
@Stable
102-
interface SignInSelectAuthFactorState : AuthenticatorStepState {
113+
interface SignInSelectAuthFactorState :
114+
AuthenticatorStepState,
115+
AuthenticatorActionState<SignInSelectAuthFactorState.Action> {
116+
117+
sealed interface Action {
118+
/**
119+
* User has selected an auth factor
120+
*/
121+
data class SelectFactor(val factor: AuthFactor) : Action
122+
}
123+
103124
/**
104125
* The input form state holder for this step.
105126
*/
@@ -115,11 +136,6 @@ interface SignInSelectAuthFactorState : AuthenticatorStepState {
115136
*/
116137
val availableAuthFactors: Set<AuthFactor>
117138

118-
/**
119-
* The factor the user selected and is currently being processed
120-
*/
121-
val selectedFactor: AuthFactor?
122-
123139
/**
124140
* Move the user to a different [AuthenticatorInitialStep].
125141
*/
@@ -530,7 +546,21 @@ interface VerifyUserConfirmState : AuthenticatorStepState {
530546
* via biometrics
531547
*/
532548
@Stable
533-
interface PasskeyCreationPromptState : AuthenticatorStepState {
549+
interface PasskeyCreationPromptState :
550+
AuthenticatorStepState,
551+
AuthenticatorActionState<PasskeyCreationPromptState.Action> {
552+
sealed interface Action {
553+
/**
554+
* User is creating a passkey
555+
*/
556+
class CreatePasskey : Action
557+
558+
/**
559+
* User has selected the Skip button
560+
*/
561+
class Skip : Action
562+
}
563+
534564
/**
535565
* Create a passkey
536566
*/
@@ -546,7 +576,16 @@ interface PasskeyCreationPromptState : AuthenticatorStepState {
546576
* The user is being shown a confirmation screen after creating a passkey
547577
*/
548578
@Stable
549-
interface PasskeyCreatedState : AuthenticatorStepState {
579+
interface PasskeyCreatedState :
580+
AuthenticatorStepState,
581+
AuthenticatorActionState<PasskeyCreatedState.Action> {
582+
sealed interface Action {
583+
/**
584+
* User has selected the Done button
585+
*/
586+
class Done : Action
587+
}
588+
550589
/**
551590
* A list of existing passkeys for this user, including the one they've just created
552591
*/

authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import android.app.Activity
1919
import android.app.Application
2020
import androidx.lifecycle.AndroidViewModel
2121
import androidx.lifecycle.viewModelScope
22+
import com.amplifyframework.AmplifyException
2223
import com.amplifyframework.auth.AuthChannelEventName
2324
import com.amplifyframework.auth.AuthException
2425
import com.amplifyframework.auth.AuthUser
@@ -34,6 +35,7 @@ import com.amplifyframework.auth.cognito.exceptions.service.InvalidParameterExce
3435
import com.amplifyframework.auth.cognito.exceptions.service.InvalidPasswordException
3536
import com.amplifyframework.auth.cognito.exceptions.service.LimitExceededException
3637
import com.amplifyframework.auth.cognito.exceptions.service.PasswordResetRequiredException
38+
import com.amplifyframework.auth.cognito.exceptions.service.UserCancelledException
3739
import com.amplifyframework.auth.cognito.exceptions.service.UserNotConfirmedException
3840
import com.amplifyframework.auth.cognito.exceptions.service.UserNotFoundException
3941
import com.amplifyframework.auth.cognito.exceptions.service.UsernameExistsException
@@ -87,12 +89,15 @@ import com.amplifyframework.ui.authenticator.util.InvalidLoginMessage
8789
import com.amplifyframework.ui.authenticator.util.LimitExceededMessage
8890
import com.amplifyframework.ui.authenticator.util.MissingConfigurationException
8991
import com.amplifyframework.ui.authenticator.util.NetworkErrorMessage
92+
import com.amplifyframework.ui.authenticator.util.PasskeyCreationFailedMessage
93+
import com.amplifyframework.ui.authenticator.util.PasskeyPromptCheck
9094
import com.amplifyframework.ui.authenticator.util.PasswordResetMessage
9195
import com.amplifyframework.ui.authenticator.util.RealAuthProvider
9296
import com.amplifyframework.ui.authenticator.util.UnableToResetPasswordMessage
9397
import com.amplifyframework.ui.authenticator.util.UnknownErrorMessage
9498
import com.amplifyframework.ui.authenticator.util.authFlow
9599
import com.amplifyframework.ui.authenticator.util.callingActivity
100+
import com.amplifyframework.ui.authenticator.util.getOrDefault
96101
import com.amplifyframework.ui.authenticator.util.isAuthFlowSessionExpiredError
97102
import com.amplifyframework.ui.authenticator.util.isConnectivityIssue
98103
import com.amplifyframework.ui.authenticator.util.preferredFirstFactor
@@ -107,8 +112,11 @@ import kotlinx.coroutines.launch
107112
import kotlinx.coroutines.withContext
108113
import org.jetbrains.annotations.VisibleForTesting
109114

110-
internal class AuthenticatorViewModel(application: Application, private val authProvider: AuthProvider) :
111-
AndroidViewModel(application) {
115+
internal class AuthenticatorViewModel(
116+
application: Application,
117+
private val authProvider: AuthProvider,
118+
private val passkeyCheck: PasskeyPromptCheck = PasskeyPromptCheck(authProvider)
119+
) : AndroidViewModel(application) {
112120

113121
// Constructor for compose viewModels provider
114122
constructor(application: Application) : this(application, RealAuthProvider())
@@ -140,13 +148,14 @@ internal class AuthenticatorViewModel(application: Application, private val auth
140148

141149
// The current activity is used for WebAuthn sign-in when using passwordless functionality
142150
private var activityReference: WeakReference<Activity> = WeakReference(null)
143-
var activity: Activity?
151+
private var activity: Activity?
144152
get() = activityReference.get()
145153
set(value) {
146154
activityReference = WeakReference(value)
147155
}
148156

149-
fun start(configuration: AuthenticatorConfiguration) {
157+
fun start(configuration: AuthenticatorConfiguration, activity: Activity?) {
158+
this.activity = activity
150159
if (::configuration.isInitialized) {
151160
return
152161
}
@@ -216,7 +225,7 @@ internal class AuthenticatorViewModel(application: Application, private val auth
216225
suspend fun signUp(username: String, password: String?, attributes: List<AuthUserAttribute>) {
217226
viewModelScope.launch {
218227
val options = AuthSignUpOptions.builder().userAttributes(attributes).build()
219-
val info = UserInfo(username = username, password = password, signInSource = SignInSource.SignUp)
228+
val info = UserInfo(username = username, password = password, signInSource = SignInSource.AutoSignIn)
220229

221230
when (val result = authProvider.signUp(username, password, options)) {
222231
is AmplifyResult.Error -> handleSignUpFailure(result.error)
@@ -350,6 +359,7 @@ internal class AuthenticatorViewModel(application: Application, private val auth
350359
// UserNotConfirmed and PasswordResetRequired are special cases where we need
351360
// to enter different flows
352361
when (error) {
362+
is UserCancelledException -> Unit // This is an expected error, user can simply retry
353363
is UserNotConfirmedException -> handleUnconfirmedSignIn(info)
354364
is PasswordResetRequiredException -> handleResetRequiredSignIn(info.username)
355365
is NotAuthorizedException -> sendMessage(InvalidLoginMessage(error))
@@ -471,7 +481,7 @@ internal class AuthenticatorViewModel(application: Application, private val auth
471481

472482
private suspend fun handleSignInSuccess(info: UserInfo, result: AuthSignInResult) {
473483
when (val nextStep = result.nextStep.signInStep) {
474-
AuthSignInStep.DONE -> checkVerificationMechanisms()
484+
AuthSignInStep.DONE -> checkForPasskeyPrompt(info)
475485
AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE,
476486
AuthSignInStep.CONFIRM_SIGN_IN_WITH_OTP -> moveTo(
477487
stateFactory.newSignInMfaState(
@@ -537,6 +547,53 @@ internal class AuthenticatorViewModel(application: Application, private val auth
537547
}
538548
}
539549

550+
private suspend fun checkForPasskeyPrompt(info: UserInfo) {
551+
if (passkeyCheck.shouldPromptForPasskey(userInfo = info, config = configuration)) {
552+
moveTo(
553+
stateFactory.newPasskeyPromptState(
554+
onSubmit = {
555+
val activityRef = activity
556+
if (activityRef == null) {
557+
// This shouldn't happen, it indicates a bug. If it does the user can retry or choose to
558+
// skip
559+
sendMessage(
560+
UnknownErrorMessage(
561+
AuthException(
562+
message = "Missing activity reference",
563+
recoverySuggestion = AmplifyException.REPORT_BUG_TO_AWS_SUGGESTION
564+
)
565+
)
566+
)
567+
} else {
568+
createPasskey(activityRef)
569+
}
570+
},
571+
onSkip = ::checkVerificationMechanisms
572+
)
573+
)
574+
} else {
575+
checkVerificationMechanisms()
576+
}
577+
}
578+
579+
private suspend fun createPasskey(activityRef: Activity) {
580+
when (val result = authProvider.createPasskey(activityRef)) {
581+
is AmplifyResult.Error -> when (result.error) {
582+
is UserCancelledException -> Unit // This is expected, user can retry or skip
583+
else -> sendMessage(PasskeyCreationFailedMessage(result.error)) // User can retry/skip
584+
}
585+
is AmplifyResult.Success -> {
586+
val passkeys = authProvider.getPasskeys().getOrDefault { emptyList() }
587+
moveTo(
588+
stateFactory.newPasskeyCreatedState(
589+
passkeys = passkeys,
590+
onDone = ::checkVerificationMechanisms
591+
)
592+
)
593+
}
594+
}
595+
}
596+
540597
private suspend fun checkVerificationMechanisms() {
541598
val mechanisms = authConfiguration.verificationMechanisms
542599
if (mechanisms.isEmpty()) {

authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/SignInSource.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,5 @@ internal enum class SignInSource {
55
SignIn,
66

77
// Automatic sign in after completing sign up
8-
SignUp,
9-
10-
// Signed in outside of Authenticator
11-
External
8+
AutoSignIn
129
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.amplifyframework.ui.authenticator.states
2+
3+
import com.amplifyframework.ui.authenticator.AuthenticatorActionState
4+
5+
internal interface MutableActionState<T> : AuthenticatorActionState<T> {
6+
override var action: T?
7+
}
8+
9+
internal inline fun <T> MutableActionState<T>.withAction(action: T, func: () -> Unit) {
10+
this.action = action
11+
func()
12+
this.action = null
13+
}
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
package com.amplifyframework.ui.authenticator.states
22

3+
import androidx.compose.runtime.getValue
4+
import androidx.compose.runtime.mutableStateOf
5+
import androidx.compose.runtime.setValue
36
import com.amplifyframework.auth.result.AuthWebAuthnCredential
47
import com.amplifyframework.ui.authenticator.PasskeyCreatedState
58
import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep
69

710
internal class PasskeyCreatedStateImpl(
811
override val passkeys: List<AuthWebAuthnCredential>,
912
private val onDone: suspend () -> Unit
10-
) : PasskeyCreatedState {
13+
) : PasskeyCreatedState,
14+
MutableActionState<PasskeyCreatedState.Action> {
1115
override val step: AuthenticatorStep = AuthenticatorStep.PasskeyCreated
1216

13-
override suspend fun done() = onDone()
17+
override var action: PasskeyCreatedState.Action? by mutableStateOf(null)
18+
19+
override suspend fun done() = withAction(PasskeyCreatedState.Action.Done()) {
20+
onDone()
21+
}
1422
}
Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
11
package com.amplifyframework.ui.authenticator.states
22

3+
import androidx.compose.runtime.getValue
4+
import androidx.compose.runtime.mutableStateOf
5+
import androidx.compose.runtime.setValue
36
import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState
7+
import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState.Action
48
import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep
59
import kotlinx.coroutines.sync.Mutex
610
import kotlinx.coroutines.sync.withLock
711

812
class PasskeyCreationPromptStateImpl(private val onSubmit: suspend () -> Unit, private val onSkip: suspend () -> Unit) :
9-
PasskeyCreationPromptState {
13+
PasskeyCreationPromptState,
14+
MutableActionState<Action> {
1015
private val mutex = Mutex()
1116

12-
override suspend fun createPasskey() {
17+
override val step = AuthenticatorStep.PasskeyCreationPrompt
18+
19+
override var action: Action? by mutableStateOf(null)
20+
21+
override suspend fun createPasskey() = withAction(Action.CreatePasskey()) {
1322
mutex.withLock {
1423
onSubmit()
1524
}
1625
}
1726

18-
override suspend fun skip() = onSkip()
19-
20-
override val step = AuthenticatorStep.PasskeyCreationPrompt
27+
override suspend fun skip() = withAction(Action.Skip()) { onSkip() }
2128
}

authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInSelectAuthFactorStateImpl.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.compose.runtime.getValue
44
import androidx.compose.runtime.mutableStateOf
55
import androidx.compose.runtime.setValue
66
import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState
7+
import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState.Action
78
import com.amplifyframework.ui.authenticator.auth.SignInMethod
89
import com.amplifyframework.ui.authenticator.data.AuthFactor
910
import com.amplifyframework.ui.authenticator.data.containsPassword
@@ -17,10 +18,11 @@ internal class SignInSelectAuthFactorStateImpl(
1718
private val onSubmit: suspend (authFactor: AuthFactor) -> Unit,
1819
private val onMoveTo: (step: AuthenticatorInitialStep) -> Unit
1920
) : BaseStateImpl(),
20-
SignInSelectAuthFactorState {
21+
SignInSelectAuthFactorState,
22+
MutableActionState<Action> {
2123
override val step: AuthenticatorStep = AuthenticatorStep.SignInSelectAuthFactor
2224

23-
override var selectedFactor: AuthFactor? by mutableStateOf(null)
25+
override var action: Action? by mutableStateOf(null)
2426

2527
init {
2628
if (availableAuthFactors.containsPassword()) {
@@ -30,15 +32,13 @@ internal class SignInSelectAuthFactorStateImpl(
3032

3133
override fun moveTo(step: AuthenticatorInitialStep) = onMoveTo(step)
3234

33-
override suspend fun select(authFactor: AuthFactor) {
35+
override suspend fun select(authFactor: AuthFactor) = withAction(Action.SelectFactor(authFactor)) {
3436
// Clear errors
3537
form.fields.values.forEach { it.state.error = null }
3638

37-
selectedFactor = authFactor
3839
form.enabled = false
3940
onSubmit(authFactor)
4041
form.enabled = true
41-
selectedFactor = null
4242
}
4343
}
4444

0 commit comments

Comments
 (0)