PM-5153: Implement FIDO2 credential provider service (passkey creation entries) (#1370)

This commit is contained in:
Patrick Honkonen 2024-05-20 12:01:35 -04:00 committed by Álison Fernandes
parent ae59d32f3b
commit bd8124ec9e
13 changed files with 674 additions and 3 deletions

View file

@ -92,6 +92,11 @@ The following is a list of all third-party dependencies included as part of the
- Purpose: Backwards compatible SplashScreen API implementation. - Purpose: Backwards compatible SplashScreen API implementation.
- License: Apache 2.0 - License: Apache 2.0
- **AndroidX Credentials**
- https://developer.android.com/jetpack/androidx/releases/credentials
- Purpose: Unified access to user's credentials.
- License: Apache 2.0
- **AndroidX Lifecycle** - **AndroidX Lifecycle**
- https://developer.android.com/jetpack/androidx/releases/lifecycle - https://developer.android.com/jetpack/androidx/releases/lifecycle
- Purpose: Lifecycle aware components and tooling. - Purpose: Lifecycle aware components and tooling.

View file

@ -147,6 +147,7 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
implementation(libs.bitwarden.sdk) implementation(libs.bitwarden.sdk)
implementation(libs.bumptech.glide) implementation(libs.bumptech.glide)
implementation(libs.androidx.credentials)
implementation(libs.google.hilt.android) implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler) ksp(libs.google.hilt.compiler)
implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.collections.immutable)

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application tools:ignore="MissingApplicationIcon">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!--
The CredentialProviderService name below refers to the legacy Xamarin app's service name.
This must always match in order for the app to properly query if it is providing credential
services.
-->
<!--suppress AndroidDomInspection -->
<service
android:name="com.x8bit.bitwarden.Autofill.CredentialProviderService"
android:enabled="true"
android:exported="true"
android:label="@string/bitwarden"
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="android.service.credentials.CredentialProviderService" />
</intent-filter>
<meta-data
android:name="android.credentials.provider"
android:resource="@xml/provider" />
</service>
<!-- Disable Crashlytics for debug builds -->
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="false" />
</application>
</manifest>

View file

@ -2,11 +2,15 @@ package com.x8bit.bitwarden
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.core.app.AppComponentFactory import androidx.core.app.AppComponentFactory
import com.x8bit.bitwarden.data.autofill.BitwardenAutofillService import com.x8bit.bitwarden.data.autofill.BitwardenAutofillService
import com.x8bit.bitwarden.data.autofill.fido2.BitwardenFido2ProviderService
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
private const val LEGACY_AUTOFILL_SERVICE_NAME = "com.x8bit.bitwarden.Autofill.AutofillService" private const val LEGACY_AUTOFILL_SERVICE_NAME = "com.x8bit.bitwarden.Autofill.AutofillService"
private const val LEGACY_CREDENTIAL_SERVICE_NAME =
"com.x8bit.bitwarden.Autofill.CredentialProviderService"
/** /**
* A factory class that allows us to intercept when a manifest element is being instantiated * A factory class that allows us to intercept when a manifest element is being instantiated
@ -16,9 +20,10 @@ private const val LEGACY_AUTOFILL_SERVICE_NAME = "com.x8bit.bitwarden.Autofill.A
@OmitFromCoverage @OmitFromCoverage
class BitwardenAppComponentFactory : AppComponentFactory() { class BitwardenAppComponentFactory : AppComponentFactory() {
/** /**
* Used to intercept when the [BitwardenAutofillService] is being instantiated and modify which * Used to intercept when the [BitwardenAutofillService] or [BitwardenFido2ProviderService] is
* service is created. This is required because the [className] used in the manifest must match * being instantiated and modify which service is created. This is required because the
* the legacy Xamarin app service name but the service name in this app is different. * [className] used in the manifest must match the legacy Xamarin app service name but the
* service name in this app is different.
*/ */
override fun instantiateServiceCompat( override fun instantiateServiceCompat(
cl: ClassLoader, cl: ClassLoader,
@ -29,6 +34,20 @@ class BitwardenAppComponentFactory : AppComponentFactory() {
super.instantiateServiceCompat(cl, BitwardenAutofillService::class.java.name, intent) super.instantiateServiceCompat(cl, BitwardenAutofillService::class.java.name, intent)
} }
LEGACY_CREDENTIAL_SERVICE_NAME -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
super.instantiateServiceCompat(
cl,
BitwardenFido2ProviderService::class.java.name,
intent,
)
} else {
throw UnsupportedOperationException(
"The CredentialProviderService requires API 34 or higher.",
)
}
}
else -> super.instantiateServiceCompat(cl, className, intent) else -> super.instantiateServiceCompat(cl, className, intent)
} }
} }

