Skip to content

Commit 88f4e55

Browse files
authored
Use credential manager instead of Fido2 for Android passkeys (#117)
1 parent 8f9743b commit 88f4e55

File tree

8 files changed

+85
-266
lines changed

8 files changed

+85
-266
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 0.9.5
2+
3+
- Migrate Android passkey implementation to use `CredentialManager` instead of `Fido2`
4+
15
# 0.9.4
26

37
- Add the ability to explicitly check if passkeys are supported on the device

android/build.gradle

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ apply plugin: 'kotlin-android'
2626

2727
android {
2828
namespace "com.descope.flutter"
29-
compileSdk 34
29+
compileSdk 35
3030

3131
compileOptions {
3232
sourceCompatibility JavaVersion.VERSION_1_8
@@ -44,7 +44,7 @@ android {
4444

4545
defaultConfig {
4646
minSdk 24
47-
targetSdk 33
47+
targetSdk 35
4848
}
4949

5050
dependencies {
@@ -53,7 +53,7 @@ android {
5353
implementation "androidx.credentials:credentials:1.3.0"
5454
implementation "androidx.credentials:credentials-play-services-auth:1.3.0"
5555
implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1"
56-
implementation "com.google.android.gms:play-services-fido:20.1.0"
56+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
5757
}
5858

5959
testOptions {
Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,3 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
22
package="com.descope.flutter">
3-
<application>
4-
<activity
5-
android:name=".DescopeHelperActivity"
6-
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
7-
android:enabled="true"
8-
android:exported="false"
9-
android:fitsSystemWindows="true"
10-
android:theme="@style/Theme.Hidden" />
11-
</application>
123
</manifest>

android/src/main/kotlin/com/descope/flutter/DescopeHelperActivity.kt

Lines changed: 0 additions & 63 deletions
This file was deleted.

android/src/main/kotlin/com/descope/flutter/DescopePlugin.kt

Lines changed: 75 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,40 @@
11
package com.descope.flutter
22

3-
import android.app.PendingIntent
43
import android.content.Context
5-
import android.content.Intent
64
import android.net.Uri
75
import androidx.browser.customtabs.CustomTabsIntent
86
import androidx.security.crypto.EncryptedSharedPreferences
97
import androidx.security.crypto.MasterKeys
8+
import androidx.credentials.CreateCredentialRequest
9+
import androidx.credentials.CreatePublicKeyCredentialRequest
10+
import androidx.credentials.CreatePublicKeyCredentialResponse
1011
import androidx.credentials.CredentialManager
1112
import androidx.credentials.CredentialManagerCallback
1213
import androidx.credentials.CustomCredential
1314
import androidx.credentials.GetCredentialRequest
1415
import androidx.credentials.GetCredentialResponse
15-
import androidx.credentials.exceptions.GetCredentialCancellationException
16+
import androidx.credentials.GetPublicKeyCredentialOption
17+
import androidx.credentials.PublicKeyCredential
1618
import androidx.credentials.exceptions.GetCredentialException
17-
import com.google.android.gms.fido.Fido
18-
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse
19-
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse
20-
import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse
21-
import com.google.android.gms.fido.fido2.api.common.ErrorCode.ABORT_ERR
22-
import com.google.android.gms.fido.fido2.api.common.ErrorCode.TIMEOUT_ERR
23-
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredential
24-
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType
19+
import androidx.credentials.exceptions.CreateCredentialCancellationException
20+
import androidx.credentials.exceptions.CreateCredentialInterruptedException
21+
import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
22+
import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException
23+
import androidx.credentials.exceptions.CreateCredentialUnknownException
24+
import androidx.credentials.exceptions.GetCredentialCancellationException
25+
import androidx.credentials.exceptions.GetCredentialInterruptedException
26+
import androidx.credentials.exceptions.GetCredentialProviderConfigurationException
27+
import androidx.credentials.exceptions.GetCredentialUnknownException
28+
import androidx.credentials.exceptions.NoCredentialException
29+
import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException
30+
import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException
2531
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
2632
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
2733
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
2834
import org.json.JSONObject
35+
import kotlinx.coroutines.Dispatchers
36+
import kotlinx.coroutines.GlobalScope
37+
import kotlinx.coroutines.launch
2938

3039
import io.flutter.embedding.engine.plugins.FlutterPlugin
3140
import io.flutter.embedding.engine.plugins.activity.ActivityAware
@@ -148,18 +157,11 @@ class DescopePlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
148157
private fun createPasskey(call: MethodCall, res: Result) {
149158
val context = this.context ?: return res.error("NULLCONTEXT", "Context is null", null)
150159
val options = call.argument<String>("options") ?: return res.error("MISSINGARGS", "'options' is required for createPasskey", null)
151-
performRegister(context, options) { pendingIntent, e ->
160+
performRegister(context, options) { json, e ->
152161
if (e != null) {
153162
res.error("FAILED", e.message, null)
154-
} else if (pendingIntent != null) {
155-
activityHelper.startHelperActivity(context, pendingIntent) { code, intent ->
156-
try {
157-
val json = prepareRegisterResponse(code, intent)
158-
res.success(json)
159-
} catch (e: Exception) {
160-
res.error("FAILED", e.message, null)
161-
}
162-
}
163+
} else if (json != null) {
164+
res.success(json)
163165
} else {
164166
res.error("FAILED", "Unxepected result when registering passkey", null)
165167
}
@@ -169,95 +171,69 @@ class DescopePlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
169171
private fun usePasskey(call: MethodCall, res: Result) {
170172
val context = this.context ?: return res.error("NULLCONTEXT", "Context is null", null)
171173
val options = call.argument<String>("options") ?: return res.error("MISSINGARGS", "'options' is required for usePasskey", null)
172-
performAssertion(context, options) { pendingIntent, e ->
174+
performAssertion(context, options) { json, e ->
173175
if (e != null) {
174176
res.error("FAILED", e.message, null)
175-
} else if (pendingIntent != null) {
176-
activityHelper.startHelperActivity(context, pendingIntent) { code, intent ->
177-
try {
178-
val json = prepareAssertionResponse(code, intent)
179-
res.success(json)
180-
} catch (e: Exception) {
181-
res.error("FAILED", e.message, null)
182-
}
183-
}
177+
} else if (json != null) {
178+
res.success(json)
184179
} else {
185-
res.error("FAILED", "Unxepected result when registering passkey", null)
180+
res.error("FAILED", "Unxepected result when using passkey", null)
186181
}
187182
}
188183
}
189184

190-
private fun performRegister(context: Context, options: String, callback: (PendingIntent?, Exception?) -> Unit) {
191-
val client = Fido.getFido2ApiClient(context)
192-
val opts = parsePublicKeyCredentialCreationOptions(convertOptions(options))
193-
val task = client.getRegisterPendingIntent(opts)
194-
task.addOnSuccessListener { callback(it, null) }
195-
task.addOnFailureListener { callback(null, it) }
196-
}
197-
198-
private fun performAssertion(context: Context, options: String, callback: (PendingIntent?, Exception?) -> Unit) {
199-
val client = Fido.getFido2ApiClient(context)
200-
val opts = parsePublicKeyCredentialRequestOptions(convertOptions(options))
201-
val task = client.getSignPendingIntent(opts)
202-
task.addOnSuccessListener { callback(it, null) }
203-
task.addOnFailureListener { callback(null, it) }
204-
}
205-
206-
private fun prepareRegisterResponse(resultCode: Int, intent: Intent?): String {
207-
val credential = extractCredential(resultCode, intent)
208-
val rawId = credential.rawId?.toBase64()
209-
val response = credential.response as AuthenticatorAttestationResponse
210-
return JSONObject().apply {
211-
put("id", rawId)
212-
put("type", PublicKeyCredentialType.PUBLIC_KEY.toString())
213-
put("rawId", rawId)
214-
put("response", JSONObject().apply {
215-
put("clientDataJson", response.clientDataJSON.toBase64())
216-
put("attestationObject", response.attestationObject.toBase64())
217-
})
218-
}.toString()
219-
}
220-
221-
private fun prepareAssertionResponse(resultCode: Int, intent: Intent?): String {
222-
val credential = extractCredential(resultCode, intent)
223-
val rawId = credential.rawId?.toBase64()
224-
val response = credential.response as AuthenticatorAssertionResponse
225-
return JSONObject().apply {
226-
put("id", rawId)
227-
put("type", PublicKeyCredentialType.PUBLIC_KEY.toString())
228-
put("rawId", rawId)
229-
put("response", JSONObject().apply {
230-
put("clientDataJson", response.clientDataJSON.toBase64())
231-
put("authenticatorData", response.authenticatorData.toBase64())
232-
put("signature", response.signature.toBase64())
233-
response.userHandle?.let { put("userHandle", it.toBase64()) }
234-
})
235-
}.toString()
236-
}
237-
238-
private fun extractCredential(resultCode: Int, intent: Intent?): PublicKeyCredential {
239-
// check general response
240-
if (resultCode == RESULT_CANCELED) throw Exception("Passkey canceled")
241-
if (intent == null) throw Exception("Null intent received from ")
242-
243-
// get the credential from the intent extra
244-
val credential = try {
245-
val byteArray = intent.getByteArrayExtra("FIDO2_CREDENTIAL_EXTRA")!!
246-
PublicKeyCredential.deserializeFromBytes(byteArray)
247-
} catch (e: Exception) {
248-
throw Exception("Failed to extract credential from intent")
185+
private fun performRegister(context: Context, options: String, callback: (String?, Exception?) -> Unit) {
186+
val publicKey = convertOptions(options)
187+
val request = CreatePublicKeyCredentialRequest(publicKey)
188+
GlobalScope.launch(Dispatchers.Main) {
189+
try {
190+
val credentialManager = CredentialManager.create(context)
191+
val result = credentialManager.createCredential(context, request as CreateCredentialRequest) as CreatePublicKeyCredentialResponse
192+
callback(result.registrationResponseJson, null)
193+
} catch (e: CreateCredentialCancellationException) {
194+
callback(null, Exception("Passkey canceled"))
195+
} catch (e: CreatePublicKeyCredentialDomException) {
196+
callback(null, Exception("Error signing registration"))
197+
} catch (e: CreateCredentialInterruptedException) {
198+
callback(null, Exception("Please try again"))
199+
} catch (e: CreateCredentialProviderConfigurationException) {
200+
callback(null, Exception("Application might be improperly configured"))
201+
} catch (e: CreateCredentialNoCreateOptionException) {
202+
callback(null, Exception("No option to create credentials"))
203+
} catch (e: CreateCredentialUnknownException) {
204+
callback(null, Exception("Unknown failure"))
205+
} catch (e: Exception) {
206+
callback(null, Exception("Unexpected failure"))
207+
}
249208
}
209+
}
250210

251-
// check for any logical failures
252-
(credential.response as? AuthenticatorErrorResponse)?.run {
253-
when (errorCode) {
254-
ABORT_ERR -> throw Exception("Passkey canceled")
255-
TIMEOUT_ERR -> throw Exception("The operation timed out")
256-
else -> throw Exception("Passkey authentication failed (${errorCode.name}: $errorMessage)")
211+
private fun performAssertion(context: Context, options: String, callback: (String?, Exception?) -> Unit) {
212+
val publicKey = convertOptions(options)
213+
val option = GetPublicKeyCredentialOption(publicKey)
214+
val request = GetCredentialRequest(listOf(option))
215+
GlobalScope.launch(Dispatchers.Main) {
216+
try {
217+
val credentialManager = CredentialManager.create(context)
218+
val result = credentialManager.getCredential(context, request)
219+
val credential = result.credential as PublicKeyCredential
220+
callback(credential.authenticationResponseJson, null)
221+
} catch (e: NoCredentialException) {
222+
callback(null, Exception("No available credentials"))
223+
} catch (e: GetCredentialCancellationException) {
224+
callback(null, Exception("Passkey canceled"))
225+
} catch (e: GetPublicKeyCredentialDomException) {
226+
callback(null, Exception("Error signing assertion"))
227+
} catch (e: GetCredentialInterruptedException) {
228+
callback(null, Exception("Please try again"))
229+
} catch (e: GetCredentialProviderConfigurationException) {
230+
callback(null, Exception("Application might be improperly configured"))
231+
} catch (e: GetCredentialUnknownException) {
232+
callback(null, Exception("Unknown failure"))
233+
} catch (e: Exception) {
234+
callback(null, Exception("Unexpected failure"))
257235
}
258236
}
259-
260-
return credential
261237
}
262238

263239
// Storage

0 commit comments

Comments
 (0)