Skip to content

Commit

Permalink
Implement passkeys, docs pending
Browse files Browse the repository at this point in the history
  • Loading branch information
itaihanski committed Jan 16, 2024
1 parent 02bed6f commit c1e8e80
Show file tree
Hide file tree
Showing 16 changed files with 842 additions and 15 deletions.
15 changes: 8 additions & 7 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ buildscript {
}

dependencies {
classpath 'com.android.tools.build:gradle:8.2.0'
classpath 'com.android.tools.build:gradle:8.2.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
Expand All @@ -25,6 +25,7 @@ apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'

android {
namespace "com.descope.flutter"
compileSdk 34

compileOptions {
Expand All @@ -47,11 +48,12 @@ android {
}

dependencies {
api "androidx.browser:browser:1.7.0"
api "androidx.security:security-crypto:1.0.0"
api "androidx.credentials:credentials:1.2.0"
api "androidx.credentials:credentials-play-services-auth:1.2.0"
api "com.google.android.libraries.identity.googleid:googleid:1.1.0"
implementation "androidx.browser:browser:1.7.0"
implementation "androidx.security:security-crypto:1.0.0"
implementation "androidx.credentials:credentials:1.2.0"
implementation "androidx.credentials:credentials-play-services-auth:1.2.0"
implementation "com.google.android.libraries.identity.googleid:googleid:1.1.0"
implementation "com.google.android.gms:play-services-fido:20.1.0"
}

testOptions {
Expand All @@ -66,4 +68,3 @@ android {
}
}
}

9 changes: 9 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.descope.flutter">
<application>
<activity
android:name=".DescopeHelperActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:enabled="true"
android:exported="false"
android:fitsSystemWindows="true"
android:theme="@style/Theme.Hidden" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.descope.flutter

import android.app.Activity
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Bundle

const val PENDING_INTENT_KEY = "pendingIntent"
const val REQUEST_CODE = 4327

class DescopeHelperActivity : Activity() {
private var resultPending = false

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@Suppress("DEPRECATION")
val pendingIntent: PendingIntent? = intent?.getParcelableExtra(PENDING_INTENT_KEY)
if (pendingIntent == null) {
finish()
return
}

if (resultPending) {
finish()
return
}

resultPending = true
startIntentSenderForResult(pendingIntent.intentSender, REQUEST_CODE, null, 0, 0, 0, null)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
resultPending = false
activityHelper.onActivityResult(resultCode, data)
finish()
}
}

// Helper

interface ActivityHelper {
fun startHelperActivity(context: Context, pendingIntent: PendingIntent, callback: (Int, Intent?) -> Unit)
fun onActivityResult(resultCode: Int, intent: Intent?)
}

internal val activityHelper = object : ActivityHelper {
private var callback: (Int, Intent?) -> Unit = { _, _ -> }

override fun startHelperActivity(context: Context, pendingIntent: PendingIntent, callback: (Int, Intent?) -> Unit) {
this.callback = callback
(context as? Activity)?.let { activity ->
activity.startActivity(Intent(activity, DescopeHelperActivity::class.java).apply { putExtra(PENDING_INTENT_KEY, pendingIntent) })
return
}
throw Exception("Passkeys require the given context to be an Activity")
}

override fun onActivityResult(resultCode: Int, intent: Intent?) {
callback(resultCode, intent)
}
}
140 changes: 140 additions & 0 deletions android/src/main/kotlin/com/descope/flutter/DescopePlugin.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.descope.flutter

import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.security.crypto.EncryptedSharedPreferences
Expand All @@ -12,6 +14,14 @@ import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import com.google.android.gms.fido.Fido
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse
import com.google.android.gms.fido.fido2.api.common.ErrorCode.ABORT_ERR
import com.google.android.gms.fido.fido2.api.common.ErrorCode.TIMEOUT_ERR
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredential
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
Expand All @@ -36,6 +46,9 @@ class DescopePlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
when (call.method) {
"startFlow" -> startFlow(call, result)
"oauthNative" -> oauthNative(call, result)
"passkeyOrigin" -> passkeyOrigin(result)
"passkeyCreate" -> createPasskey(call, result)
"passkeyAuthenticate" -> usePasskey(call, result)
"loadItem" -> loadItem(call, result)
"saveItem" -> saveItem(call, result)
"removeItem" -> removeItem(call, result)
Expand Down Expand Up @@ -115,6 +128,133 @@ class DescopePlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
credentialManager.getCredentialAsync(context, request, null, Runnable::run, callback)
}

// Passkeys

private fun passkeyOrigin(res: Result) {
val context = this.context ?: return res.error("NULLCONTEXT", "Context is null", null)
try {
val origin = getPackageOrigin(context)
res.success(origin)
} catch (e: Exception) {
res.error("FAILED", "Context is null", null)
}
}

private fun createPasskey(call: MethodCall, res: Result) {
val context = this.context ?: return res.error("NULLCONTEXT", "Context is null", null)
val options = call.argument<String>("options") ?: return res.error("MISSINGARGS", "'options' is required for createPasskey", null)
performRegister(context, options) { pendingIntent, e ->
if (e != null) {
res.error("FAILED", e.message, null)
} else if (pendingIntent != null) {
activityHelper.startHelperActivity(context, pendingIntent) { code, intent ->
try {
val json = prepareRegisterResponse(code, intent)
res.success(json)
} catch (e: Exception) {
res.error("FAILED", e.message, null)
}
}
} else {
res.error("FAILED", "Unxepected result when registering passkey", null)
}
}
}

private fun usePasskey(call: MethodCall, res: Result) {
val context = this.context ?: return res.error("NULLCONTEXT", "Context is null", null)
val options = call.argument<String>("options") ?: return res.error("MISSINGARGS", "'options' is required for usePasskey", null)
performAssertion(context, options) { pendingIntent, e ->
if (e != null) {
res.error("FAILED", e.message, null)
} else if (pendingIntent != null) {
activityHelper.startHelperActivity(context, pendingIntent) { code, intent ->
try {
val json = prepareAssertionResponse(code, intent)
res.success(json)
} catch (e: Exception) {
res.error("FAILED", e.message, null)
}
}
} else {
res.error("FAILED", "Unxepected result when registering passkey", null)
}
}
}

private fun performRegister(context: Context, options: String, callback: (PendingIntent?, Exception?) -> Unit) {
val client = Fido.getFido2ApiClient(context)
val opts = parsePublicKeyCredentialCreationOptions(convertOptions(options))
val task = client.getRegisterPendingIntent(opts)
task.addOnSuccessListener { callback(it, null) }
task.addOnFailureListener { callback(null, it) }
}

private fun performAssertion(context: Context, options: String, callback: (PendingIntent?, Exception?) -> Unit) {
val client = Fido.getFido2ApiClient(context)
val opts = parsePublicKeyCredentialRequestOptions(convertOptions(options))
val task = client.getSignPendingIntent(opts)
task.addOnSuccessListener { callback(it, null) }
task.addOnFailureListener { callback(null, it) }
}

private fun prepareRegisterResponse(resultCode: Int, intent: Intent?): String {
val credential = extractCredential(resultCode, intent)
val rawId = credential.rawId.toBase64()
val response = credential.response as AuthenticatorAttestationResponse
return JSONObject().apply {
put("id", rawId)
put("type", PublicKeyCredentialType.PUBLIC_KEY.toString())
put("rawId", rawId)
put("response", JSONObject().apply {
put("clientDataJson", response.clientDataJSON.toBase64())
put("attestationObject", response.attestationObject.toBase64())
})
}.toString()
}

private fun prepareAssertionResponse(resultCode: Int, intent: Intent?): String {
val credential = extractCredential(resultCode, intent)
val rawId = credential.rawId.toBase64()
val response = credential.response as AuthenticatorAssertionResponse
return JSONObject().apply {
put("id", rawId)
put("type", PublicKeyCredentialType.PUBLIC_KEY.toString())
put("rawId", rawId)
put("response", JSONObject().apply {
put("clientDataJson", response.clientDataJSON.toBase64())
put("authenticatorData", response.authenticatorData.toBase64())
put("signature", response.signature.toBase64())
response.userHandle?.let { put("userHandle", it.toBase64()) }
})
}.toString()
}

private fun extractCredential(resultCode: Int, intent: Intent?): PublicKeyCredential {
// check general response
if (resultCode == RESULT_CANCELED) throw Exception("Passkey canceled")
if (intent == null) throw Exception("Null intent received from ")

// get the credential from the intent extra
val credential = try {
val byteArray = intent.getByteArrayExtra("FIDO2_CREDENTIAL_EXTRA")!!
PublicKeyCredential.deserializeFromBytes(byteArray)
} catch (e: Exception) {
throw Exception("Failed to extract credential from intent")
}

// check for any logical failures
(credential.response as? AuthenticatorErrorResponse)?.run {
when (errorCode) {
ABORT_ERR -> throw Exception("Passkey canceled")
TIMEOUT_ERR -> throw Exception("The operation timed out")
else -> throw Exception("Passkey authentication failed (${errorCode.name}: $errorMessage)")
}
}

return credential
}

// Storage

@Suppress("UNUSED_PARAMETER")
Expand Down
Loading

0 comments on commit c1e8e80

Please sign in to comment.