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" }