BIT-2276: Add support for logging in with WebAuthN two-factor (#1304)

This commit is contained in:
David Perez 2024-04-25 12:35:58 -05:00 committed by Álison Fernandes
parent a80f903df0
commit 80f6011571
17 changed files with 582 additions and 197 deletions

View file

@ -92,6 +92,16 @@
android:host="sso-callback"
android:scheme="bitwarden" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="webauthn-callback"
android:scheme="bitwarden" />
</intent-filter>
</activity>
<provider

View file

@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull
import com.x8bit.bitwarden.data.auth.util.getYubiKeyResultOrNull
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
@ -25,6 +26,7 @@ class AuthCallbackViewModel @Inject constructor(
private fun handleIntentReceived(action: AuthCallbackAction.IntentReceive) {
val yubiKeyResult = action.intent.getYubiKeyResultOrNull()
val webAuthResult = action.intent.getWebAuthResultOrNull()
val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult()
val duoCallbackTokenResult = action.intent.getDuoCallbackTokenResult()
val ssoCallbackResult = action.intent.getSsoCallbackResult()
@ -51,6 +53,10 @@ class AuthCallbackViewModel @Inject constructor(
)
}
webAuthResult != null -> {
authRepository.setWebAuthResult(webAuthResult = webAuthResult)
}
else -> Unit
}
}

View file

@ -2,11 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.util
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlDecodeOrNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
@ -61,53 +58,3 @@ val GetTokenResponseJson.TwoFactorRequired?.twoFactorDisplayEmail: String
*/
private val Map<TwoFactorAuthMethod, JsonObject?>.duo: JsonObject?
get() = get(TwoFactorAuthMethod.DUO) ?: get(TwoFactorAuthMethod.DUO_ORGANIZATION)
/**
* If it exists, return the identifier for the relying party used with Web AuthN two-factor
* authentication.
*/
val GetTokenResponseJson.TwoFactorRequired?.webAuthRpId: String?
get() = this
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.get("rpId")
?.jsonPrimitive
?.contentOrNull
/**
* If it exists, return the type of user verification needed to complete the Web AuthN two-factor
* authentication.
*/
val GetTokenResponseJson.TwoFactorRequired?.webAuthUserVerification: String?
get() = this
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.get("userVerification")
?.jsonPrimitive
?.contentOrNull
/**
* If it exists, return the challenge that the authenticator need to solve to complete the
* Web AuthN two-factor authentication.
*/
val GetTokenResponseJson.TwoFactorRequired?.webAuthChallenge: String?
get() = this
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.get("challenge")
?.jsonPrimitive
?.contentOrNull
/**
* If it exists, return the credentials allowed to be used to solve the challenge to complete the
* Web AuthN two-factor authentication.
*/
val GetTokenResponseJson.TwoFactorRequired?.webAuthAllowCredentials: List<String>?
get() = this
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.get("allowCredentials")
?.jsonArray
?.mapNotNull {
it.jsonObject["id"]?.jsonPrimitive?.contentOrNull?.base64UrlDecodeOrNull()
}

View file

@ -27,6 +27,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.AuthenticatorProvider
import kotlinx.coroutines.flow.Flow
@ -71,6 +72,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
val yubiKeyResultFlow: Flow<YubiKeyResult>
/**
* Flow of the current [WebAuthResult]. Subscribers should listen to the flow in order to
* receive updates whenever [setWebAuthResult] is called.
*/
val webAuthResultFlow: Flow<WebAuthResult>
/**
* The organization identifier currently associated with this user's SSO flow.
*/
@ -285,6 +292,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
fun setYubiKeyResult(yubiKeyResult: YubiKeyResult)
/**
* Set the value of [webAuthResultFlow].
*/
fun setWebAuthResult(webAuthResult: WebAuthResult)
/**
* Checks for a claimed domain organization for the [email] that can be used for an SSO request.
*/

View file

