Skip to content

Commit 73f7de9

Browse files
committed
Add PasskeyPromptCheck
1 parent 2f0253d commit 73f7de9

File tree

3 files changed

+223
-0
lines changed

3 files changed

+223
-0
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.amplifyframework.ui.authenticator.util
2+
3+
import android.os.Build
4+
5+
// Facade for android.os.Build to facilitate testing
6+
internal class OsBuild {
7+
val sdkInt: Int
8+
get() = Build.VERSION.SDK_INT
9+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.amplifyframework.ui.authenticator.util
2+
3+
import com.amplifyframework.auth.AuthFactorType
4+
import com.amplifyframework.ui.authenticator.AuthenticatorConfiguration
5+
import com.amplifyframework.ui.authenticator.data.AuthenticationFlow
6+
import com.amplifyframework.ui.authenticator.data.PasskeyPrompt
7+
import com.amplifyframework.ui.authenticator.data.UserInfo
8+
import com.amplifyframework.ui.authenticator.enums.SignInSource
9+
10+
// Utility class for checking whether a user should be shown a passkey prompt
11+
internal class PasskeyPromptCheck(private val authProvider: AuthProvider, private val osBuild: OsBuild = OsBuild()) {
12+
suspend fun shouldPromptForPasskey(userInfo: UserInfo, config: AuthenticatorConfiguration): Boolean {
13+
// Ensure that userHasPasskey is the last check so that the network request can be short-circuited by
14+
// the local-only checks.
15+
val authFlow = config.authenticationFlow
16+
return authFlow is AuthenticationFlow.UserChoice &&
17+
deviceSupportsPasskeyCreation() &&
18+
passkeyPromptsEnabled(userInfo, authFlow) &&
19+
!userHasPasskey()
20+
}
21+
22+
// Passkey creation supported starting with API 28
23+
private fun deviceSupportsPasskeyCreation() = osBuild.sdkInt >= 28
24+
25+
// Check whether passkey prompts are enabled by configuration
26+
private fun passkeyPromptsEnabled(userInfo: UserInfo, authFlow: AuthenticationFlow.UserChoice): Boolean =
27+
when (userInfo.signInSource) {
28+
SignInSource.SignIn -> authFlow.passkeyPrompts.afterSignIn == PasskeyPrompt.Always
29+
SignInSource.AutoSignIn -> authFlow.passkeyPrompts.afterSignUp == PasskeyPrompt.Always
30+
}
31+
32+
// Check if the user already has a passkey registered
33+
private suspend fun userHasPasskey() = when (val result = authProvider.getAvailableAuthFactors()) {
34+
is AmplifyResult.Error -> true // Assume user already has passkey on error so we don't incorrectly prompt them
35+
is AmplifyResult.Success -> result.data.any { it == AuthFactorType.WEB_AUTHN }
36+
}
37+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package com.amplifyframework.ui.authenticator.util
2+
3+
import com.amplifyframework.auth.AuthFactorType
4+
import com.amplifyframework.auth.exceptions.UnknownException
5+
import com.amplifyframework.ui.authenticator.AuthenticatorConfiguration
6+
import com.amplifyframework.ui.authenticator.data.AuthenticationFlow
7+
import com.amplifyframework.ui.authenticator.data.PasskeyPrompt
8+
import com.amplifyframework.ui.authenticator.data.PasskeyPrompts
9+
import com.amplifyframework.ui.authenticator.data.UserInfo
10+
import com.amplifyframework.ui.authenticator.enums.SignInSource
11+
import io.kotest.matchers.booleans.shouldBeFalse
12+
import io.kotest.matchers.booleans.shouldBeTrue
13+
import io.mockk.coEvery
14+
import io.mockk.every
15+
import io.mockk.mockk
16+
import kotlinx.coroutines.test.runTest
17+
import org.junit.Test
18+
19+
class PasskeyPromptCheckTest {
20+
21+
private val authProvider = mockk<AuthProvider> {
22+
coEvery { getAvailableAuthFactors() } returns
23+
AmplifyResult.Success(listOf(AuthFactorType.PASSWORD_SRP, AuthFactorType.SMS_OTP))
24+
}
25+
private val osBuild = mockk<OsBuild> {
26+
every { sdkInt } returns 30
27+
}
28+
private val passkeyPromptCheck = PasskeyPromptCheck(authProvider, osBuild)
29+
30+
@Test
31+
fun `shouldPromptForPasskey returns false when auth flow is not UserChoice`() = runTest {
32+
val userInfo = mockUserInfo()
33+
val config = mockAuthenticatorConfiguration(authFlow = AuthenticationFlow.Password)
34+
35+
val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config)
36+
result.shouldBeFalse()
37+
}
38+
39+
@Test
40+
fun `shouldPromptForPasskey returns false when passkey prompts are disabled for SignIn`() = runTest {
41+
val userInfo = mockUserInfo(source = SignInSource.SignIn)
42+
val config = mockAuthenticatorConfiguration(
43+
authFlow = AuthenticationFlow.UserChoice(
44+
passkeyPrompts = PasskeyPrompts(afterSignIn = PasskeyPrompt.Never)
45+
)
46+
)
47+
48+
val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config)
49+
result.shouldBeFalse()
50+
}
51+
52+
@Test
53+
fun `shouldPromptForPasskey returns false when passkey prompts are disabled for AutoSignIn`() = runTest {
54+
val userInfo = mockUserInfo(source = SignInSource.AutoSignIn)
55+
val config = mockAuthenticatorConfiguration(
56+
authFlow = AuthenticationFlow.UserChoice(
57+
passkeyPrompts = PasskeyPrompts(afterSignUp = PasskeyPrompt.Never)
58+
)
59+
)
60+
61+
val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config)
62+
result.shouldBeFalse()
63+
}
64+
65+
@Test
66+
fun `shouldPromptForPasskey returns false when user already has passkey`() = runTest {
67+
val userInfo = mockUserInfo()
68+
val config = mockAuthenticatorConfiguration()
69+
70+
coEvery { authProvider.getAvailableAuthFactors() } returns AmplifyResult.Success(
71+
listOf(AuthFactorType.PASSWORD, AuthFactorType.WEB_AUTHN)
72+
)
73+
74+
val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config)
75+
result.shouldBeFalse()
76+
}
77+
78+
@Test
79+
fun `shouldPromptForPasskey returns false when getAvailableAuthFactors returns error`() = runTest {
80+
val userInfo = mockUserInfo()
81+
val config = mockAuthenticatorConfiguration()
82+
83+
coEvery { authProvider.getAvailableAuthFactors() } returns AmplifyResult.Error(
84+
UnknownException("Network error")
85+
)
86+
87+
val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config)
88+
result.shouldBeFalse()
89+
}
90+
91+
@Test
92+
fun `shouldPromptForPasskey returns false when os version is below 28`() = runTest {
93+
val userInfo = mockUserInfo()
94+
val config = mockAuthenticatorConfiguration()
95+
96+
every { osBuild.sdkInt } returns 27
97+
98+
val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config)
99+
result.shouldBeFalse()
100+
}
101+
102+
@Test
103+
fun `shouldPromptForPasskey returns true when os version is 28`() = runTest {
104+
val userInfo = mockUserInfo()
105+
val config = mockAuthenticatorConfiguration()
106+
107+
every { osBuild.sdkInt } returns 28
108+
109+
val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config)
110+
result.shouldBeTrue()
111+
}
112+
113+
@Test
114+
fun `shouldPromptForPasskey returns true when auth factor list is empty`() = runTest {
115+
val userInfo = mockUserInfo()
116+
val config = mockAuthenticatorConfiguration()
117+
118+
coEvery { authProvider.getAvailableAuthFactors() } returns AmplifyResult.Success(emptyList())
119+
120+
val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config)
121+
result.shouldBeTrue()
122+
}
123+
124+
@Test
125+
fun `shouldPromptForPasskey returns true when auth factors don't have webAuthn`() = runTest {
126+
val userInfo = mockUserInfo()
127+
val config = mockAuthenticatorConfiguration()
128+
129+
coEvery { authProvider.getAvailableAuthFactors() } returns AmplifyResult.Success(
130+
AuthFactorType.entries - AuthFactorType.WEB_AUTHN
131+
)
132+
133+
val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config)
134+
result.shouldBeTrue()
135+
}
136+
137+
@Test
138+
fun `shouldPromptForPasskey returns true for autoSignIn`() = runTest {
139+
val userInfo = mockUserInfo(source = SignInSource.AutoSignIn)
140+
val config = mockAuthenticatorConfiguration(
141+
authFlow = AuthenticationFlow.UserChoice(
142+
passkeyPrompts = PasskeyPrompts(
143+
afterSignIn = PasskeyPrompt.Never,
144+
afterSignUp = PasskeyPrompt.Always
145+
)
146+
)
147+
)
148+
149+
val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config)
150+
result.shouldBeTrue()
151+
}
152+
153+
@Test
154+
fun `shouldPromptForPasskey returns true for normal signIn`() = runTest {
155+
val userInfo = mockUserInfo(source = SignInSource.SignIn)
156+
val config = mockAuthenticatorConfiguration(
157+
authFlow = AuthenticationFlow.UserChoice(
158+
passkeyPrompts = PasskeyPrompts(
159+
afterSignIn = PasskeyPrompt.Always,
160+
afterSignUp = PasskeyPrompt.Never
161+
)
162+
)
163+
)
164+
165+
val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config)
166+
result.shouldBeTrue()
167+
}
168+
169+
private fun mockUserInfo(source: SignInSource = SignInSource.SignIn) = mockk<UserInfo> {
170+
every { signInSource } returns source
171+
}
172+
173+
private fun mockAuthenticatorConfiguration(authFlow: AuthenticationFlow = AuthenticationFlow.UserChoice()) =
174+
mockk<AuthenticatorConfiguration> {
175+
every { authenticationFlow } returns authFlow
176+
}
177+
}

0 commit comments

Comments
 (0)