@clerk/expo-biometrics 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/android/build.gradle +49 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialCoding.kt +147 -0
  6. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialFileStore.kt +304 -0
  7. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialStore.kt +96 -0
  8. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricKeyManager.kt +216 -0
  9. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricsError.kt +58 -0
  10. package/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt +139 -0
  11. package/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStorageContractTest.kt +450 -0
  12. package/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStoreTest.kt +179 -0
  13. package/android/src/test/java/expo/modules/clerk/biometrics/BiometricKeyManagerTest.kt +49 -0
  14. package/android/src/test/resources/biometric-credential-storage/v2/biometric_credentials.v2.json +50 -0
  15. package/android/src/test/resources/robolectric.properties +1 -0
  16. package/dist/ClerkExpoBiometricsModule.d.ts +18 -0
  17. package/dist/ClerkExpoBiometricsModule.d.ts.map +1 -0
  18. package/dist/ClerkExpoBiometricsModule.js +26 -0
  19. package/dist/ClerkExpoBiometricsModule.js.map +1 -0
  20. package/dist/errors.d.ts +11 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/errors.js +75 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/index.d.ts +57 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +204 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/types.d.ts +85 -0
  29. package/dist/types.d.ts.map +1 -0
  30. package/dist/types.js +17 -0
  31. package/dist/types.js.map +1 -0
  32. package/expo-module.config.json +9 -0
  33. package/ios/BiometricCredentialCoding.swift +335 -0
  34. package/ios/BiometricCredentialStore.swift +230 -0
  35. package/ios/BiometricKeyManager.swift +228 -0
  36. package/ios/BiometricsError.swift +117 -0
  37. package/ios/ClerkExpoBiometrics.podspec +32 -0
  38. package/ios/ClerkExpoBiometricsModule.swift +164 -0
  39. package/ios/Tests/BiometricCredentialContractTests.swift +286 -0
  40. package/ios/Tests/BiometricCredentialStoreTests.swift +323 -0
  41. package/ios/Tests/BiometricSystemIntegrationTests.swift +169 -0
  42. package/ios/Tests/Resources/BiometricCredentialStorageContractV1.json +31 -0
  43. package/package.json +53 -0