View file

@ -0,0 +1,72 @@
package com.x8bit.bitwarden.data.autofill.fido2
import android.os.Build
import android.os.CancellationSignal
import android.os.OutcomeReceiver
import androidx.annotation.RequiresApi
import androidx.credentials.exceptions.ClearCredentialException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.CredentialProviderService
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* The [CredentialProviderService] for the app. This fulfills FIDO2 credential requests from other
* applications.
*/
@OmitFromCoverage
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@AndroidEntryPoint
class BitwardenFido2ProviderService : CredentialProviderService() {
/**
* A processor to handle the FIDO2 credential fulfillment. We keep the service light because it
* isn't easily testable.
*/
@Inject
lateinit var processor: Fido2ProviderProcessor
override fun onBeginCreateCredentialRequest(
request: BeginCreateCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,
) {
processor.processCreateCredentialRequest(
request,
cancellationSignal,
callback,
)
}
override fun onBeginGetCredentialRequest(
request: BeginGetCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
) {
processor.processGetCredentialRequest(
request,
cancellationSignal,
callback,
)
}
override fun onClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<Void?, ClearCredentialException>,
) {
processor.processClearCredentialStateRequest(
request,
cancellationSignal,
callback,
)
}
}

View file

@ -0,0 +1,42 @@
package com.x8bit.bitwarden.data.autofill.fido2.di
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorImpl
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provides dependencies within the fido2 package.
*/
@OmitFromCoverage
@Module
@InstallIn(SingletonComponent::class)
object Fido2ProviderModule {
@RequiresApi(Build.VERSION_CODES.S)
@Singleton
@Provides
fun provideCredentialProviderProcessor(
@ApplicationContext context: Context,
authRepository: AuthRepository,
dispatcherManager: DispatcherManager,
intentManager: IntentManager,
): Fido2ProviderProcessor =
Fido2ProviderProcessorImpl(
context,
authRepository,
intentManager,
dispatcherManager,
)
}

View file

@ -0,0 +1,63 @@
package com.x8bit.bitwarden.data.autofill.fido2.processor
import android.os.CancellationSignal
import android.os.OutcomeReceiver
import androidx.credentials.exceptions.ClearCredentialException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.ProviderClearCredentialStateRequest
/**
* A class to handle FIDO2 credential request processing. This includes save and autofill requests.
*/
interface Fido2ProviderProcessor {
/**
* Process the [BeginCreateCredentialRequest] and invoke the [callback] with the result.
*
* @param request The request data from the OS that contains data about the requesting provider.
* @param cancellationSignal signal for observing cancellation requests. The system will use
* this to notify us that the result is no longer needed and we should stop handling it in order
* to save our resources.
* @param callback the callback object to be used to notify the response or error
*/
fun processCreateCredentialRequest(
request: BeginCreateCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,
)
/**
* Process the [BeginGetCredentialRequest] and invoke the [callback] with the result.
*
* @param request The request data form the OS that contains data about the requesting provider.
* @param cancellationSignal signal for observing cancellation requests. The system will use
* this to notify us that the result is no longer needed and we should stop handling it in order
* to save our resources.
* @param callback the callback object to be used to notify the response or error
*/
fun processGetCredentialRequest(
request: BeginGetCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
)
/**
* Process the [ProviderClearCredentialStateRequest] and invoke the [callback] with the result.
*
* @param request The request data form the OS that contains data about the requesting provider.
* @param cancellationSignal signal for observing cancellation requests. The system will use
* this to notify us that the result is no longer needed and we should stop handling it in order
* to save our resources.
* @param callback the callback object to be used to notify the response or error
*/
fun processClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<Void?, ClearCredentialException>,
)
}

View file