@ -59,6 +59,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
@ -275,6 +276,9 @@ class AuthRepositoryImpl(
private val yubiKeyResultChannel = Channel<YubiKeyResult>(capacity = Int.MAX_VALUE)
override val yubiKeyResultFlow: Flow<YubiKeyResult> = yubiKeyResultChannel.receiveAsFlow()
private val webAuthResultChannel = Channel<WebAuthResult>(capacity = Int.MAX_VALUE)
override val webAuthResultFlow: Flow<WebAuthResult> = webAuthResultChannel.receiveAsFlow()
private val mutableSsoCallbackResultFlow = bufferedMutableSharedFlow<SsoCallbackResult>()
override val ssoCallbackResultFlow: Flow<SsoCallbackResult> =
mutableSsoCallbackResultFlow.asSharedFlow()
@ -934,6 +938,10 @@ class AuthRepositoryImpl(
yubiKeyResultChannel.trySend(yubiKeyResult)
}
override fun setWebAuthResult(webAuthResult: WebAuthResult) {
webAuthResultChannel.trySend(webAuthResult)
}
override suspend fun getOrganizationDomainSsoDetails(
email: String,
): OrganizationDomainSsoDetailsResult = organizationService

View file

@ -0,0 +1,78 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.URLEncoder
import java.util.Base64
private const val WEB_AUTH_HOST: String = "webauthn-callback"
private const val CALLBACK_URI = "bitwarden://$WEB_AUTH_HOST"
/**
* Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases.
*
* - `null`: Intent is not an web auth key callback.
* - [WebAuthResult.Success]: Intent is the web auth key callback with correct data.
* - [WebAuthResult.Failure]: Intent is the web auth key callback with incorrect data.
*/
fun Intent.getWebAuthResultOrNull(): WebAuthResult? {
val localData = data
return if (action == Intent.ACTION_VIEW &&
localData != null &&
localData.host == WEB_AUTH_HOST
) {
localData
.getQueryParameter("data")
?.let { WebAuthResult.Success(token = it) }
?: WebAuthResult.Failure
} else {
null
}
}
/**
* Generates a [Uri] to display a web authn challenge for Bitwarden authentication.
*/
fun generateUriForWebAuth(
baseUrl: String,
data: JsonObject,
headerText: String,
buttonText: String,
returnButtonText: String,
): Uri {
val json = buildJsonObject {
put(key = "callbackUri", value = CALLBACK_URI)
put(key = "data", value = data.toString())
put(key = "headerText", value = headerText)
put(key = "btnText", value = buttonText)
put(key = "btnReturnText", value = returnButtonText)
}
val base64Data = Base64
.getEncoder()
.encodeToString(json.toString().toByteArray(Charsets.UTF_8))
val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8")
val url = baseUrl +
"/webauthn-mobile-connector.html" +
"?data=$base64Data" +
"&parent=$parentParam" +
"&v=2"
return Uri.parse(url)
}
/**
* Sealed class representing the result of web auth callback token extraction.
*/
sealed class WebAuthResult {
/**
* Represents a token present in the web auth callback.
*/
data class Success(val token: String) : WebAuthResult()
/**
* Represents a failure in the web auth callback.
*/
data object Failure : WebAuthResult()
}

View file

@ -108,6 +108,10 @@ fun TwoFactorLoginScreen(
intentManager.startCustomTabsActivity(uri = event.uri)
}
is TwoFactorLoginEvent.NavigateToWebAuth -> {
intentManager.startCustomTabsActivity(uri = event.uri)
}
is TwoFactorLoginEvent.ShowToast -> {
Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show()
}

View file

@ -18,17 +18,21 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForWebAuth
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.button
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.imageRes
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.isDuo
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.isContinueButtonEnabled
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.shouldUseNfc
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.showPasswordInput
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@ -48,6 +52,7 @@ private const val KEY_STATE = "state"
class TwoFactorLoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val resourceManager: ResourceManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<TwoFactorLoginState, TwoFactorLoginEvent, TwoFactorLoginAction>(
initialState = savedStateHandle[KEY_STATE]
@ -57,7 +62,10 @@ class TwoFactorLoginViewModel @Inject constructor(
codeInput = "",
displayEmail = authRepository.twoFactorResponse.twoFactorDisplayEmail,
dialogState = null,
isContinueButtonEnabled = authRepository.twoFactorResponse.preferredAuthMethod.isDuo,
isContinueButtonEnabled = authRepository
.twoFactorResponse
.preferredAuthMethod
.isContinueButtonEnabled,
isRememberMeEnabled = false,
captchaToken = null,
email = TwoFactorLoginArgs(savedStateHandle).emailAddress,
@ -100,6 +108,13 @@ class TwoFactorLoginViewModel @Inject constructor(
.map { TwoFactorLoginAction.Internal.ReceiveYubiKeyResult(yubiKeyResult = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
// Process the Web Authn result when it is received.
authRepository
.webAuthResultFlow
.map { TwoFactorLoginAction.Internal.ReceiveWebAuthResult(webAuthResult = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: TwoFactorLoginAction) {
@ -130,6 +145,10 @@ class TwoFactorLoginViewModel @Inject constructor(
handleReceiveYubiKeyResult(action)
}
is TwoFactorLoginAction.Internal.ReceiveWebAuthResult -> {
handleReceiveWebAuthResult(action)
}
is TwoFactorLoginAction.Internal.ReceiveResendEmailResult -> {
handleReceiveResendEmailResult(action)
}
@ -175,23 +194,59 @@ class TwoFactorLoginViewModel @Inject constructor(
/**
* Navigates to the Duo webpage if appropriate, else processes the login.
*/
@Suppress("MaxLineLength")
private fun handleContinueButtonClick() {
if (state.authMethod.isDuo) {
val authUrl = authRepository.twoFactorResponse.twoFactorDuoAuthUrl
// The url should not be empty unless the environment is somehow not supported.
sendEvent(
event = authUrl
?.let {
TwoFactorLoginEvent.NavigateToDuo(
uri = Uri.parse(it),
)
}
?: TwoFactorLoginEvent.ShowToast(
message = R.string.generic_error_message.asText(),
),
)
} else {
initiateLogin()
when (state.authMethod) {
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
-> {
val authUrl = authRepository.twoFactorResponse.twoFactorDuoAuthUrl
// The url should not be empty unless the environment is somehow not supported.
sendEvent(
event = authUrl
?.let { TwoFactorLoginEvent.NavigateToDuo(uri = Uri.parse(it)) }
?: TwoFactorLoginEvent.ShowToast(R.string.generic_error_message.asText()),
)
}
TwoFactorAuthMethod.WEB_AUTH -> {
sendEvent(
event = authRepository
.twoFactorResponse
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.let {
val uri = generateUriForWebAuth(
baseUrl = environmentRepository
.environment
.environmentUrlData
.baseWebVaultUrlOrDefault,
data = it,
headerText = resourceManager.getString(
resId = R.string.fido2_title,
),
buttonText = resourceManager.getString(
resId = R.string.fido2_authenticate_web_authn,
),
returnButtonText = resourceManager.getString(
resId = R.string.fido2_return_to_app,
),
)
TwoFactorLoginEvent.NavigateToWebAuth(uri = uri)
}
?: TwoFactorLoginEvent.ShowToast(
message = R.string.there_was_an_error_starting_web_authn_two_factor_authentication.asText(),
),
)
}
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.YUBI_KEY,
TwoFactorAuthMethod.U2F,
TwoFactorAuthMethod.REMEMBER,
TwoFactorAuthMethod.RECOVERY_CODE,
-> initiateLogin()
}
}
@ -289,6 +344,30 @@ class TwoFactorLoginViewModel @Inject constructor(
}
}
/**
* Handle the web auth result.
*/
private fun handleReceiveWebAuthResult(
action: TwoFactorLoginAction.Internal.ReceiveWebAuthResult,
) {
when (val result = action.webAuthResult) {
WebAuthResult.Failure -> {
mutableStateFlow.update {
it.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
is WebAuthResult.Success -> {
mutableStateFlow.update { it.copy(codeInput = result.token) }
initiateLogin()
}
}
}
/**
* Handle the resend email result.
*/
@ -481,7 +560,7 @@ data class TwoFactorLoginState(
/**
* Indicates whether the code input should be displayed.
*/
val shouldShowCodeInput: Boolean get() = !authMethod.isDuo
val shouldShowCodeInput: Boolean get() = authMethod.showPasswordInput
/**
* The image to display for the given the [authMethod].
@ -532,6 +611,11 @@ sealed class TwoFactorLoginEvent {
*/
data class NavigateToDuo(val uri: Uri) : TwoFactorLoginEvent()
/**
* Navigates to the WebAuth authentication screen.
*/
data class NavigateToWebAuth(val uri: Uri) : TwoFactorLoginEvent()
/**
* Navigates to the recovery code help page.
*
@ -631,5 +715,12 @@ sealed class TwoFactorLoginAction {
val resendEmailResult: ResendEmailResult,
val isUserInitiated: Boolean,
) : Internal()
/**
* Indicates a web auth result has been received.
*/
data class ReceiveWebAuthResult(
val webAuthResult: WebAuthResult,
) : Internal()
}
}

View file

@ -65,15 +65,41 @@ val TwoFactorAuthMethod.button: Text
}
/**
* Gets a boolean indicating if the given auth method uses Duo.
* Gets a boolean indicating if the given auth method has the continue button enabled by default.
*/
val TwoFactorAuthMethod.isDuo: Boolean
val TwoFactorAuthMethod.isContinueButtonEnabled: Boolean
get() = when (this) {
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
TwoFactorAuthMethod.WEB_AUTH,
-> true
else -> false
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.YUBI_KEY,
TwoFactorAuthMethod.U2F,
TwoFactorAuthMethod.REMEMBER,
TwoFactorAuthMethod.RECOVERY_CODE,
-> false
}
/**
* Gets a boolean indicating if the given auth method should display the password input field.
*/
val TwoFactorAuthMethod.showPasswordInput: Boolean
get() = when (this) {
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
TwoFactorAuthMethod.WEB_AUTH,
-> false
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.YUBI_KEY,
TwoFactorAuthMethod.U2F,
TwoFactorAuthMethod.REMEMBER,
TwoFactorAuthMethod.RECOVERY_CODE,
-> true
}
/**

View file

@ -17,4 +17,5 @@
<string name="autofill_suggestion" translatable="false">Autofill suggestion</string>
<string name="continue_to_complete_web_authn_verfication" translatable="false">Continue to complete WebAuthn verification.</string>
<string name="launch_web_authn" translatable="false">Launch WebAuthn</string>
<string name="there_was_an_error_starting_web_authn_two_factor_authentication" translatable="false">There was an error starting WebAuthn two factor authentication</string>
</resources>

View file

@ -5,9 +5,11 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.getYubiKeyResultOrNull
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
@ -34,6 +36,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
fun setUp() {
mockkStatic(
Intent::getYubiKeyResultOrNull,
Intent::getWebAuthResultOrNull,
Intent::getCaptchaCallbackTokenResult,
Intent::getDuoCallbackTokenResult,
Intent::getSsoCallbackResult,
@ -44,6 +47,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
fun tearDown() {
unmockkStatic(
Intent::getYubiKeyResultOrNull,
Intent::getWebAuthResultOrNull,
Intent::getCaptchaCallbackTokenResult,
Intent::getDuoCallbackTokenResult,
Intent::getSsoCallbackResult,
@ -58,6 +62,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
every { mockIntent.getCaptchaCallbackTokenResult() } returns captchaCallbackTokenResult
every { mockIntent.getDuoCallbackTokenResult() } returns null
every { mockIntent.getYubiKeyResultOrNull() } returns null
every { mockIntent.getWebAuthResultOrNull() } returns null
every { mockIntent.getSsoCallbackResult() } returns null
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent))
@ -74,6 +79,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
every { mockIntent.getCaptchaCallbackTokenResult() } returns null
every { mockIntent.getDuoCallbackTokenResult() } returns duoCallbackTokenResult
every { mockIntent.getYubiKeyResultOrNull() } returns null
every { mockIntent.getWebAuthResultOrNull() } returns null
every { mockIntent.getSsoCallbackResult() } returns null
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent))
@ -92,6 +98,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
)
every { mockIntent.getSsoCallbackResult() } returns sseCallbackResult
every { mockIntent.getYubiKeyResultOrNull() } returns null
every { mockIntent.getWebAuthResultOrNull() } returns null
every { mockIntent.getCaptchaCallbackTokenResult() } returns null
every { mockIntent.getDuoCallbackTokenResult() } returns null
@ -107,6 +114,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
val mockIntent = mockk<Intent>()
val yubiKeyResult = mockk<YubiKeyResult>()
every { mockIntent.getYubiKeyResultOrNull() } returns yubiKeyResult
every { mockIntent.getWebAuthResultOrNull() } returns null
every { mockIntent.getCaptchaCallbackTokenResult() } returns null
every { mockIntent.getDuoCallbackTokenResult() } returns null
every { mockIntent.getSsoCallbackResult() } returns null
@ -117,6 +125,24 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on ReceiveNewIntent with Web Auth Result should call setWebAuthResult`() {
val viewModel = createViewModel()
val webAuthResult = mockk<WebAuthResult>()
val mockIntent = mockk<Intent> {
every { getWebAuthResultOrNull() } returns webAuthResult
every { getYubiKeyResultOrNull() } returns null
every { getCaptchaCallbackTokenResult() } returns null
every { getDuoCallbackTokenResult() } returns null
every { getSsoCallbackResult() } returns null
}
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent))
verify(exactly = 1) {
authRepository.setWebAuthResult(webAuthResult)
}
}
private fun createViewModel() = AuthCallbackViewModel(
authRepository = authRepository,
)

View file

@ -2,12 +2,10 @@ package com.x8bit.bitwarden.data.auth.datasource.network.util
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
@ -156,79 +154,4 @@ class TwoFactorRequiredExtensionTest {
)
assertEquals(authUrl, subject.twoFactorDuoAuthUrl)
}
@Test
fun `webAuthRpId returns the expected value`() {
val rpId = "vault.bitwarden.com"
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.WEB_AUTH to JsonObject(
mapOf("rpId" to JsonPrimitive(rpId)),
),
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals(rpId, subject.webAuthRpId)
}
@Test
fun `webAuthUserVerification returns the expected value`() {
val userVerification = "discouraged"
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.WEB_AUTH to JsonObject(
mapOf("userVerification" to JsonPrimitive(userVerification)),
),
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals(userVerification, subject.webAuthUserVerification)
}
@Test
fun `webAuthChallenge returns the expected value`() {
val challenge = "987t34478t9rxq7t8n"
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.WEB_AUTH to JsonObject(
mapOf("challenge" to JsonPrimitive(challenge)),
),
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals(challenge, subject.webAuthChallenge)
}
@Test
fun `webAuthAllowCredentials returns the expected value`() {
val credential = "98426435782"
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.WEB_AUTH to JsonObject(
mapOf(
"allowCredentials" to JsonArray(
listOf(
JsonObject(
mapOf(
"type" to JsonPrimitive("public-key"),
"id" to JsonPrimitive(credential),
),
),
),
),
),
),
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertNotNull(subject.webAuthAllowCredentials)
}
}

View file

@ -75,6 +75,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
@ -4048,6 +4049,15 @@ class AuthRepositoryTest {
}
}
@Test
fun `setWebAuthResult should change the value of webAuthResultFlow`() = runTest {
val webAuthResult = WebAuthResult.Success("mockk")
repository.webAuthResultFlow.test {
repository.setWebAuthResult(webAuthResult)
assertEquals(webAuthResult, awaitItem())
}
}
@Test
fun `getOrganizationDomainSsoDetails Failure should return Failure `() = runTest {
val email = "test@gmail.com"

View file

@ -0,0 +1,76 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.serialization.json.JsonObject
import org.junit.Assert.assertEquals
import org.junit.Test
class WebAuthUtilsTest : BaseComposeTest() {
@Test
fun `generateUriForWebAuth should return valid Uri`() {
val baseUrl = "https://vault.bitwarden.com"
val actualUri = generateUriForWebAuth(
baseUrl = baseUrl,
data = JsonObject(emptyMap()),
headerText = "header",
buttonText = "button",
returnButtonText = "returnButton",
)
val expectedUrl = baseUrl +
"/webauthn-mobile-connector.html" +
"?data=eyJjYWxsYmFja1VyaSI6ImJpdHdhcmRlbjovL3dlYmF1dGhuLWNhbGxiYWNrIiwiZ" +
"GF0YSI6Int9IiwiaGVhZGVyVGV4dCI6ImhlYWRlciIsImJ0blRleHQiOiJidXR0b24iLCJi" +
"dG5SZXR1cm5UZXh0IjoicmV0dXJuQnV0dG9uIn0=" +
"&parent=bitwarden%3A%2F%2Fwebauthn-callback" +
"&v=2"
val expectedUri = Uri.parse(expectedUrl)
assertEquals(expectedUri, actualUri)
}
@Test
fun `getWebAuthResultOrNull should return null when data is null`() {
val intent = mockk<Intent> {
every { data } returns null
every { action } returns Intent.ACTION_VIEW
}
val result = intent.getWebAuthResultOrNull()
assertEquals(null, result)
}
@Test
fun `getWebAuthResultOrNull should return null when action is not Intent ACTION_VIEW`() {
val intent = mockk<Intent> {
every { data } returns null
every { action } returns Intent.ACTION_ANSWER
}
val result = intent.getWebAuthResultOrNull()
assertEquals(null, result)
}
@Test
fun `getWebAuthResultOrNull should return Failure with missing data parameter`() {
val intent = mockk<Intent> {
every { data?.getQueryParameter("data") } returns null
every { action } returns Intent.ACTION_VIEW
every { data?.host } returns "webauthn-callback"
}
val result = intent.getWebAuthResultOrNull()
assertEquals(WebAuthResult.Failure, result)
}
@Test
fun `getWebAuthResultOrNull should return Success when data query parameter is present`() {
val intent = mockk<Intent> {
every { data?.getQueryParameter("data") } returns "myToken"
every { action } returns Intent.ACTION_VIEW
every { data?.host } returns "webauthn-callback"
}
val result = intent.getWebAuthResultOrNull()
assertEquals(WebAuthResult.Success("myToken"), result)
}
}

View file

@ -200,6 +200,16 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
it.copy(authMethod = TwoFactorAuthMethod.DUO)
}
composeTestRule.onNodeWithText("Verification code").assertIsNotDisplayed()
mutableStateFlow.update {
it.copy(authMethod = TwoFactorAuthMethod.DUO_ORGANIZATION)
}
composeTestRule.onNodeWithText("Verification code").assertIsNotDisplayed()
mutableStateFlow.update {
it.copy(authMethod = TwoFactorAuthMethod.WEB_AUTH)
}
composeTestRule.onNodeWithText("Verification code").assertIsNotDisplayed()
}
@Test
@ -253,6 +263,13 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
verify { intentManager.startCustomTabsActivity(mockUri) }
}
@Test
fun `NavigateToDuoNavigateToWebAuth should call intentManager startCustomTabsActivity`() {
val mockUri = mockk<Uri>()
mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToWebAuth(mockUri))
verify { intentManager.startCustomTabsActivity(mockUri) }
}
@Test
fun `NavigateToRecoveryCode should launch the recovery code uri`() {
val mockUri = mockk<Uri>()

View file

@ -13,13 +13,17 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForWebAuth
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@ -36,33 +40,40 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class TwoFactorLoginViewModelTest : BaseViewModelTest() {
private val mutableCaptchaTokenResultFlow =
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
private val mutableDuoTokenResultFlow =
bufferedMutableSharedFlow<DuoCallbackTokenResult>()
private val mutableDuoTokenResultFlow = bufferedMutableSharedFlow<DuoCallbackTokenResult>()
private val mutableYubiKeyResultFlow = bufferedMutableSharedFlow<YubiKeyResult>()
private val mutableWebAuthResultFlow = bufferedMutableSharedFlow<WebAuthResult>()
private val authRepository: AuthRepository = mockk(relaxed = true) {
every { twoFactorResponse } returns TWO_FACTOR_RESPONSE
every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
every { duoTokenResultFlow } returns mutableDuoTokenResultFlow
every { yubiKeyResultFlow } returns mutableYubiKeyResultFlow
every { webAuthResultFlow } returns mutableWebAuthResultFlow
}
private val environmentRepository: EnvironmentRepository = mockk(relaxed = true) {
every {
environment.environmentUrlData.baseWebVaultUrlOrDefault
} returns "https://vault.bitwarden.com"
private val environmentRepository: EnvironmentRepository = mockk {
every { environment } returns Environment.Us
}
private val resourceManager: ResourceManager = mockk()
@BeforeEach
fun setUp() {
mockkStatic(::generateUriForCaptcha)
mockkStatic(
::generateUriForCaptcha,
::generateUriForWebAuth,
)
mockkStatic(Uri::class)
}
@AfterEach
fun tearDown() {
unmockkStatic(::generateUriForCaptcha)
unmockkStatic(
::generateUriForCaptcha,
::generateUriForWebAuth,
)
unmockkStatic(Uri::class)
}
@ -89,6 +100,59 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `webAuthResultFlow update with success should populate the codeInput and initial login`() {
val token = "token"
val initialState = DEFAULT_STATE.copy(authMethod = TwoFactorAuthMethod.WEB_AUTH)
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = token,
method = TwoFactorAuthMethod.WEB_AUTH.value.toString(),
remember = false,
),
captchaToken = null,
)
} returns LoginResult.Success
val viewModel = createViewModel(state = initialState)
mutableWebAuthResultFlow.tryEmit(WebAuthResult.Success(token))
assertEquals(
initialState.copy(codeInput = token),
viewModel.stateFlow.value,
)
coVerify(exactly = 1) {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = token,
method = TwoFactorAuthMethod.WEB_AUTH.value.toString(),
remember = false,
),
captchaToken = null,
)
}
}
@Test
fun `webAuthResultFlow update with failure should display error dialog`() {
val initialState = DEFAULT_STATE.copy(authMethod = TwoFactorAuthMethod.WEB_AUTH)
val viewModel = createViewModel(state = initialState)
mutableWebAuthResultFlow.tryEmit(WebAuthResult.Failure)
assertEquals(
initialState.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
message = R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `captchaTokenFlow success update should trigger a login`() = runTest {
coEvery {
@ -317,6 +381,75 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `ContinueButtonClick login should emit NavigateToWebAuth when auth method is WEB_AUTH and data is non-null`() =
runTest {
val data = JsonObject(mapOf("AuthUrl" to JsonPrimitive("bitwarden.com")))
val response = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(TwoFactorAuthMethod.WEB_AUTH to data),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
val mockkUri = mockk<Uri>()
val headerText = "header"
val buttonText = "button"
val returnButtonText = "return"
every { resourceManager.getString(R.string.fido2_title) } returns headerText
every {
resourceManager.getString(R.string.fido2_authenticate_web_authn)
} returns buttonText
every {
resourceManager.getString(R.string.fido2_return_to_app)
} returns returnButtonText
every { authRepository.twoFactorResponse } returns response
every {
generateUriForWebAuth(
baseUrl = Environment.Us.environmentUrlData.baseWebVaultUrlOrDefault,
data = data,
headerText = headerText,
buttonText = buttonText,
returnButtonText = returnButtonText,
)
} returns mockkUri
val viewModel = createViewModel(
state = DEFAULT_STATE.copy(authMethod = TwoFactorAuthMethod.WEB_AUTH),
)
viewModel.eventFlow.test {
viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick)
assertEquals(
TwoFactorLoginEvent.NavigateToWebAuth(mockkUri),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `ContinueButtonClick login should emit ShowToast when auth method is WEB_AUTH and data is null`() =
runTest {
val response = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = emptyMap(),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
every { authRepository.twoFactorResponse } returns response
val viewModel = createViewModel(
state = DEFAULT_STATE.copy(authMethod = TwoFactorAuthMethod.WEB_AUTH),
)
viewModel.eventFlow.test {
viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick)
assertEquals(
TwoFactorLoginEvent.ShowToast(
message = R.string.there_was_an_error_starting_web_authn_two_factor_authentication.asText(),
),
awaitItem(),
)
}
}
@Test
fun `ContinueButtonClick login returns CaptchaRequired should emit NavigateToCaptcha`() =
runTest {
@ -627,43 +760,42 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
TwoFactorLoginViewModel(
authRepository = authRepository,
environmentRepository = environmentRepository,
resourceManager = resourceManager,
savedStateHandle = SavedStateHandle().also {
it["state"] = state
it["email_address"] = "example@email.com"
it["password"] = "password123"
},
)
companion object {
private val TWO_FACTOR_AUTH_METHODS_DATA = mapOf(
TwoFactorAuthMethod.EMAIL to JsonObject(
mapOf("Email" to JsonPrimitive("ex***@email.com")),
),
TwoFactorAuthMethod.AUTHENTICATOR_APP to JsonObject(mapOf("Email" to JsonNull)),
)
private val TWO_FACTOR_RESPONSE =
GetTokenResponseJson.TwoFactorRequired(
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
private val DEFAULT_STATE = TwoFactorLoginState(
authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP,
availableAuthMethods = listOf(
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.RECOVERY_CODE,
),
codeInput = "",
displayEmail = "ex***@email.com",
dialogState = null,
isContinueButtonEnabled = false,
isRememberMeEnabled = false,
captchaToken = null,
email = "example@email.com",
password = "password123",
)
}
}
private val TWO_FACTOR_AUTH_METHODS_DATA = mapOf(
TwoFactorAuthMethod.EMAIL to JsonObject(
mapOf("Email" to JsonPrimitive("ex***@email.com")),
),
TwoFactorAuthMethod.AUTHENTICATOR_APP to JsonObject(mapOf("Email" to JsonNull)),
)
private val TWO_FACTOR_RESPONSE = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
private val DEFAULT_STATE = TwoFactorLoginState(
authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP,
availableAuthMethods = listOf(
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.RECOVERY_CODE,
),
codeInput = "",
displayEmail = "ex***@email.com",
dialogState = null,
isContinueButtonEnabled = false,
isRememberMeEnabled = false,
captchaToken = null,
email = "example@email.com",
password = "password123",
)

View file

@ -79,7 +79,7 @@ class TwoFactorAuthMethodExtensionTest {
}
@Test
fun `isDuo returns the expected value`() {
fun `isContinueButtonEnabled returns the expected value`() {
mapOf(
TwoFactorAuthMethod.AUTHENTICATOR_APP to false,
TwoFactorAuthMethod.EMAIL to false,
@ -88,11 +88,29 @@ class TwoFactorAuthMethodExtensionTest {
TwoFactorAuthMethod.U2F to false,
TwoFactorAuthMethod.REMEMBER to false,
TwoFactorAuthMethod.DUO_ORGANIZATION to true,
TwoFactorAuthMethod.WEB_AUTH to false,
TwoFactorAuthMethod.WEB_AUTH to true,
TwoFactorAuthMethod.RECOVERY_CODE to false,
)
.forEach { (type, isDuo) ->
assertEquals(isDuo, type.isDuo)
.forEach { (type, isContinueButtonEnabled) ->
assertEquals(isContinueButtonEnabled, type.isContinueButtonEnabled)
}
}
@Test
fun `showPasswordInput returns the expected value`() {
mapOf(
TwoFactorAuthMethod.AUTHENTICATOR_APP to true,
TwoFactorAuthMethod.EMAIL to true,
TwoFactorAuthMethod.DUO to false,
TwoFactorAuthMethod.YUBI_KEY to true,
TwoFactorAuthMethod.U2F to true,
TwoFactorAuthMethod.REMEMBER to true,
TwoFactorAuthMethod.DUO_ORGANIZATION to false,
TwoFactorAuthMethod.WEB_AUTH to false,
TwoFactorAuthMethod.RECOVERY_CODE to true,
)
.forEach { (type, showPasswordInput) ->
assertEquals(showPasswordInput, type.showPasswordInput)
}
}