Skip to content

Commit 35b71e9

Browse files
authored
fix(Authenticator): Add error messages when a TOTP next step is received (#76)
1 parent a06a6a7 commit 35b71e9

File tree

6 files changed

+414
-10
lines changed

6 files changed

+414
-10
lines changed

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

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,15 @@ import kotlinx.coroutines.flow.asSharedFlow
8181
import kotlinx.coroutines.flow.asStateFlow
8282
import kotlinx.coroutines.launch
8383
import kotlinx.coroutines.withContext
84+
import org.jetbrains.annotations.VisibleForTesting
8485

85-
internal class AuthenticatorViewModel(application: Application) : AndroidViewModel(application) {
86+
internal class AuthenticatorViewModel(
87+
application: Application,
88+
private val authProvider: AuthProvider
89+
) : AndroidViewModel(application) {
8690

87-
companion object {
88-
var authProvider: AuthProvider = RealAuthProvider()
89-
}
91+
// Constructor for compose viewModels provider
92+
constructor(application: Application) : this(application, RealAuthProvider())
9093

9194
private val logger = Amplify.Logging.forNamespace("Authenticator")
9295

@@ -102,7 +105,7 @@ internal class AuthenticatorViewModel(application: Application) : AndroidViewMod
102105
get() = stepState.value
103106

104107
// Gets the current state or null if the current state is not the parameter type
105-
private inline fun <reified T> getState(): T? = currentState as? T
108+
private inline fun <reified T> currentStateAs(): T? = currentState as? T
106109

107110
private val _events = MutableSharedFlow<AuthenticatorMessage>(
108111
extraBufferCapacity = 1,
@@ -230,7 +233,8 @@ internal class AuthenticatorViewModel(application: Application) : AndroidViewMod
230233
//endregion
231234
//region SignIn
232235

233-
private suspend fun signIn(username: String, password: String) {
236+
@VisibleForTesting
237+
suspend fun signIn(username: String, password: String) {
234238
viewModelScope.launch {
235239
when (val result = authProvider.signIn(username, password)) {
236240
is AmplifyResult.Error -> handleSignInFailure(username, password, result.error)
@@ -292,7 +296,7 @@ internal class AuthenticatorViewModel(application: Application) : AndroidViewMod
292296
}
293297

294298
private suspend fun handleSignInSuccess(username: String, password: String, result: AuthSignInResult) {
295-
when (result.nextStep.signInStep) {
299+
when (val nextStep = result.nextStep.signInStep) {
296300
AuthSignInStep.DONE -> checkVerificationMechanisms()
297301
AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE -> moveTo(
298302
stateFactory.newSignInMfaState(
@@ -316,6 +320,26 @@ internal class AuthenticatorViewModel(application: Application) : AndroidViewMod
316320
// This step isn't actually returned, it comes back as a UserNotConfirmedException.
317321
// Handling here for future correctness
318322
AuthSignInStep.CONFIRM_SIGN_UP -> handleUnconfirmedSignIn(username, password)
323+
// Show an error for TOTP next step
324+
AuthSignInStep.CONTINUE_SIGN_IN_WITH_TOTP_SETUP,
325+
AuthSignInStep.CONTINUE_SIGN_IN_WITH_MFA_SELECTION,
326+
AuthSignInStep.CONFIRM_SIGN_IN_WITH_TOTP_CODE -> {
327+
val exception = AuthException(
328+
"Authenticator does not yet support TOTP workflows.",
329+
"Disable TOTP to use Authenticator."
330+
)
331+
logger.error("Unsupported next step $nextStep", exception)
332+
sendMessage(UnknownErrorMessage(exception))
333+
}
334+
else -> {
335+
// Generic error for any other next steps that may be added in the future
336+
val exception = AuthException(
337+
"Unsupported next step $nextStep.",
338+
"Authenticator does not support this Authentication flow, disable it to use Authenticator."
339+
)
340+
logger.error("Unsupported next step $nextStep", exception)
341+
sendMessage(UnknownErrorMessage(exception))
342+
}
319343
}
320344
}
321345

@@ -463,7 +487,7 @@ internal class AuthenticatorViewModel(application: Application) : AndroidViewMod
463487

464488
private suspend fun handleAuthException(error: AuthException) {
465489
logger.warn("Encountered AuthException: $error")
466-
val state = getState<BaseStateImpl>() ?: return
490+
val state = currentStateAs<BaseStateImpl>() ?: return
467491
when (error) {
468492
is InvalidParameterException -> {
469493
// TODO : This happens if a field is invalid format e.g. phone number
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package com.amplifyframework.ui.authenticator
17+
18+
import com.amplifyframework.ui.authenticator.util.AuthenticatorMessage
19+
import io.kotest.matchers.Matcher
20+
import io.kotest.matchers.MatcherResult
21+
import io.kotest.matchers.should
22+
import io.kotest.matchers.types.shouldBeInstanceOf
23+
24+
fun haveMessage(expected: String) = Matcher<AuthenticatorMessage.Error> {
25+
MatcherResult(
26+
it.cause.message == expected,
27+
{ "error has message of ${it.cause.message} but it should have message $expected" },
28+
{ "error should not have message $expected" }
29+
)
30+
}
31+
32+
fun haveRecoverySuggestion(expected: String) = Matcher<AuthenticatorMessage.Error> {
33+
MatcherResult(
34+
it.cause.recoverySuggestion == expected,
35+
{ "error has message of ${it.cause.recoverySuggestion} but it should have message $expected" },
36+
{ "error should not have message $expected" }
37+
)
38+
}
39+
40+
fun AuthenticatorMessage?.shouldBeError(
41+
causeMessage: String? = null,
42+
recoverySuggestion: String? = null
43+
) {
44+
val casted = this.shouldBeInstanceOf<AuthenticatorMessage.Error>()
45+
causeMessage?.let { casted should haveMessage(it) }
46+
recoverySuggestion?.let { casted should haveRecoverySuggestion(it) }
47+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package com.amplifyframework.ui.authenticator
17+
18+
import android.app.Application
19+
import app.cash.turbine.test
20+
import com.amplifyframework.auth.result.step.AuthSignInStep
21+
import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep
22+
import com.amplifyframework.ui.authenticator.util.AmplifyResult
23+
import com.amplifyframework.ui.authenticator.util.AuthProvider
24+
import com.amplifyframework.ui.testing.CoroutineTestRule
25+
import io.kotest.matchers.shouldBe
26+
import io.mockk.coEvery
27+
import io.mockk.coVerify
28+
import io.mockk.mockk
29+
import kotlinx.coroutines.ExperimentalCoroutinesApi
30+
import kotlinx.coroutines.test.advanceUntilIdle
31+
import kotlinx.coroutines.test.runTest
32+
import org.junit.Rule
33+
import org.junit.Test
34+
35+
/**
36+
* Unit tests for the [AuthenticatorViewModel] class
37+
*/
38+
@OptIn(ExperimentalCoroutinesApi::class)
39+
class AuthenticatorViewModelTest {
40+
41+
@get:Rule
42+
val coroutineRule = CoroutineTestRule()
43+
44+
private val application = mockk<Application>(relaxed = true)
45+
private val authProvider = mockk<AuthProvider>(relaxed = true)
46+
47+
private val viewModel = AuthenticatorViewModel(application, authProvider)
48+
49+
//region start tests
50+
51+
@Test
52+
fun `start only executes once`() = runTest {
53+
viewModel.start(mockAuthConfiguration())
54+
viewModel.start(mockAuthConfiguration())
55+
advanceUntilIdle()
56+
57+
// fetchAuthSession only called by the first start
58+
coVerify(exactly = 1) {
59+
authProvider.fetchAuthSession()
60+
}
61+
}
62+
63+
@Test
64+
fun `missing configuration results in an error`() = runTest {
65+
coEvery { authProvider.getConfiguration() } returns null
66+
67+
viewModel.start(mockAuthConfiguration())
68+
advanceUntilIdle()
69+
70+
coVerify(exactly = 0) { authProvider.fetchAuthSession() }
71+
viewModel.currentStep shouldBe AuthenticatorStep.Error
72+
}
73+
74+
@Test
75+
fun `fetchAuthSession error during start results in an error`() = runTest {
76+
coEvery { authProvider.fetchAuthSession() } returns AmplifyResult.Error(mockAuthException())
77+
78+
viewModel.start(mockAuthConfiguration())
79+
advanceUntilIdle()
80+
81+
coVerify(exactly = 1) { authProvider.fetchAuthSession() }
82+
viewModel.currentStep shouldBe AuthenticatorStep.Error
83+
}
84+
85+
@Test
86+
fun `getCurrentUser error during start results in an error`() = runTest {
87+
coEvery { authProvider.fetchAuthSession() } returns AmplifyResult.Success(mockAuthSession(isSignedIn = true))
88+
coEvery { authProvider.getCurrentUser() } returns AmplifyResult.Error(mockAuthException())
89+
90+
viewModel.start(mockAuthConfiguration())
91+
advanceUntilIdle()
92+
93+
coVerify(exactly = 1) {
94+
authProvider.fetchAuthSession()
95+
authProvider.getCurrentUser()
96+
}
97+
viewModel.currentStep shouldBe AuthenticatorStep.Error
98+
}
99+
100+
@Test
101+
fun `when already signed in during start the initial state should be signed in`() = runTest {
102+
coEvery { authProvider.fetchAuthSession() } returns AmplifyResult.Success(mockAuthSession(isSignedIn = true))
103+
coEvery { authProvider.getCurrentUser() } returns AmplifyResult.Success(mockAuthUser())
104+
105+
viewModel.start(mockAuthConfiguration())
106+
advanceUntilIdle()
107+
108+
coVerify(exactly = 1) {
109+
authProvider.fetchAuthSession()
110+
authProvider.getCurrentUser()
111+
}
112+
viewModel.currentStep shouldBe AuthenticatorStep.SignedIn
113+
}
114+
115+
@Test
116+
fun `initial step is SignIn`() = runTest {
117+
coEvery { authProvider.fetchAuthSession() } returns AmplifyResult.Success(mockAuthSession(isSignedIn = false))
118+
119+
viewModel.start(mockAuthConfiguration(initialStep = AuthenticatorStep.SignIn))
120+
advanceUntilIdle()
121+
122+
viewModel.currentStep shouldBe AuthenticatorStep.SignIn
123+
}
124+
125+
//endregion
126+
//region signIn tests
127+
128+
@Test
129+
fun `TOTPSetup next step is unsupported`() = runTest {
130+
coEvery { authProvider.fetchAuthSession() } returns AmplifyResult.Success(mockAuthSession(isSignedIn = false))
131+
coEvery { authProvider.signIn(any(), any()) } returns AmplifyResult.Success(
132+
mockSignInResult(signInStep = AuthSignInStep.CONTINUE_SIGN_IN_WITH_TOTP_SETUP)
133+
)
134+
135+
viewModel.start(mockAuthConfiguration(initialStep = AuthenticatorStep.SignIn))
136+
137+
viewModel.events.test {
138+
viewModel.signIn("username", "password")
139+
awaitItem().shouldBeError(causeMessage = "Authenticator does not yet support TOTP workflows.")
140+
}
141+
}
142+
143+
@Test
144+
fun `TOTP Code next step is unsupported`() = runTest {
145+
coEvery { authProvider.fetchAuthSession() } returns AmplifyResult.Success(mockAuthSession(isSignedIn = false))
146+
coEvery { authProvider.signIn(any(), any()) } returns AmplifyResult.Success(
147+
mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_TOTP_CODE)
148+
)
149+
150+
viewModel.start(mockAuthConfiguration(initialStep = AuthenticatorStep.SignIn))
151+
152+
viewModel.events.test {
153+
viewModel.signIn("username", "password")
154+
awaitItem().shouldBeError(causeMessage = "Authenticator does not yet support TOTP workflows.")
155+
}
156+
}
157+
158+
@Test
159+
fun `MFA Selection next step is unsupported`() = runTest {
160+
coEvery { authProvider.fetchAuthSession() } returns AmplifyResult.Success(mockAuthSession(isSignedIn = false))
161+
coEvery { authProvider.signIn(any(), any()) } returns AmplifyResult.Success(
162+
mockSignInResult(signInStep = AuthSignInStep.CONTINUE_SIGN_IN_WITH_MFA_SELECTION)
163+
)
164+
165+
viewModel.start(mockAuthConfiguration(initialStep = AuthenticatorStep.SignIn))
166+
167+
viewModel.events.test {
168+
viewModel.signIn("username", "password")
169+
awaitItem().shouldBeError(causeMessage = "Authenticator does not yet support TOTP workflows.")
170+
}
171+
}
172+
173+
//endregion
174+
//region helpers
175+
private val AuthenticatorViewModel.currentStep: AuthenticatorStep
176+
get() = stepState.value.step
177+
//endregion
178+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package com.amplifyframework.ui.authenticator
17+
18+
import com.amplifyframework.auth.AuthCodeDeliveryDetails
19+
import com.amplifyframework.auth.AuthException
20+
import com.amplifyframework.auth.AuthSession
21+
import com.amplifyframework.auth.AuthUser
22+
import com.amplifyframework.auth.MFAType
23+
import com.amplifyframework.auth.TOTPSetupDetails
24+
import com.amplifyframework.auth.result.AuthSignInResult
25+
import com.amplifyframework.auth.result.step.AuthNextSignInStep
26+
import com.amplifyframework.auth.result.step.AuthSignInStep
27+
import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep
28+
import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep
29+
import com.amplifyframework.ui.authenticator.forms.SignUpFormBuilder
30+
31+
internal fun mockAuthConfiguration(
32+
initialStep: AuthenticatorInitialStep = AuthenticatorStep.SignIn,
33+
signUpForm: SignUpFormBuilder.() -> Unit = {}
34+
) = AuthenticatorConfiguration(
35+
initialStep = initialStep,
36+
signUpForm = signUpForm
37+
)
38+
39+
internal fun mockAuthException(
40+
message: String = "A test exception",
41+
recoverySuggestion: String = "A test suggestion",
42+
cause: Throwable? = null
43+
) = AuthException(
44+
message = message,
45+
recoverySuggestion = recoverySuggestion,
46+
cause = cause
47+
)
48+
49+
internal fun mockAuthSession(
50+
isSignedIn: Boolean = false
51+
) = AuthSession(isSignedIn)
52+
53+
internal fun mockAuthUser(
54+
userId: String = "userId",
55+
username: String = "username"
56+
) = AuthUser(userId, username)
57+
58+
internal fun mockSignInResult(
59+
isSignedIn: Boolean = true,
60+
nextSignInStep: AuthNextSignInStep = mockNextSignInStep()
61+
) = AuthSignInResult(isSignedIn, nextSignInStep)
62+
63+
internal fun mockSignInResult(
64+
signInStep: AuthSignInStep = AuthSignInStep.DONE,
65+
additionalInfo: Map<String, String> = emptyMap(),
66+
codeDeliveryDetails: AuthCodeDeliveryDetails? = null,
67+
totpSetupDetails: TOTPSetupDetails? = null,
68+
allowedMFATypes: Set<MFAType>? = null
69+
) = AuthSignInResult(
70+
signInStep == AuthSignInStep.DONE,
71+
mockNextSignInStep(
72+
signInStep = signInStep,
73+
additionalInfo = additionalInfo,
74+
codeDeliveryDetails = codeDeliveryDetails,
75+
totpSetupDetails = totpSetupDetails,
76+
allowedMFATypes = allowedMFATypes
77+
)
78+
)
79+
80+
internal fun mockNextSignInStep(
81+
signInStep: AuthSignInStep = AuthSignInStep.DONE,
82+
additionalInfo: Map<String, String> = emptyMap(),
83+
codeDeliveryDetails: AuthCodeDeliveryDetails? = null,
84+
totpSetupDetails: TOTPSetupDetails? = null,
85+
allowedMFATypes: Set<MFAType>? = null
86+
) = AuthNextSignInStep(signInStep, additionalInfo, codeDeliveryDetails, totpSetupDetails, allowedMFATypes)

0 commit comments

Comments
 (0)