diff --git a/README.md b/README.md
index 6eaaf989a..b2442370f 100644
--- a/README.md
+++ b/README.md
@@ -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.
- 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**
- https://developer.android.com/jetpack/androidx/releases/lifecycle
- Purpose: Lifecycle aware components and tooling.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ec0f941be..fb440e766 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -147,6 +147,7 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.bitwarden.sdk)
implementation(libs.bumptech.glide)
+ implementation(libs.androidx.credentials)
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.kotlinx.collections.immutable)
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..b60f59cdd
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/x8bit/bitwarden/BitwardenAppComponentFactory.kt b/app/src/main/java/com/x8bit/bitwarden/BitwardenAppComponentFactory.kt
index ef7bd297b..fdd79525a 100644
--- a/app/src/main/java/com/x8bit/bitwarden/BitwardenAppComponentFactory.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/BitwardenAppComponentFactory.kt
@@ -2,11 +2,15 @@ package com.x8bit.bitwarden
import android.app.Service
import android.content.Intent
+import android.os.Build
import androidx.core.app.AppComponentFactory
import com.x8bit.bitwarden.data.autofill.BitwardenAutofillService
+import com.x8bit.bitwarden.data.autofill.fido2.BitwardenFido2ProviderService
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
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
@@ -16,9 +20,10 @@ private const val LEGACY_AUTOFILL_SERVICE_NAME = "com.x8bit.bitwarden.Autofill.A
@OmitFromCoverage
class BitwardenAppComponentFactory : AppComponentFactory() {
/**
- * Used to intercept when the [BitwardenAutofillService] is being instantiated and modify which
- * service is created. This is required because the [className] used in the manifest must match
- * the legacy Xamarin app service name but the service name in this app is different.
+ * Used to intercept when the [BitwardenAutofillService] or [BitwardenFido2ProviderService] is
+ * being instantiated and modify which service is created. This is required because the
+ * [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(
cl: ClassLoader,
@@ -29,6 +34,20 @@ class BitwardenAppComponentFactory : AppComponentFactory() {
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)
}
}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/BitwardenFido2ProviderService.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/BitwardenFido2ProviderService.kt
new file mode 100644
index 000000000..0196cd6ad
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/BitwardenFido2ProviderService.kt
@@ -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,
+ ) {
+ processor.processCreateCredentialRequest(
+ request,
+ cancellationSignal,
+ callback,
+ )
+ }
+
+ override fun onBeginGetCredentialRequest(
+ request: BeginGetCredentialRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver,
+ ) {
+ processor.processGetCredentialRequest(
+ request,
+ cancellationSignal,
+ callback,
+ )
+ }
+
+ override fun onClearCredentialStateRequest(
+ request: ProviderClearCredentialStateRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver,
+ ) {
+ processor.processClearCredentialStateRequest(
+ request,
+ cancellationSignal,
+ callback,
+ )
+ }
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt
new file mode 100644
index 000000000..8d095981a
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt
@@ -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,
+ )
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessor.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessor.kt
new file mode 100644
index 000000000..d88a2b331
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessor.kt
@@ -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,
+ )
+
+ /**
+ * 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,
+ )
+
+ /**
+ * 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,
+ )
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorImpl.kt
new file mode 100644
index 000000000..404d555f0
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorImpl.kt
@@ -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,
+ ) {
+ 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.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,
+ ) {
+ // no-op: RFU
+ callback.onError(GetCredentialUnsupportedException())
+ }
+
+ override fun processClearCredentialStateRequest(
+ request: ProviderClearCredentialStateRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver,
+ ) {
+ // no-op: RFU
+ callback.onError(ClearCredentialUnsupportedException())
+ }
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt
index 3f502005a..34bf86d9c 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt
@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.manager.intent
+import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
import android.os.Parcelable
@@ -80,6 +81,16 @@ interface IntentManager {
*/
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.
*/
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt
index 684c2a742..20b9a85fe 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt
@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.manager.intent
import android.app.Activity
+import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Context
@@ -20,6 +21,7 @@ import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.x8bit.bitwarden.BuildConfig
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.ui.platform.util.toFormattedPattern
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"
+/**
+ * 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
* Intents within a given context.
@@ -185,6 +194,23 @@ class IntentManagerImpl(
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 {
val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR)
val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME)
diff --git a/app/src/standardDebug/res/xml/provider.xml b/app/src/standardDebug/res/xml/provider.xml
new file mode 100644
index 000000000..395d04812
--- /dev/null
+++ b/app/src/standardDebug/res/xml/provider.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorTest.kt
new file mode 100644
index 000000000..9fcf258e2
--- /dev/null
+++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorTest.kt
@@ -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(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 =
+ mockk()
+ val captureSlot = slot()
+ 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 =
+ mockk()
+ val captureSlot = slot()
+ 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 =
+ mockk()
+ val captureSlot = slot()
+ 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 =
+ mockk()
+ val captureSlot = slot()
+ 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 =
+ mockk()
+ val captureSlot = slot()
+ 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 =
+ mockk()
+ mutableUserStateFlow.value = DEFAULT_USER_STATE
+ val captureSlot = slot()
+ 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 {
+ val accounts = mutableListOf()
+ 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
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d79e8db64..b069edadc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,6 +14,7 @@ androidxBrowser = "1.8.0"
androidxCamera = "1.3.3"
androidxComposeBom = "2024.05.00"
androidxCore = "1.13.1"
+androidxCredentials = "1.2.2"
androidxHiltNavigationCompose = "1.2.0"
androidxLifecycle = "2.7.0"
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-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
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-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }