mirror of
https://github.com/bitwarden/android.git
synced 2025-03-16 03:08:50 +03:00
BIT-2276: Add support for logging in with WebAuthN two-factor (#1304)
This commit is contained in:
parent
a80f903df0
commit
80f6011571
17 changed files with 582 additions and 197 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>()
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue