mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
PM-5153: Implement FIDO2 credential provider service (passkey creation entries) (#1370)
This commit is contained in:
parent
ae59d32f3b
commit
bd8124ec9e
13 changed files with 674 additions and 3 deletions
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
43
app/src/debug/AndroidManifest.xml
Normal file
43
app/src/debug/AndroidManifest.xml
Normal 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>
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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>,
|
||||
)
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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)
|
||||
|
|
6
app/src/standardDebug/res/xml/provider.xml
Normal file
6
app/src/standardDebug/res/xml/provider.xml
Normal 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>
|
|
@ -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
|
||||
}
|
|
@ -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" }
|
||||
|
|
Loading…
Reference in a new issue