@@ -0,0 +1,96 @@
1
+ package expo.modules.clerk.biometrics
2
+
3
+ import org.json.JSONArray
4
+ import org.json.JSONObject
5
+
6
+ /** The record shape the JS API uses, before the identifier hint is hashed for storage. */
7
+ internal data class BiometricCredentialRecordInput(
8
+ val id: String,
9
+ val localKeyId: String,
10
+ val userId: String,
11
+ val appIdentifier: String,
12
+ val identifierHint: String?,
13
+ val policy: String,
14
+ val createdAt: Double,
15
+ val updatedAt: Double,
16
+ ) {
17
+ fun toLocalRecord(): BiometricCredentialLocalRecord {
18
+ for ((name, value) in listOf("id" to id, "localKeyId" to localKeyId, "userId" to userId, "appIdentifier" to appIdentifier)) {
19
+ if (value.isEmpty()) {
20
+ throw BiometricsError(BiometricsErrorCode.INVALID_ARGUMENT, "record.$name must be a non-empty string.")
21
+ }
22
+ }
23
+ val policy =
24
+ BiometricCredentialPolicy.fromValue(policy)
25
+ ?: throw BiometricsError(BiometricsErrorCode.INVALID_ARGUMENT, "Unknown biometric credential policy '$policy'.")
26
+ if (!isValidTimestamp(createdAt) || !isValidTimestamp(updatedAt)) {
27
+ throw BiometricsError(
28
+ BiometricsErrorCode.INVALID_ARGUMENT,
29
+ "record.createdAt and record.updatedAt must be milliseconds since the Unix epoch.",
30
+ )
31
+ }
32
+ return BiometricCredentialLocalRecord(
33
+ id = id,
34
+ localKeyId = localKeyId,
35
+ userId = userId,
36
+ appIdentifier = appIdentifier,
37
+ identifierHintSha256 = BiometricCredentialCoding.hashIdentifierHint(identifierHint),
38
+ policy = policy,
39
+ createdAt = Math.round(createdAt),
40
+ updatedAt = Math.round(updatedAt),
41
+ )
42
+ }
43
+
44
+ private fun isValidTimestamp(value: Double) = value.isFinite() && value >= 0 && value < 9.0e15
45
+ }
46
+
47
+ /** The store operations exposed to JS, on top of the contract v2 file and the Keystore. */
48
+ internal class BiometricCredentialStore(
49
+ private val fileStore: BiometricCredentialFileStore,
50
+ private val deleteKey: (String) -> Unit,
51
+ ) {
52
+ /** Every decodable record for every app, mapped to the JS field names, with unknown stored fields passed through. */
53
+ fun listRecordsJson(): String = JSONArray(fileStore.records().map(::bridgeJson)).toString()
54
+
55
+ fun save(input: BiometricCredentialRecordInput, removeOtherRecordsForApp: Boolean) {
56
+ val record = input.toLocalRecord()
57
+ fileStore.save(record)?.let { runCatching { deleteKey(it) } }
58
+ if (!removeOtherRecordsForApp) return
59
+
60
+ // Contract v2 section 5.3: only the same user's other credentials; other users' are left for reconciliation.
61
+ val removable =
62
+ fileStore
63
+ .records()
64
+ .map { it.record }
65
+ .filter { it.appIdentifier == record.appIdentifier && it.userId == record.userId && it.id != record.id }
66
+ .filter { it.localKeyId == record.localKeyId || runCatching { deleteKey(it.localKeyId) }.isSuccess }
67
+ .map { it.id }
68
+ runCatching { fileStore.delete(removable.toSet()) }
69
+ }
70
+
71
+ /** Deletes the key, then every record that references it. When the key cannot be deleted the records are kept. */
72
+ fun deleteRecords(localKeyId: String) {
73
+ deleteKey(localKeyId)
74
+ fileStore.delete(fileStore.records().filter { it.record.localKeyId == localKeyId }.map { it.record.id }.toSet())
75
+ }
76
+
77
+ companion object {
78
+ fun bridgeJson(stored: StoredBiometricCredentialRecord): JSONObject {
79
+ val json = JSONObject()
80
+ for (key in stored.json.keys()) {
81
+ if (key !in BiometricCredentialRecordJson.KNOWN_FIELDS) json.put(key, stored.json.get(key))
82
+ }
83
+ val record = stored.record
84
+ json.put("id", record.id)
85
+ json.put("localKeyId", record.localKeyId)
86
+ json.put("userId", record.userId)
87
+ json.put("appIdentifier", record.appIdentifier)
88
+ json.put("identifierHint", JSONObject.NULL)
89
+ json.put("identifierHintSha256", record.identifierHintSha256 ?: JSONObject.NULL)
90
+ json.put("policy", record.policy.value)
91
+ json.put("createdAt", record.createdAt)
92
+ json.put("updatedAt", record.updatedAt)
93
+ return json
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,216 @@
1
+ package expo.modules.clerk.biometrics
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import android.security.keystore.KeyInfo
6
+ import android.security.keystore.KeyPermanentlyInvalidatedException
7
+ import android.security.keystore.KeyProperties
8
+ import androidx.annotation.RequiresApi
9
+ import androidx.biometric.BiometricManager
10
+ import androidx.biometric.BiometricManager.Authenticators
11
+ import androidx.biometric.BiometricPrompt
12
+ import androidx.core.content.ContextCompat
13
+ import androidx.fragment.app.FragmentActivity
14
+ import java.security.KeyFactory
15
+ import java.security.KeyPairGenerator
16
+ import java.security.KeyStore
17
+ import java.security.PrivateKey
18
+ import java.security.Signature
19
+ import java.security.interfaces.ECPublicKey
20
+ import java.util.concurrent.atomic.AtomicBoolean
21
+
22
+ internal data class BiometricAvailability(
23
+ val biometryType: String,
24
+ val canEvaluateBiometrics: Boolean,
25
+ val canEvaluateDeviceOwner: Boolean,
26
+ val errorCode: BiometricsErrorCode?,
27
+ val secureKeyStorageAvailable: Boolean,
28
+ )
29
+
30
+ internal data class BiometricCredentialKey(val localKeyId: String, val publicKeyJwk: String)
31
+
32
+ /** Android Keystore keys laid out as clerk-android's `DefaultBiometricCredentialKeyManager` creates them. */
33
+ internal class BiometricKeyManager {
34
+ fun availability(context: Context): BiometricAvailability {
35
+ if (!secureKeyStorageAvailable()) {
36
+ return BiometricAvailability("none", false, false, BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE, false)
37
+ }
38
+ val manager = BiometricManager.from(context)
39
+ val strong = manager.canAuthenticate(Authenticators.BIOMETRIC_STRONG)
40
+ val canEvaluateBiometrics = strong == BiometricManager.BIOMETRIC_SUCCESS
41
+ // Keys only accept device credentials on API 30+; before that, device owner authentication cannot sign.
42
+ val canEvaluateDeviceOwner =
43
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
44
+ manager.canAuthenticate(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL) ==
45
+ BiometricManager.BIOMETRIC_SUCCESS
46
+ } else {
47
+ canEvaluateBiometrics
48
+ }
49
+ return BiometricAvailability(
50
+ biometryType = biometryType(strong),
51
+ canEvaluateBiometrics = canEvaluateBiometrics,
52
+ canEvaluateDeviceOwner = canEvaluateDeviceOwner,
53
+ errorCode = if (canEvaluateBiometrics) null else BiometricsError.forCanAuthenticate(strong).code,
54
+ secureKeyStorageAvailable = true,
55
+ )
56
+ }
57
+
58
+ fun createKey(context: Context, policy: BiometricCredentialPolicy): BiometricCredentialKey {
59
+ if (!secureKeyStorageAvailable()) {
60
+ throw BiometricsError(
61
+ BiometricsErrorCode.SECURE_KEY_STORAGE_UNAVAILABLE,
62
+ "Biometric credential keys require Android 9 (API 28) or later.",
63
+ )
64
+ }
65
+ val status = BiometricManager.from(context).canAuthenticate(promptAuthenticators(policy))
66
+ if (status != BiometricManager.BIOMETRIC_SUCCESS) {
67
+ throw BiometricsError.forCanAuthenticate(status)
68
+ }
69
+
70
+ val localKeyId = BiometricCredentialCoding.makeLocalKeyId()
71
+ try {
72
+ val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEY_STORE)
73
+ generator.initialize(BiometricCredentialCoding.keyGenParameterSpec(localKeyId, policy))
74
+ val publicKey = generator.generateKeyPair().public as ECPublicKey
75
+ return BiometricCredentialKey(
76
+ localKeyId = localKeyId,
77
+ publicKeyJwk = BiometricCredentialCoding.publicKeyJwk(publicKey.w.affineX, publicKey.w.affineY),
78
+ )
79
+ } catch (e: Exception) {
80
+ runCatching { deleteKey(localKeyId) }
81
+ throw BiometricsError(
82
+ BiometricsErrorCode.KEY_GENERATION_FAILED,
83
+ e.message ?: "Unable to create the biometric credential key.",
84
+ e,
85
+ )
86
+ }
87
+ }
88
+
89
+ fun hasKey(localKeyId: String): Boolean =
90
+ storageCall { keyStore().containsAlias(BiometricCredentialCoding.keyAlias(localKeyId)) }
91
+
92
+ fun deleteKey(localKeyId: String) =
93
+ storageCall {
94
+ val keyStore = keyStore()
95
+ val alias = BiometricCredentialCoding.keyAlias(localKeyId)
96
+ if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
97
+ }
98
+
99
+ /** Shows the biometric prompt on [activity] and signs the UTF-8 bytes of [clientData] with the unlocked key. */
100
+ fun sign(
101
+ activity: FragmentActivity,
102
+ localKeyId: String,
103
+ clientData: String,
104
+ reason: String?,
105
+ onResult: (Result<String>) -> Unit,
106
+ ) {
107
+ requireSupportedSdk()
108
+ val privateKey =
109
+ storageCall { keyStore().getKey(BiometricCredentialCoding.keyAlias(localKeyId), null) as? PrivateKey }
110
+ ?: throw BiometricsError(BiometricsErrorCode.KEY_NOT_FOUND, "The biometric credential key was not found.")
111
+ val signature =
112
+ try {
113
+ Signature.getInstance(BiometricCredentialCoding.SIGNATURE_ALGORITHM).apply { initSign(privateKey) }
114
+ } catch (e: KeyPermanentlyInvalidatedException) {
115
+ throw BiometricsError(BiometricsErrorCode.KEY_INVALIDATED, "The biometric credential key was invalidated.", e)
116
+ } catch (e: Exception) {
117
+ throw BiometricsError(BiometricsErrorCode.SIGNING_FAILED, e.message ?: "Unable to initialize signing.", e)
118
+ }
119
+ val allowsDeviceCredential = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && keyAllowsDeviceCredential(privateKey)
120
+ val title = reason?.takeIf { it.isNotBlank() } ?: activity.applicationInfo.loadLabel(activity.packageManager).toString()
121
+
122
+ val completed = AtomicBoolean(false)
123
+ val complete = { result: Result<String> -> if (completed.compareAndSet(false, true)) onResult(result) }
124
+ val callback =
125
+ object : BiometricPrompt.AuthenticationCallback() {
126
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
127
+ val unlocked = result.cryptoObject?.signature ?: signature
128
+ complete(
129
+ runCatching {
130
+ unlocked.update(clientData.toByteArray(Charsets.UTF_8))
131
+ BiometricCredentialCoding.base64UrlEncode(BiometricCredentialCoding.rawES256SignatureFromDer(unlocked.sign()))
132
+ }.recoverCatching { e ->
133
+ throw e as? BiometricsError
134
+ ?: BiometricsError(BiometricsErrorCode.SIGNING_FAILED, e.message ?: "Unable to sign the challenge.", e)
135
+ }
136
+ )
137
+ }
138
+
139
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
140
+ complete(Result.failure(BiometricsError.forPromptError(errorCode, errString)))
141
+ }
142
+ }
143
+
144
+ activity.runOnUiThread {
145
+ try {
146
+ val promptInfo =
147
+ BiometricPrompt.PromptInfo.Builder().setTitle(title).apply {
148
+ if (allowsDeviceCredential) {
149
+ setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL)
150
+ } else {
151
+ setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG)
152
+ setNegativeButtonText(activity.getString(android.R.string.cancel))
153
+ }
154
+ }.build()
155
+ BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
156
+ .authenticate(promptInfo, BiometricPrompt.CryptoObject(signature))
157
+ } catch (e: Exception) {
158
+ complete(
159
+ Result.failure(
160
+ BiometricsError(BiometricsErrorCode.AUTHENTICATION_FAILED, e.message ?: "Unable to show the prompt.", e)
161
+ )
162
+ )
163
+ }
164
+ }
165
+ }
166
+
167
+ // The key's own protections decide whether a device credential can unlock it, so keys created for
168
+ // biometry_or_device_passcode on API 28-29 stay biometric-only after an OS upgrade.
169
+ @RequiresApi(Build.VERSION_CODES.R)
170
+ private fun keyAllowsDeviceCredential(key: PrivateKey): Boolean =
171
+ runCatching {
172
+ val info = KeyFactory.getInstance(key.algorithm, ANDROID_KEY_STORE).getKeySpec(key, KeyInfo::class.java)
173
+ info.userAuthenticationType and KeyProperties.AUTH_DEVICE_CREDENTIAL != 0
174
+ }.getOrDefault(false)
175
+
176
+ private fun requireSupportedSdk() {
177
+ if (Build.VERSION.SDK_INT < BiometricCredentialCoding.MIN_SDK) {
178
+ throw BiometricsError(BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE, "Biometric credentials require Android 9 (API 28) or later.")
179
+ }
180
+ }
181
+
182
+ private fun keyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
183
+
184
+ private fun <T> storageCall(body: () -> T): T =
185
+ try {
186
+ body()
187
+ } catch (e: BiometricsError) {
188
+ throw e
189
+ } catch (e: Exception) {
190
+ throw BiometricsError(BiometricsErrorCode.STORAGE_FAILED, e.message ?: "Android Keystore access failed.", e)
191
+ }
192
+
193
+ companion object {
194
+ private const val ANDROID_KEY_STORE = "AndroidKeyStore"
195
+
196
+ fun secureKeyStorageAvailable(sdkInt: Int = Build.VERSION.SDK_INT): Boolean = sdkInt >= BiometricCredentialCoding.MIN_SDK
197
+
198
+ /** Contract v2 section 2: device credentials only for biometry_or_device_passcode, and only on API 30+. */
199
+ fun promptAuthenticators(policy: BiometricCredentialPolicy, sdkInt: Int = Build.VERSION.SDK_INT): Int =
200
+ if (policy == BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE && sdkInt >= Build.VERSION_CODES.R) {
201
+ Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL
202
+ } else {
203
+ Authenticators.BIOMETRIC_STRONG
204
+ }
205
+
206
+ /** Android does not report which biometric is Class 3, so any present strong biometric is `biometric`. */
207
+ fun biometryType(canAuthenticateStrong: Int): String =
208
+ when (canAuthenticateStrong) {
209
+ BiometricManager.BIOMETRIC_SUCCESS,
210
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED,
211
+ BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE,
212
+ BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> "biometric"
213
+ else -> "none"
214
+ }
215
+ }
216
+ }
@@ -0,0 +1,58 @@
1
+ package expo.modules.clerk.biometrics
2
+
3
+ import androidx.biometric.BiometricManager
4
+ import androidx.biometric.BiometricPrompt
5
+
6
+ internal enum class BiometricsErrorCode(val value: String) {
7
+ USER_CANCELED("user_canceled"),
8
+ SYSTEM_CANCELED("system_canceled"),
9
+ AUTHENTICATION_FAILED("authentication_failed"),
10
+ BIOMETRY_NOT_AVAILABLE("biometry_not_available"),
11
+ BIOMETRY_NOT_ENROLLED("biometry_not_enrolled"),
12
+ BIOMETRY_LOCKOUT("biometry_lockout"),
13
+ PASSCODE_NOT_SET("passcode_not_set"),
14
+ SECURE_KEY_STORAGE_UNAVAILABLE("secure_key_storage_unavailable"),
15
+ KEY_NOT_FOUND("key_not_found"),
16
+ KEY_INVALIDATED("key_invalidated"),
17
+ KEY_GENERATION_FAILED("key_generation_failed"),
18
+ SIGNING_FAILED("signing_failed"),
19
+ STORAGE_FAILED("storage_failed"),
20
+ INVALID_ARGUMENT("invalid_argument"),
21
+ }
22
+
23
+ internal class BiometricsError(
24
+ val code: BiometricsErrorCode,
25
+ message: String,
26
+ cause: Throwable? = null,
27
+ ) : Exception(message, cause) {
28
+ companion object {
29
+ fun forCanAuthenticate(status: Int): BiometricsError =
30
+ when (status) {
31
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
32
+ BiometricsError(BiometricsErrorCode.BIOMETRY_NOT_ENROLLED, "No biometrics are enrolled on this device.")
33
+ else ->
34
+ BiometricsError(
35
+ BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE,
36
+ "Strong biometric authentication is not available on this device (status $status).",
37
+ )
38
+ }
39
+
40
+ fun forPromptError(errorCode: Int, message: CharSequence?): BiometricsError {
41
+ val code =
42
+ when (errorCode) {
43
+ BiometricPrompt.ERROR_USER_CANCELED,
44
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON -> BiometricsErrorCode.USER_CANCELED
45
+ BiometricPrompt.ERROR_CANCELED -> BiometricsErrorCode.SYSTEM_CANCELED
46
+ BiometricPrompt.ERROR_LOCKOUT,
47
+ BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> BiometricsErrorCode.BIOMETRY_LOCKOUT
48
+ BiometricPrompt.ERROR_NO_BIOMETRICS -> BiometricsErrorCode.BIOMETRY_NOT_ENROLLED
49
+ BiometricPrompt.ERROR_HW_NOT_PRESENT,
50
+ BiometricPrompt.ERROR_HW_UNAVAILABLE,
51
+ BiometricPrompt.ERROR_SECURITY_UPDATE_REQUIRED -> BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE
52
+ BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> BiometricsErrorCode.PASSCODE_NOT_SET
53
+ else -> BiometricsErrorCode.AUTHENTICATION_FAILED
54
+ }
55
+ return BiometricsError(code, message?.toString() ?: "Biometric authentication failed (error $errorCode).")
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,139 @@
1
+ package expo.modules.clerk.biometrics
2
+
3
+ import android.content.Context
4
+ import androidx.fragment.app.FragmentActivity
5
+ import expo.modules.kotlin.Promise
6
+ import expo.modules.kotlin.exception.CodedException
7
+ import expo.modules.kotlin.modules.Module
8
+ import expo.modules.kotlin.modules.ModuleDefinition
9
+ import expo.modules.kotlin.records.Field
10
+ import expo.modules.kotlin.records.Record
11
+
12
+ class ClerkBiometricsException internal constructor(error: BiometricsError) :
13
+ CodedException(error.code.value, error.message, error.cause)
14
+
15
+ class BiometricCredentialRecordArgument : Record {
16
+ @Field var id: String = ""
17
+ @Field var localKeyId: String = ""
18
+ @Field var userId: String = ""
19
+ @Field var appIdentifier: String = ""
20
+ @Field var identifierHint: String? = null
21
+ @Field var policy: String = ""
22
+ @Field var createdAt: Double = -1.0
23
+ @Field var updatedAt: Double = -1.0
24
+ }
25
+
26
+ class SaveRecordOptionsArgument : Record {
27
+ @Field var removeOtherRecordsForApp: Boolean = false
28
+ }
29
+
30
+ class ClerkExpoBiometricsModule : Module() {
31
+ private val keyManager = BiometricKeyManager()
32
+ private var cachedStore: BiometricCredentialStore? = null
33
+
34
+ override fun definition() = ModuleDefinition {
35
+ Name("ClerkExpoBiometrics")
36
+
37
+ Function("getAppIdentifier") { -> context().packageName }
38
+
39
+ Function("hashIdentifierHint") { hint: String -> BiometricCredentialCoding.hashIdentifierHint(hint) }
40
+
41
+ AsyncFunction("getAvailability") { ->
42
+ val availability = keyManager.availability(context())
43
+ mapOf(
44
+ "biometryType" to availability.biometryType,
45
+ "canEvaluateBiometrics" to availability.canEvaluateBiometrics,
46
+ "canEvaluateDeviceOwner" to availability.canEvaluateDeviceOwner,
47
+ "errorCode" to availability.errorCode?.value,
48
+ "secureKeyStorageAvailable" to availability.secureKeyStorageAvailable,
49
+ )
50
+ }
51
+
52
+ AsyncFunction("createKey") { policy: String ->
53
+ bridge {
54
+ val parsed =
55
+ BiometricCredentialPolicy.fromValue(policy)
56
+ ?: throw BiometricsError(BiometricsErrorCode.INVALID_ARGUMENT, "Unknown biometric credential policy '$policy'.")
57
+ val key = keyManager.createKey(context(), parsed)
58
+ mapOf("localKeyId" to key.localKeyId, "publicKeyJwk" to key.publicKeyJwk)
59
+ }
60
+ }
61
+
62
+ AsyncFunction("sign") { localKeyId: String, clientData: String, reason: String?, promise: Promise ->
63
+ try {
64
+ val activity =
65
+ appContext.currentActivity as? FragmentActivity
66
+ ?: throw BiometricsError(BiometricsErrorCode.SIGNING_FAILED, "Signing requires a foreground FragmentActivity.")
67
+ keyManager.sign(activity, localKeyId, clientData, reason) { result ->
68
+ result.fold(promise::resolve) { error -> promise.reject(codedException(error)) }
69
+ }
70
+ } catch (e: Throwable) {
71
+ promise.reject(codedException(e))
72
+ }
73
+ }
74
+
75
+ AsyncFunction("hasKey") { localKeyId: String -> bridge { keyManager.hasKey(localKeyId) } }
76
+
77
+ AsyncFunction("deleteKey") { localKeyId: String -> bridge { keyManager.deleteKey(localKeyId) } }
78
+
79
+ AsyncFunction("listRecords") { -> storeCall { store().listRecordsJson() } }
80
+
81
+ AsyncFunction("saveRecord") { record: BiometricCredentialRecordArgument, options: SaveRecordOptionsArgument ->
82
+ storeCall {
83
+ val input =
84
+ BiometricCredentialRecordInput(
85
+ id = record.id,
86
+ localKeyId = record.localKeyId,
87
+ userId = record.userId,
88
+ appIdentifier = record.appIdentifier,
89
+ identifierHint = record.identifierHint,
90
+ policy = record.policy,
91
+ createdAt = record.createdAt,
92
+ updatedAt = record.updatedAt,
93
+ )
94
+ store().save(input, options.removeOtherRecordsForApp)
95
+ }
96
+ }
97
+
98
+ AsyncFunction("deleteRecord") { localKeyId: String -> storeCall { store().deleteRecords(localKeyId) } }
99
+
100
+ // Uninstalling wipes both noBackupFilesDir and the app's Keystore keys, so no reinstall marker is needed.
101
+ AsyncFunction("ensureInstallationMarker") { -> mapOf("wiped" to false) }
102
+ }
103
+
104
+ private fun context(): Context =
105
+ appContext.reactContext?.applicationContext ?: throw CodedException("storage_failed", "The React context is not available.", null)
106
+
107
+ @Synchronized
108
+ private fun store(): BiometricCredentialStore =
109
+ cachedStore
110
+ ?: BiometricCredentialStore(
111
+ BiometricCredentialFileStore.inDirectory(context().noBackupFilesDir),
112
+ keyManager::deleteKey,
113
+ ).also { cachedStore = it }
114
+
115
+ private fun <T> bridge(body: () -> T): T =
116
+ try {
117
+ body()
118
+ } catch (e: BiometricsError) {
119
+ throw ClerkBiometricsException(e)
120
+ }
121
+
122
+ private fun <T> storeCall(body: () -> T): T =
123
+ try {
124
+ body()
125
+ } catch (e: CodedException) {
126
+ throw e
127
+ } catch (e: BiometricsError) {
128
+ throw ClerkBiometricsException(e)
129
+ } catch (e: Exception) {
130
+ throw CodedException("storage_failed", e.message ?: "The biometric credential store failed.", e)
131
+ }
132
+
133
+ private fun codedException(error: Throwable): CodedException =
134
+ when (error) {
135
+ is CodedException -> error
136
+ is BiometricsError -> ClerkBiometricsException(error)
137
+ else -> CodedException("unknown", error.message ?: "Biometric signing failed.", error)
138
+ }
139
+ }