@ -0,0 +1,140 @@
package com.x8bit.bitwarden.data.autofill.fido2.processor
import android.content.Context
import android.os.Build
import android.os.CancellationSignal
import android.os.OutcomeReceiver
import androidx.annotation.RequiresApi
import androidx.credentials.exceptions.ClearCredentialException
import androidx.credentials.exceptions.ClearCredentialUnsupportedException
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnsupportedException
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.CreateEntry
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicInteger
private const val CREATE_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY"
/**
* The default implementation of [Fido2ProviderProcessor]. Its purpose is to handle FIDO2 related
* processing.
*/
@RequiresApi(Build.VERSION_CODES.S)
class Fido2ProviderProcessorImpl(
private val context: Context,
private val authRepository: AuthRepository,
private val intentManager: IntentManager,
dispatcherManager: DispatcherManager,
) : Fido2ProviderProcessor {
private val requestCode = AtomicInteger()
private val scope = CoroutineScope(dispatcherManager.unconfined)
override fun processCreateCredentialRequest(
request: BeginCreateCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,
) {
cancellationSignal.setOnCancelListener {
callback.onError(CreateCredentialCancellationException())
scope.cancel()
}
val userId = authRepository.activeUserId
if (userId == null) {
callback.onError(CreateCredentialUnknownException("Active user is required."))
return
}
scope.launch {
processCreateCredentialRequest(request = request)
?.let { callback.onResult(it) }
?: callback.onError(CreateCredentialUnknownException())
}
}
private fun processCreateCredentialRequest(
request: BeginCreateCredentialRequest,
): BeginCreateCredentialResponse? {
return when (request) {
is BeginCreatePublicKeyCredentialRequest -> {
handleCreatePasskeyQuery(request)
}
else -> null
}
}
@Suppress("ReturnCount")
private fun handleCreatePasskeyQuery(
request: BeginCreatePublicKeyCredentialRequest,
): BeginCreateCredentialResponse? {
val requestJson = request
.candidateQueryData
.getString("androidx.credentials.BUNDLE_KEY_REQUEST_JSON")
if (requestJson.isNullOrEmpty()) return null
val userState = authRepository.userStateFlow.value ?: return null
return BeginCreateCredentialResponse.Builder()
.setCreateEntries(userState.accounts.toCreateEntries())
.build()
}
private fun List<UserState.Account>.toCreateEntries() = map { it.toCreateEntry() }
private fun UserState.Account.toCreateEntry(): CreateEntry {
val accountName = name ?: email
return CreateEntry
.Builder(
accountName = accountName,
pendingIntent = intentManager.createFido2CreationPendingIntent(
CREATE_PASSKEY_INTENT,
userId,
requestCode.getAndIncrement(),
),
)
.setDescription(
context.getString(
R.string.your_passkey_will_be_saved_to_your_bitwarden_vault_for_x,
accountName,
),
)
.build()
}
override fun processGetCredentialRequest(
request: BeginGetCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
) {
// no-op: RFU
callback.onError(GetCredentialUnsupportedException())
}
override fun processClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<Void?, ClearCredentialException>,
) {
// no-op: RFU
callback.onError(ClearCredentialUnsupportedException())
}
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.manager.intent package com.x8bit.bitwarden.ui.platform.manager.intent
import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
@ -80,6 +81,16 @@ interface IntentManager {
*/ */
fun createDocumentIntent(fileName: String): Intent fun createDocumentIntent(fileName: String): Intent
/**
* Creates a pending intent to use when providing [androidx.credentials.provider.CreateEntry]
* instances for FIDO 2 credential creation.
*/
fun createFido2CreationPendingIntent(
action: String,
userId: String,
requestCode: Int,
): PendingIntent
/** /**
* Represents file information. * Represents file information.
*/ */

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.manager.intent package com.x8bit.bitwarden.ui.platform.manager.intent
import android.app.Activity import android.app.Activity
import android.app.PendingIntent
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
@ -20,6 +21,7 @@ import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.x8bit.bitwarden.BuildConfig import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import java.io.File import java.io.File
@ -42,6 +44,13 @@ private const val TEMP_CAMERA_IMAGE_NAME: String = "temp_camera_image.jpg"
*/ */
private const val TEMP_CAMERA_IMAGE_DIR: String = "camera_temp" private const val TEMP_CAMERA_IMAGE_DIR: String = "camera_temp"
/**
* Key for the user id included in FIDO 2 provider "create entries".
*
* @see IntentManager.createFido2CreationPendingIntent
*/
private const val EXTRA_KEY_USER_ID: String = "EXTRA_KEY_USER_ID"
/** /**
* The default implementation of the [IntentManager] for simplifying the handling of Android * The default implementation of the [IntentManager] for simplifying the handling of Android
* Intents within a given context. * Intents within a given context.
@ -185,6 +194,23 @@ class IntentManagerImpl(
putExtra(Intent.EXTRA_TITLE, fileName) putExtra(Intent.EXTRA_TITLE, fileName)
} }
override fun createFido2CreationPendingIntent(
action: String,
userId: String,
requestCode: Int,
): PendingIntent {
val intent = Intent(action)
.setPackage(context.packageName)
.putExtra(EXTRA_KEY_USER_ID, userId)
return PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ requestCode,
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(),
)
}
private fun getCameraFileData(): IntentManager.FileData { private fun getCameraFileData(): IntentManager.FileData {
val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR) val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR)
val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME) val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME)

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<credential-provider>
<capabilities>
<capability name="androidx.credentials.TYPE_PUBLIC_KEY_CREDENTIAL" />
</capabilities>
</credential-provider>

View file

@ -0,0 +1,240 @@
package com.x8bit.bitwarden.data.autofill.fido2.processor
import android.app.PendingIntent
import android.content.Context
import android.os.Bundle
import android.os.CancellationSignal
import android.os.OutcomeReceiver
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginCreatePasswordCredentialRequest
import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class Fido2ProviderProcessorTest {
private lateinit var fido2Processor: Fido2ProviderProcessor
private val context: Context = mockk()
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val authRepository: AuthRepository = mockk {
every { activeUserId } returns "mockActiveUserId"
every { userStateFlow } returns mutableUserStateFlow
}
private val intentManager: IntentManager = mockk()
private val dispatcherManager: DispatcherManager = FakeDispatcherManager()
private val cancellationSignal: CancellationSignal = mockk()
@BeforeEach
fun setUp() {
fido2Processor = Fido2ProviderProcessorImpl(
context,
authRepository,
intentManager,
dispatcherManager,
)
}
@Test
fun `processCreateCredentialRequest should invoke callback with error when user id is null`() {
val request: BeginCreateCredentialRequest = mockk()
val callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException> =
mockk()
val captureSlot = slot<CreateCredentialException>()
every { authRepository.activeUserId } returns null
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { callback.onError(capture(captureSlot)) } just runs
fido2Processor.processCreateCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 1) { callback.onError(any()) }
verify(exactly = 0) { callback.onResult(any()) }
assert(captureSlot.captured is CreateCredentialUnknownException)
assertEquals("Active user is required.", captureSlot.captured.errorMessage)
}
@Suppress("MaxLineLength")
@Test
fun `processCreateCredentialRequest should invoke callback with error on password create request`() {
val request: BeginCreatePasswordCredentialRequest = mockk()
val callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException> =
mockk()
val captureSlot = slot<CreateCredentialException>()
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { callback.onError(capture(captureSlot)) } just runs
fido2Processor.processCreateCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 1) { callback.onError(any()) }
verify(exactly = 0) { callback.onResult(any()) }
assert(captureSlot.captured is CreateCredentialUnknownException)
}
@Suppress("MaxLineLength")
@Test
fun `processCreateCredentialRequest should invoke callback with error when json is null or empty`() {
val request: BeginCreatePublicKeyCredentialRequest = mockk()
val candidateQueryData: Bundle = mockk()
val callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException> =
mockk()
val captureSlot = slot<CreateCredentialException>()
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { request.candidateQueryData } returns candidateQueryData
every {
candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_REQUEST_JSON")
} returns null
every { callback.onError(capture(captureSlot)) } just runs
fido2Processor.processCreateCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 1) { callback.onError(any()) }
verify(exactly = 0) { callback.onResult(any()) }
assert(captureSlot.captured is CreateCredentialUnknownException)
}
@Test
fun `processCreateCredentialRequest should invoke callback with error when user state null`() {
val request: BeginCreatePublicKeyCredentialRequest = mockk()
val candidateQueryData: Bundle = mockk()
val callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException> =
mockk()
val captureSlot = slot<CreateCredentialException>()
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { request.candidateQueryData } returns candidateQueryData
every {
candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_REQUEST_JSON")
} returns "{\"mockJsonRequest\":1}"
every { callback.onError(capture(captureSlot)) } just runs
fido2Processor.processCreateCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 1) { callback.onError(any()) }
verify(exactly = 0) { callback.onResult(any()) }
assert(captureSlot.captured is CreateCredentialUnknownException)
}
@Suppress("MaxLineLength")
@Test
fun `processCreateCredentialRequest should invoke callback with result when user state is valid`() {
val request: BeginCreatePublicKeyCredentialRequest = mockk()
val candidateQueryData: Bundle = mockk()
val callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException> =
mockk()
val captureSlot = slot<BeginCreateCredentialResponse>()
val mockIntent: PendingIntent = mockk()
mutableUserStateFlow.value = DEFAULT_USER_STATE
every { context.packageName } returns "com.x8bit.bitwarden"
every { context.getString(any(), any()) } returns "mockDescription"
every {
intentManager.createFido2CreationPendingIntent(
any(),
any(),
any(),
)
} returns mockIntent
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { request.candidateQueryData } returns candidateQueryData
every {
candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_REQUEST_JSON")
} returns "{\"mockJsonRequest\":1}"
every { callback.onResult(capture(captureSlot)) } just runs
fido2Processor.processCreateCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 1) { callback.onResult(any()) }
verify(exactly = 0) { callback.onError(any()) }
assertEquals(DEFAULT_USER_STATE.accounts.size, captureSlot.captured.createEntries.size)
val capturedEntry = captureSlot.captured.createEntries[0]
assertEquals(DEFAULT_USER_STATE.accounts[0].email, capturedEntry.accountName)
}
@Test
fun `processCreateCredentialRequest should generate result entries for each user account`() {
val request: BeginCreatePublicKeyCredentialRequest = mockk()
val candidateQueryData: Bundle = mockk()
val callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException> =
mockk()
mutableUserStateFlow.value = DEFAULT_USER_STATE
val captureSlot = slot<BeginCreateCredentialResponse>()
val mockIntent: PendingIntent = mockk()
every { context.packageName } returns "com.x8bit.bitwarden.dev"
every { context.getString(any(), any()) } returns "mockDescription"
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { request.candidateQueryData } returns candidateQueryData
every {
candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_REQUEST_JSON")
} returns "{\"mockJsonRequest\":1}"
every { callback.onResult(capture(captureSlot)) } just runs
every {
intentManager.createFido2CreationPendingIntent(
any(),
any(),
any(),
)
} returns mockIntent
fido2Processor.processCreateCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 1) { callback.onResult(any()) }
verify(exactly = 0) { callback.onError(any()) }
assertEquals(DEFAULT_USER_STATE.accounts.size, captureSlot.captured.createEntries.size)
DEFAULT_USER_STATE.accounts.forEachIndexed { index, mockAccount ->
assertEquals(mockAccount.email, captureSlot.captured.createEntries[index].accountName)
}
}
}
private val DEFAULT_USER_STATE = UserState(
activeUserId = "mockUserId-1",
accounts = createMockAccounts(2),
)
private fun createMockAccounts(number: Int): List<UserState.Account> {
val accounts = mutableListOf<UserState.Account>()
repeat(number) {
accounts.add(
UserState.Account(
userId = "mockUserId-$it",
name = null,
email = "mockEmail-$it",
avatarColorHex = "$it",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
needsMasterPassword = false,
trustedDevice = null,
organizations = emptyList(),
isBiometricsEnabled = false,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
),
)
}
return accounts
}

View file

@ -14,6 +14,7 @@ androidxBrowser = "1.8.0"
androidxCamera = "1.3.3" androidxCamera = "1.3.3"
androidxComposeBom = "2024.05.00" androidxComposeBom = "2024.05.00"
androidxCore = "1.13.1" androidxCore = "1.13.1"
androidxCredentials = "1.2.2"
androidxHiltNavigationCompose = "1.2.0" androidxHiltNavigationCompose = "1.2.0"
androidxLifecycle = "2.7.0" androidxLifecycle = "2.7.0"
androidxNavigation = "2.7.7" androidxNavigation = "2.7.7"
@ -71,6 +72,8 @@ androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-mani
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
#noinspection CredentialDependency - Used for Passkey support, which is not available below Android 14
androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidxCredentials" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }