From 3dbc87c731c741669714080c3a654e9bd07efa37 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:13:47 -0400
Subject: [PATCH 1/3] feat(expo-biometrics): add biometric credential native
module
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.changeset/expo-biometrics-package.md | 7 +
.github/workflows/expo-native-build.yml | 10 +-
packages/expo-biometrics/.gitignore | 58 ++++
packages/expo-biometrics/.npmignore | 14 +
packages/expo-biometrics/LICENSE | 21 ++
packages/expo-biometrics/README.md | 84 +++++
packages/expo-biometrics/android/build.gradle | 39 +++
.../android/src/main/AndroidManifest.xml | 2 +
.../biometrics/ClerkExpoBiometricsModule.kt | 33 ++
.../expo-biometrics/expo-module.config.json | 9 +
.../ios/BiometricCredentialCoding.swift | 324 ++++++++++++++++++
.../ios/BiometricCredentialStore.swift | 230 +++++++++++++
.../ios/BiometricKeyManager.swift | 206 +++++++++++
.../expo-biometrics/ios/BiometricsError.swift | 116 +++++++
.../ios/ClerkExpoBiometrics.podspec | 32 ++
.../ios/ClerkExpoBiometricsModule.swift | 158 +++++++++
.../BiometricCredentialContractTests.swift | 277 +++++++++++++++
.../Tests/BiometricCredentialStoreTests.swift | 320 +++++++++++++++++
.../BiometricSystemIntegrationTests.swift | 146 ++++++++
.../BiometricCredentialStorageContractV1.json | 31 ++
packages/expo-biometrics/package.json | 53 +++
.../src/ClerkExpoBiometricsModule.ts | 26 ++
.../src/__tests__/index.test.ts | 272 +++++++++++++++
packages/expo-biometrics/src/errors.ts | 48 +++
packages/expo-biometrics/src/index.ts | 207 +++++++++++
packages/expo-biometrics/src/types.ts | 86 +++++
.../tsconfig.declarations.json | 15 +
packages/expo-biometrics/tsconfig.json | 27 ++
packages/expo-biometrics/tsup.config.ts | 19 +
packages/expo-biometrics/vitest.config.mts | 7 +
pnpm-lock.yaml | 6 +
31 files changed, 2880 insertions(+), 3 deletions(-)
create mode 100644 .changeset/expo-biometrics-package.md
create mode 100644 packages/expo-biometrics/.gitignore
create mode 100644 packages/expo-biometrics/.npmignore
create mode 100644 packages/expo-biometrics/LICENSE
create mode 100644 packages/expo-biometrics/README.md
create mode 100644 packages/expo-biometrics/android/build.gradle
create mode 100644 packages/expo-biometrics/android/src/main/AndroidManifest.xml
create mode 100644 packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt
create mode 100644 packages/expo-biometrics/expo-module.config.json
create mode 100644 packages/expo-biometrics/ios/BiometricCredentialCoding.swift
create mode 100644 packages/expo-biometrics/ios/BiometricCredentialStore.swift
create mode 100644 packages/expo-biometrics/ios/BiometricKeyManager.swift
create mode 100644 packages/expo-biometrics/ios/BiometricsError.swift
create mode 100644 packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec
create mode 100644 packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift
create mode 100644 packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift
create mode 100644 packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift
create mode 100644 packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift
create mode 100644 packages/expo-biometrics/ios/Tests/Resources/BiometricCredentialStorageContractV1.json
create mode 100644 packages/expo-biometrics/package.json
create mode 100644 packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts
create mode 100644 packages/expo-biometrics/src/__tests__/index.test.ts
create mode 100644 packages/expo-biometrics/src/errors.ts
create mode 100644 packages/expo-biometrics/src/index.ts
create mode 100644 packages/expo-biometrics/src/types.ts
create mode 100644 packages/expo-biometrics/tsconfig.declarations.json
create mode 100644 packages/expo-biometrics/tsconfig.json
create mode 100644 packages/expo-biometrics/tsup.config.ts
create mode 100644 packages/expo-biometrics/vitest.config.mts
diff --git a/.changeset/expo-biometrics-package.md b/.changeset/expo-biometrics-package.md
new file mode 100644
index 00000000000..0e3340cf7a9
--- /dev/null
+++ b/.changeset/expo-biometrics-package.md
@@ -0,0 +1,7 @@
+---
+'@clerk/expo-biometrics': minor
+---
+
+Add `@clerk/expo-biometrics`, an experimental Expo native module that creates and signs with the device-bound keys behind Clerk biometric credentials and manages their on-device records. It is iOS-only for now; on Android every call rejects with `not_implemented`.
+
+If you try this out, make sure to pin your version as breaking changes can happen in minors.
diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml
index cd69db5c340..aad2a09afa6 100644
--- a/.github/workflows/expo-native-build.yml
+++ b/.github/workflows/expo-native-build.yml
@@ -10,6 +10,7 @@ on:
- 'integration/templates/expo-native/**'
- 'integration/tests/expo-native/**'
- 'packages/expo/**'
+ - 'packages/expo-biometrics/**'
- 'packages/expo-google-signin/**'
- 'packages/expo-native-components/**'
workflow_dispatch:
@@ -85,6 +86,7 @@ jobs:
turbo.json \
packages/clerk-js \
packages/expo \
+ packages/expo-biometrics \
packages/expo-google-signin \
packages/expo-native-components \
packages/react \
@@ -123,11 +125,12 @@ jobs:
- name: Build and pack Clerk packages
if: steps.native-build-cache.outputs.cache-hit != 'true'
run: |
- pnpm --filter @clerk/expo... build
+ pnpm --filter @clerk/expo... --filter @clerk/expo-biometrics build
mkdir -p "$SDK_PACK_DIR"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-native-components pack --pack-destination "$SDK_PACK_DIR"
+ pnpm --filter @clerk/expo-biometrics pack --pack-destination "$SDK_PACK_DIR"
- name: Install fixture dependencies
if: steps.native-build-cache.outputs.cache-hit != 'true'
@@ -138,11 +141,12 @@ jobs:
run: |
cp "package.sdk-$EXPO_SDK.json" package.json
pnpm install --no-frozen-lockfile
- # [0-9] keeps this glob off the clerk-expo-google-signin and clerk-expo-native tarballs.
+ # [0-9] keeps this glob off the other clerk-expo-* tarballs.
SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)"
GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)"
NATIVE_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-native-components-*.tgz)"
- pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" -w
+ BIOMETRICS_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-biometrics-*.tgz)"
+ pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" "$BIOMETRICS_TARBALL" -w
# expo-dev-client makes even release builds boot into the dev
# launcher (unreachable Metro in CI), which stalls every Maestro
# flow on a blank screen. Skip it on e2e jobs only.
diff --git a/packages/expo-biometrics/.gitignore b/packages/expo-biometrics/.gitignore
new file mode 100644
index 00000000000..3dde874d50d
--- /dev/null
+++ b/packages/expo-biometrics/.gitignore
@@ -0,0 +1,58 @@
+# OSX
+#
+.DS_Store
+
+# VSCode
+.vscode/
+jsconfig.json
+
+# Xcode
+#
+build/
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+xcuserdata
+*.xccheckout
+*.moved-aside
+DerivedData
+*.hmap
+*.ipa
+*.xcuserstate
+project.xcworkspace
+
+# Android/IJ
+#
+.classpath
+.cxx
+.gradle
+.idea
+.project
+.settings
+local.properties
+android.iml
+android/app/libs
+android/keystores/debug.keystore
+
+# Cocoapods
+#
+example/ios/Pods
+
+# Ruby
+example/vendor/
+
+# node.js
+#
+node_modules/
+npm-debug.log
+yarn-debug.log
+yarn-error.log
+
+# Expo
+.expo/*
+.env
\ No newline at end of file
diff --git a/packages/expo-biometrics/.npmignore b/packages/expo-biometrics/.npmignore
new file mode 100644
index 00000000000..937d158a8ea
--- /dev/null
+++ b/packages/expo-biometrics/.npmignore
@@ -0,0 +1,14 @@
+# Exclude all top-level hidden directories by convention
+/.*/
+
+# Exclude tarballs generated by `npm pack`
+/*.tgz
+
+__mocks__
+__tests__
+
+/babel.config.js
+/android/src/androidTest/
+/android/src/test/
+/android/build/
+/example/
diff --git a/packages/expo-biometrics/LICENSE b/packages/expo-biometrics/LICENSE
new file mode 100644
index 00000000000..daceccfbc84
--- /dev/null
+++ b/packages/expo-biometrics/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Clerk, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/expo-biometrics/README.md b/packages/expo-biometrics/README.md
new file mode 100644
index 00000000000..dcac2a997f6
--- /dev/null
+++ b/packages/expo-biometrics/README.md
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
@clerk/expo-biometrics
+
+
+
+
+[](https://clerk.com/discord)
+[](https://clerk.com/docs?utm_source=github&utm_medium=expo_biometrics)
+[](https://x.com/intent/follow?screen_name=clerk)
+
+[Changelog](https://github.com/clerk/javascript/blob/main/packages/expo-biometrics/CHANGELOG.md)
+·
+[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml)
+·
+[Request a Feature](https://feedback.clerk.com/roadmap)
+·
+[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=expo_biometrics)
+
+
+
+> [!WARNING]
+> This package is experimental. Pin its version, as breaking changes can happen in minor releases.
+
+The native building block for Clerk biometric credentials in Expo apps. It creates hardware-backed signing keys, signs challenges behind a Face ID / Touch ID prompt, and stores the on-device records that link each key to a Clerk credential. It does not talk to Clerk's API; `@clerk/expo` builds the sign-in and enrollment flows on top of it.
+
+The key and record layout is shared with the Clerk iOS SDK, so credentials enrolled by either SDK in the same app are visible to both.
+
+### Prerequisites
+
+- Expo SDK 54 or later, in a development build (the module is not available in Expo Go or on the web)
+- iOS. Android support is not implemented yet: every call rejects with `not_implemented`.
+- `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option.
+
+## Installation
+
+```sh
+npx expo install @clerk/expo-biometrics
+```
+
+Then rebuild your native app.
+
+## API
+
+```ts
+import {
+ createKey,
+ deleteKey,
+ deleteRecord,
+ ensureInstallationMarker,
+ getAppIdentifier,
+ getAvailability,
+ hasKey,
+ listRecords,
+ saveRecord,
+ sign,
+} from '@clerk/expo-biometrics';
+```
+
+| Function | Description |
+| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier). |
+| `getAvailability()` | The device's biometry type and whether biometrics or device owner authentication can be evaluated. |
+| `createKey(policy)` | Creates a Secure Enclave P-256 key and returns its `localKeyId` and public key JWK. |
+| `sign(localKeyId, clientData, reason?)` | Prompts for authentication and returns an ES256 signature over `clientData` (raw `r \|\| s`, base64url without padding). |
+| `hasKey(localKeyId)` / `deleteKey(localKeyId)` | Checks for or deletes a key. |
+| `listRecords()` | Every stored credential record, for every app identifier. |
+| `saveRecord(record, options)` | Saves a record. With `removeOtherRecordsForApp: true`, deletes the app's other records and their keys. |
+| `deleteRecord(localKeyId)` | Deletes a key, then the records that reference it. |
+| `ensureInstallationMarker()` | Deletes records and keys left behind by a previous installation of the app. The store functions call it for you. |
+
+Every error is a `ClerkBiometricsError` with a stable `code`, such as `user_canceled`, `biometry_not_enrolled`, `biometry_lockout`, `key_not_found`, or `storage_failed`.
+
+## License
+
+This project is licensed under the **MIT license**.
+
+See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/expo-biometrics/LICENSE) for more information.
diff --git a/packages/expo-biometrics/android/build.gradle b/packages/expo-biometrics/android/build.gradle
new file mode 100644
index 00000000000..a40c50f6d7b
--- /dev/null
+++ b/packages/expo-biometrics/android/build.gradle
@@ -0,0 +1,39 @@
+apply plugin: 'com.android.library'
+// AGP 9+ registers the `kotlin` extension itself, and applying kotlin-android on top of that fails configuration.
+if (project.extensions.findByName('kotlin') == null) {
+ apply plugin: 'kotlin-android'
+}
+
+apply plugin: 'maven-publish'
+
+group = 'expo.modules.clerk.biometrics'
+version = '1.0.0'
+
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+apply from: expoModulesCorePlugin
+applyKotlinExpoModulesCorePlugin()
+useCoreDependencies()
+useExpoPublishing()
+
+buildscript {
+ ext.safeExtGet = { prop, fallback ->
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+ }
+}
+
+android {
+ namespace "expo.modules.clerk.biometrics"
+
+ compileSdkVersion safeExtGet("compileSdkVersion", 36)
+
+ defaultConfig {
+ minSdkVersion safeExtGet("minSdkVersion", 24)
+ targetSdkVersion safeExtGet("targetSdkVersion", 36)
+ versionCode 1
+ versionName "1.0.0"
+ }
+}
+
+dependencies {
+ implementation project(':expo-modules-core')
+}
diff --git a/packages/expo-biometrics/android/src/main/AndroidManifest.xml b/packages/expo-biometrics/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..bdae66c8f5a
--- /dev/null
+++ b/packages/expo-biometrics/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt
new file mode 100644
index 00000000000..a0fd2a58942
--- /dev/null
+++ b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt
@@ -0,0 +1,33 @@
+package expo.modules.clerk.biometrics
+
+import expo.modules.kotlin.Promise
+import expo.modules.kotlin.exception.CodedException
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class NotImplementedException :
+ CodedException("not_implemented", "@clerk/expo-biometrics is not supported on Android yet.", null)
+
+class ClerkExpoBiometricsModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("ClerkExpoBiometrics")
+
+ Function("getAppIdentifier") { -> notImplemented() }
+
+ AsyncFunction("getAvailability") { promise: Promise -> reject(promise) }
+ AsyncFunction("createKey") { _: String, promise: Promise -> reject(promise) }
+ AsyncFunction("sign") { _: String, _: String, _: String?, promise: Promise -> reject(promise) }
+ AsyncFunction("hasKey") { _: String, promise: Promise -> reject(promise) }
+ AsyncFunction("deleteKey") { _: String, promise: Promise -> reject(promise) }
+ AsyncFunction("listRecords") { promise: Promise -> reject(promise) }
+ AsyncFunction("saveRecord") { _: Map, _: Map, promise: Promise -> reject(promise) }
+ AsyncFunction("deleteRecord") { _: String, promise: Promise -> reject(promise) }
+ AsyncFunction("ensureInstallationMarker") { promise: Promise -> reject(promise) }
+ }
+
+ private fun reject(promise: Promise) {
+ promise.reject(NotImplementedException())
+ }
+
+ private fun notImplemented(): T = throw NotImplementedException()
+}
diff --git a/packages/expo-biometrics/expo-module.config.json b/packages/expo-biometrics/expo-module.config.json
new file mode 100644
index 00000000000..08bfbbf215e
--- /dev/null
+++ b/packages/expo-biometrics/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+ "platforms": ["apple", "android"],
+ "apple": {
+ "modules": ["ClerkExpoBiometricsModule"]
+ },
+ "android": {
+ "modules": ["expo.modules.clerk.biometrics.ClerkExpoBiometricsModule"]
+ }
+}
diff --git a/packages/expo-biometrics/ios/BiometricCredentialCoding.swift b/packages/expo-biometrics/ios/BiometricCredentialCoding.swift
new file mode 100644
index 00000000000..441245f9ec0
--- /dev/null
+++ b/packages/expo-biometrics/ios/BiometricCredentialCoding.swift
@@ -0,0 +1,324 @@
+import Foundation
+
+// Implements clerk-ios Documentation/BiometricCredentialStorageContract.md (version 1). Any change here must stay
+// byte-compatible with ClerkKit, which reads and writes the same keys, Keychain item, and UserDefaults marker.
+
+enum BiometricCredentialPolicy: String, CaseIterable {
+ case biometryCurrentSet = "biometry_current_set"
+ case biometryAny = "biometry_any"
+ case biometryOrDevicePasscode = "biometry_or_device_passcode"
+}
+
+enum BiometricCredentialCoding {
+ static let applicationTagPrefix = "dev.clerk.trusted_device"
+ static let metadataAccount = "trustedDeviceCredentials"
+ static let installationMarkerPrefix = "com.clerk.trusted-device-installation-marker"
+
+ static func makeLocalKeyId() -> String {
+ "tdlk_" + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
+ }
+
+ static func applicationTag(localKeyId: String) -> Data {
+ Data("\(applicationTagPrefix).\(localKeyId)".utf8)
+ }
+
+ static func metadataService(infoDictionaryService: String?, bundleIdentifier: String?) -> String {
+ if let infoDictionaryService, !infoDictionaryService.isEmpty {
+ return infoDictionaryService
+ }
+ return bundleIdentifier ?? ""
+ }
+
+ static func installationMarkerKey(service: String?, accessGroup: String?, appIdentifier: String?) -> String {
+ [
+ installationMarkerPrefix,
+ encodeInstallationMarkerComponent(service),
+ encodeInstallationMarkerComponent(accessGroup),
+ encodeInstallationMarkerComponent(appIdentifier),
+ ].joined(separator: ".")
+ }
+
+ private static func encodeInstallationMarkerComponent(_ value: String?) -> String {
+ guard let value else { return "n" }
+ return "s\(value.utf8.count):\(value)"
+ }
+
+ static func base64URLEncodedString(_ data: D) -> String {
+ Data(data)
+ .base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ static func publicKeyJWK(fromX963Representation representation: Data) throws -> String {
+ let bytes = [UInt8](representation)
+ guard bytes.count == 65, bytes[0] == 0x04 else {
+ throw BiometricsError(.keyGenerationFailed, "The public key is not an uncompressed P-256 point.")
+ }
+ let x = base64URLEncodedString(bytes[1 ..< 33])
+ let y = base64URLEncodedString(bytes[33 ..< 65])
+ return #"{"kty":"EC","crv":"P-256","x":"\#(x)","y":"\#(y)","alg":"ES256"}"#
+ }
+
+ static func rawES256Signature(fromDEREncoded signature: Data) throws -> Data {
+ let bytes = [UInt8](signature)
+ var offset = 0
+
+ guard try readDERByte(bytes, offset: &offset) == 0x30 else {
+ throw invalidSignature()
+ }
+ let sequenceLength = try readDERLength(bytes, offset: &offset)
+ guard sequenceLength == bytes.count - offset else {
+ throw invalidSignature()
+ }
+
+ let r = try readDERInteger(bytes, offset: &offset)
+ let s = try readDERInteger(bytes, offset: &offset)
+ guard offset == bytes.count else {
+ throw invalidSignature()
+ }
+
+ var raw = try paddedES256Component(r)
+ raw.append(try paddedES256Component(s))
+ return raw
+ }
+
+ private static func readDERByte(_ bytes: [UInt8], offset: inout Int) throws -> UInt8 {
+ guard offset < bytes.count else { throw invalidSignature() }
+ let byte = bytes[offset]
+ offset += 1
+ return byte
+ }
+
+ private static func readDERLength(_ bytes: [UInt8], offset: inout Int) throws -> Int {
+ let first = try readDERByte(bytes, offset: &offset)
+ if first & 0x80 == 0 {
+ return Int(first)
+ }
+ let byteCount = Int(first & 0x7F)
+ guard byteCount > 0, byteCount <= MemoryLayout.size, byteCount <= bytes.count - offset else {
+ throw invalidSignature()
+ }
+ var length = 0
+ for _ in 0 ..< byteCount {
+ length = (length << 8) | Int(try readDERByte(bytes, offset: &offset))
+ }
+ return length
+ }
+
+ private static func readDERInteger(_ bytes: [UInt8], offset: inout Int) throws -> [UInt8] {
+ guard try readDERByte(bytes, offset: &offset) == 0x02 else { throw invalidSignature() }
+ let length = try readDERLength(bytes, offset: &offset)
+ guard length > 0, length <= bytes.count - offset else { throw invalidSignature() }
+ let value = Array(bytes[offset ..< offset + length])
+ offset += length
+ return value
+ }
+
+ private static func paddedES256Component(_ bytes: [UInt8]) throws -> Data {
+ guard let first = bytes.first, first & 0x80 == 0 else { throw invalidSignature() }
+ var component = bytes
+ while component.first == 0x00, component.count > 32 {
+ component.removeFirst()
+ }
+ guard !component.isEmpty, component.count <= 32 else { throw invalidSignature() }
+ var padded = Data(repeating: 0x00, count: 32 - component.count)
+ padded.append(contentsOf: component)
+ return padded
+ }
+
+ private static func invalidSignature() -> BiometricsError {
+ BiometricsError(.signingFailed, "Security returned an invalid ES256 signature.")
+ }
+}
+
+/// A record in the metadata Keychain item.
+struct BiometricCredentialRecord: Equatable {
+ enum Field {
+ static let id = "id"
+ static let localKeyId = "localKeyId"
+ static let userId = "userId"
+ static let appIdentifier = "appIdentifier"
+ static let identifierHint = "identifierHint"
+ static let policy = "policy"
+ static let createdAt = "createdAt"
+ static let updatedAt = "updatedAt"
+ }
+
+ let id: String
+ let localKeyId: String
+ let userId: String
+ let appIdentifier: String
+ let identifierHint: String?
+ let policy: BiometricCredentialPolicy
+ /// Milliseconds since the Unix epoch.
+ let createdAt: Double
+ /// Milliseconds since the Unix epoch.
+ let updatedAt: Double
+
+ init(
+ id: String,
+ localKeyId: String,
+ userId: String,
+ appIdentifier: String,
+ identifierHint: String?,
+ policy: BiometricCredentialPolicy,
+ createdAt: Double,
+ updatedAt: Double
+ ) {
+ self.id = id
+ self.localKeyId = localKeyId
+ self.userId = userId
+ self.appIdentifier = appIdentifier
+ self.identifierHint = Self.normalizedIdentifierHint(identifierHint)
+ self.policy = policy
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+
+ /// Decodes a stored record, returning `nil` for records ClerkKit treats as malformed.
+ init?(jsonObject object: [String: Any]) {
+ guard
+ let id = object[Field.id] as? String,
+ let localKeyId = object[Field.localKeyId] as? String,
+ let userId = object[Field.userId] as? String,
+ let appIdentifier = object[Field.appIdentifier] as? String,
+ let policyValue = object[Field.policy] as? String,
+ let policy = BiometricCredentialPolicy(rawValue: policyValue),
+ let createdAt = Self.number(object[Field.createdAt]),
+ let updatedAt = Self.number(object[Field.updatedAt])
+ else {
+ return nil
+ }
+
+ let identifierHint: String?
+ switch object[Field.identifierHint] {
+ case .none, is NSNull:
+ identifierHint = nil
+ case let value as String:
+ identifierHint = value
+ default:
+ return nil
+ }
+
+ self.init(
+ id: id,
+ localKeyId: localKeyId,
+ userId: userId,
+ appIdentifier: appIdentifier,
+ identifierHint: identifierHint,
+ policy: policy,
+ createdAt: createdAt,
+ updatedAt: updatedAt
+ )
+ }
+
+ /// The object written to the Keychain: integer milliseconds, `identifierHint` omitted when absent.
+ var jsonObject: [String: Any] {
+ var object: [String: Any] = [
+ Field.id: id,
+ Field.localKeyId: localKeyId,
+ Field.userId: userId,
+ Field.appIdentifier: appIdentifier,
+ Field.policy: policy.rawValue,
+ Field.createdAt: Int64(createdAt.rounded()),
+ Field.updatedAt: Int64(updatedAt.rounded()),
+ ]
+ if let identifierHint {
+ object[Field.identifierHint] = identifierHint
+ }
+ return object
+ }
+
+ static func normalizedIdentifierHint(_ identifierHint: String?) -> String? {
+ guard let identifierHint else { return nil }
+ let normalized = identifierHint.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return normalized.isEmpty ? nil : normalized
+ }
+
+ static func isValidTimestamp(_ value: Double) -> Bool {
+ value.isFinite && value >= 0 && value < 9.0e15
+ }
+
+ private static func number(_ value: Any?) -> Double? {
+ guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else {
+ return nil
+ }
+ return number.doubleValue
+ }
+}
+
+enum BiometricCredentialRecordList {
+ /// Parses the metadata item. A missing item is an empty list; a top-level value that is not an array of objects is an error.
+ static func decode(_ data: Data?) throws -> [[String: Any]] {
+ guard let data else { return [] }
+ let object: Any
+ do {
+ object = try JSONSerialization.jsonObject(with: data)
+ } catch {
+ throw BiometricsError(.storageFailed, "Biometric credential metadata is not valid JSON.")
+ }
+ guard let records = object as? [[String: Any]] else {
+ throw BiometricsError(.storageFailed, "Biometric credential metadata is not an array.")
+ }
+ return records
+ }
+
+ /// Serializes the list, or returns `nil` when it is empty so the caller deletes the item instead of writing `[]`.
+ static func encode(_ records: [[String: Any]]) throws -> Data? {
+ guard !records.isEmpty else { return nil }
+ do {
+ return try JSONSerialization.data(withJSONObject: records)
+ } catch {
+ throw BiometricsError(.storageFailed, "Biometric credential metadata could not be encoded.")
+ }
+ }
+
+ /// Well-formed records with their stored fields passed through and `identifierHint` normalized as ClerkKit reads it.
+ static func listable(_ records: [[String: Any]]) -> [[String: Any]] {
+ records.compactMap { object in
+ guard let record = BiometricCredentialRecord(jsonObject: object) else { return nil }
+ var listed = object
+ if let identifierHint = record.identifierHint {
+ listed[BiometricCredentialRecord.Field.identifierHint] = identifierHint
+ } else {
+ listed.removeValue(forKey: BiometricCredentialRecord.Field.identifierHint)
+ }
+ return listed
+ }
+ }
+
+ struct SaveResult {
+ let records: [[String: Any]]
+ /// Keys of records with the same `id` that pointed at a different key.
+ let replacedLocalKeyIds: [String]
+ }
+
+ /// Replaces any record with the same `id`, drops malformed records for the same app, and keeps everything else untouched.
+ static func saving(_ record: BiometricCredentialRecord, into records: [[String: Any]]) -> SaveResult {
+ var kept: [[String: Any]] = []
+ var replacedLocalKeyIds: [String] = []
+
+ for object in records {
+ if object[BiometricCredentialRecord.Field.id] as? String == record.id {
+ if let localKeyId = object[BiometricCredentialRecord.Field.localKeyId] as? String,
+ localKeyId != record.localKeyId
+ {
+ replacedLocalKeyIds.append(localKeyId)
+ }
+ continue
+ }
+ guard object[BiometricCredentialRecord.Field.appIdentifier] as? String == record.appIdentifier else {
+ kept.append(object)
+ continue
+ }
+ if BiometricCredentialRecord(jsonObject: object) != nil {
+ kept.append(object)
+ }
+ }
+
+ kept.append(record.jsonObject)
+ return SaveResult(records: kept, replacedLocalKeyIds: replacedLocalKeyIds)
+ }
+}
diff --git a/packages/expo-biometrics/ios/BiometricCredentialStore.swift b/packages/expo-biometrics/ios/BiometricCredentialStore.swift
new file mode 100644
index 00000000000..252a381f9c8
--- /dev/null
+++ b/packages/expo-biometrics/ios/BiometricCredentialStore.swift
@@ -0,0 +1,230 @@
+import Foundation
+import Security
+
+protocol BiometricCredentialMetadataStorage {
+ func read() throws -> Data?
+ func write(_ data: Data) throws
+ func delete() throws
+}
+
+/// The generic-password item ClerkKit's `SystemKeychain` uses for biometric credential metadata, with no access group.
+struct KeychainMetadataStorage: BiometricCredentialMetadataStorage {
+ let service: String
+ var account: String = BiometricCredentialCoding.metadataAccount
+
+ func read() throws -> Data? {
+ var query = baseQuery()
+ query[kSecReturnData as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ switch status {
+ case errSecSuccess:
+ return result as? Data
+ case errSecItemNotFound:
+ return nil
+ default:
+ throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status))
+ }
+ }
+
+ func write(_ data: Data) throws {
+ var addQuery = baseQuery()
+ addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+ addQuery[kSecValueData as String] = data
+
+ let status = SecItemAdd(addQuery as CFDictionary, nil)
+ switch status {
+ case errSecSuccess:
+ return
+ case errSecDuplicateItem:
+ let attributes: [String: Any] = [
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ]
+ let updateStatus = SecItemUpdate(baseQuery() as CFDictionary, attributes as CFDictionary)
+ guard updateStatus == errSecSuccess else {
+ throw BiometricsError(.storageFailed, BiometricsError.statusMessage(updateStatus))
+ }
+ default:
+ throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status))
+ }
+ }
+
+ func delete() throws {
+ let status = SecItemDelete(baseQuery() as CFDictionary)
+ switch status {
+ case errSecSuccess, errSecItemNotFound:
+ return
+ default:
+ throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status))
+ }
+ }
+
+ func baseQuery() -> [String: Any] {
+ [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ ]
+ }
+}
+
+/// Read-modify-write operations on the shared metadata item. Every operation runs under one lock and first applies the
+/// reinstall marker, so records written here are never wiped by ClerkKit's first configuration in this installation.
+final class BiometricCredentialStore {
+ private let storage: BiometricCredentialMetadataStorage
+ private let deleteKey: (String) throws -> Void
+ private let userDefaults: UserDefaults
+ private let service: String
+ private let appIdentifier: String?
+ private let lock = NSRecursiveLock()
+
+ init(
+ storage: BiometricCredentialMetadataStorage,
+ deleteKey: @escaping (String) throws -> Void,
+ userDefaults: UserDefaults,
+ service: String,
+ appIdentifier: String?
+ ) {
+ self.storage = storage
+ self.deleteKey = deleteKey
+ self.userDefaults = userDefaults
+ self.service = service
+ self.appIdentifier = appIdentifier
+ }
+
+ static func live(keyManager: BiometricKeyManager, bundle: Bundle = .main) -> BiometricCredentialStore {
+ let service = BiometricCredentialCoding.metadataService(
+ infoDictionaryService: bundle.object(forInfoDictionaryKey: "ClerkKeychainService") as? String,
+ bundleIdentifier: bundle.bundleIdentifier
+ )
+ return BiometricCredentialStore(
+ storage: KeychainMetadataStorage(service: service),
+ deleteKey: { try keyManager.deleteKey(localKeyId: $0) },
+ userDefaults: .standard,
+ service: service,
+ appIdentifier: bundle.bundleIdentifier
+ )
+ }
+
+ var installationMarkerKey: String {
+ BiometricCredentialCoding.installationMarkerKey(service: service, accessGroup: nil, appIdentifier: appIdentifier)
+ }
+
+ /// Returns `true` when the marker was missing and this app's records and keys from a previous installation were deleted.
+ @discardableResult
+ func ensureInstallationMarker() throws -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+
+ guard appIdentifier != nil else { return false }
+ let markerKey = installationMarkerKey
+ if userDefaults.object(forKey: markerKey) as? Bool == true {
+ return false
+ }
+
+ let records = try readRecords()
+ var remaining: [[String: Any]] = []
+ var keyDeletionError: Error?
+ for record in records {
+ guard record[BiometricCredentialRecord.Field.appIdentifier] as? String == appIdentifier else {
+ remaining.append(record)
+ continue
+ }
+ if let localKeyId = record[BiometricCredentialRecord.Field.localKeyId] as? String {
+ do {
+ try deleteKey(localKeyId)
+ } catch {
+ keyDeletionError = keyDeletionError ?? error
+ remaining.append(record)
+ continue
+ }
+ }
+ }
+
+ if remaining.count != records.count {
+ try persist(remaining)
+ }
+ if let keyDeletionError {
+ throw keyDeletionError
+ }
+
+ userDefaults.set(true, forKey: markerKey)
+ return true
+ }
+
+ /// JSON array of every well-formed record, for every app identifier.
+ func listRecordsJSON() throws -> String {
+ lock.lock()
+ defer { lock.unlock() }
+
+ try ensureInstallationMarker()
+ let data = try JSONSerialization.data(withJSONObject: BiometricCredentialRecordList.listable(readRecords()))
+ return String(decoding: data, as: UTF8.self)
+ }
+
+ func save(_ record: BiometricCredentialRecord, removeOtherRecordsForApp: Bool) throws {
+ lock.lock()
+ defer { lock.unlock() }
+
+ try ensureInstallationMarker()
+ let result = BiometricCredentialRecordList.saving(record, into: try readRecords())
+ try persist(result.records)
+
+ for localKeyId in result.replacedLocalKeyIds where localKeyId != record.localKeyId {
+ try? deleteKey(localKeyId)
+ }
+
+ guard removeOtherRecordsForApp else { return }
+
+ var remaining: [[String: Any]] = []
+ for object in result.records {
+ let isOtherRecordForApp = object[BiometricCredentialRecord.Field.appIdentifier] as? String == record.appIdentifier
+ && object[BiometricCredentialRecord.Field.id] as? String != record.id
+ guard isOtherRecordForApp else {
+ remaining.append(object)
+ continue
+ }
+ if let localKeyId = object[BiometricCredentialRecord.Field.localKeyId] as? String, localKeyId != record.localKeyId {
+ do {
+ try deleteKey(localKeyId)
+ } catch {
+ remaining.append(object)
+ continue
+ }
+ }
+ }
+ if remaining.count != result.records.count {
+ try? persist(remaining)
+ }
+ }
+
+ /// Deletes the key, then every record that references it. When the key cannot be deleted the records are kept.
+ func deleteRecords(localKeyId: String) throws {
+ lock.lock()
+ defer { lock.unlock() }
+
+ try ensureInstallationMarker()
+ try deleteKey(localKeyId)
+
+ let records = try readRecords()
+ let remaining = records.filter { $0[BiometricCredentialRecord.Field.localKeyId] as? String != localKeyId }
+ if remaining.count != records.count {
+ try persist(remaining)
+ }
+ }
+
+ private func readRecords() throws -> [[String: Any]] {
+ try BiometricCredentialRecordList.decode(storage.read())
+ }
+
+ private func persist(_ records: [[String: Any]]) throws {
+ if let data = try BiometricCredentialRecordList.encode(records) {
+ try storage.write(data)
+ } else {
+ try storage.delete()
+ }
+ }
+}
diff --git a/packages/expo-biometrics/ios/BiometricKeyManager.swift b/packages/expo-biometrics/ios/BiometricKeyManager.swift
new file mode 100644
index 00000000000..53e306a8b2a
--- /dev/null
+++ b/packages/expo-biometrics/ios/BiometricKeyManager.swift
@@ -0,0 +1,206 @@
+import Foundation
+import LocalAuthentication
+import Security
+
+struct BiometricAvailability: Equatable {
+ let biometryType: String
+ let canEvaluateBiometrics: Bool
+ let canEvaluateDeviceOwner: Bool
+ let errorCode: BiometricsError.Code?
+}
+
+struct BiometricCredentialKey: Equatable {
+ let localKeyId: String
+ let publicKeyJWK: String
+}
+
+/// Secure Enclave keys laid out as ClerkKit's `BiometricCredentialKeyManager` creates them.
+final class BiometricKeyManager {
+ func availability() -> BiometricAvailability {
+ let context = LAContext()
+ var biometricsError: NSError?
+ let canEvaluateBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &biometricsError)
+ // biometryType is only populated after canEvaluatePolicy has run.
+ let biometryType = Self.biometryTypeName(context.biometryType)
+ var deviceOwnerError: NSError?
+ let canEvaluateDeviceOwner = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &deviceOwnerError)
+
+ return BiometricAvailability(
+ biometryType: biometryType,
+ canEvaluateBiometrics: canEvaluateBiometrics,
+ canEvaluateDeviceOwner: canEvaluateDeviceOwner,
+ errorCode: canEvaluateBiometrics
+ ? nil
+ : BiometricsError.localAuthentication(biometricsError, fallback: .biometryNotAvailable).code
+ )
+ }
+
+ func createKey(policy: BiometricCredentialPolicy) throws -> BiometricCredentialKey {
+ let context = LAContext()
+ var laError: NSError?
+ guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &laError) else {
+ throw BiometricsError.localAuthentication(laError, fallback: .biometryNotAvailable)
+ }
+
+ let localKeyId = BiometricCredentialCoding.makeLocalKeyId()
+ let attributes = Self.privateKeyAttributes(localKeyId: localKeyId, accessControl: try Self.accessControl(policy: policy))
+
+ var error: Unmanaged?
+ guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
+ throw Self.cfError(error, fallback: .keyGenerationFailed)
+ }
+
+ do {
+ return BiometricCredentialKey(localKeyId: localKeyId, publicKeyJWK: try Self.publicKeyJWK(for: privateKey))
+ } catch {
+ try? deleteKey(localKeyId: localKeyId)
+ throw error
+ }
+ }
+
+ func sign(localKeyId: String, clientData: String, reason: String?) throws -> String {
+ let privateKey = try privateKey(localKeyId: localKeyId, reason: reason)
+ let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
+ guard SecKeyIsAlgorithmSupported(privateKey, .sign, algorithm) else {
+ throw BiometricsError(.signingFailed, "The key does not support ES256 signing.")
+ }
+
+ var error: Unmanaged?
+ guard let signature = SecKeyCreateSignature(privateKey, algorithm, Data(clientData.utf8) as CFData, &error) as Data? else {
+ throw Self.cfError(error, fallback: .signingFailed)
+ }
+
+ return BiometricCredentialCoding.base64URLEncodedString(
+ try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: signature)
+ )
+ }
+
+ func hasKey(localKeyId: String) throws -> Bool {
+ var query = Self.privateKeyQuery(localKeyId: localKeyId)
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+
+ let status = SecItemCopyMatching(query as CFDictionary, nil)
+ switch status {
+ case errSecSuccess:
+ return true
+ case errSecItemNotFound:
+ return false
+ default:
+ throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status))
+ }
+ }
+
+ func deleteKey(localKeyId: String) throws {
+ let status = SecItemDelete(Self.privateKeyQuery(localKeyId: localKeyId) as CFDictionary)
+ switch status {
+ case errSecSuccess, errSecItemNotFound:
+ return
+ default:
+ throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status))
+ }
+ }
+
+ private func privateKey(localKeyId: String, reason: String?) throws -> SecKey {
+ let context = LAContext()
+ if let reason {
+ context.localizedReason = reason
+ }
+
+ var query = Self.privateKeyQuery(localKeyId: localKeyId)
+ query[kSecReturnRef as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+ query[kSecUseAuthenticationContext as String] = context
+
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ switch status {
+ case errSecSuccess:
+ guard let result, CFGetTypeID(result) == SecKeyGetTypeID() else {
+ throw BiometricsError(.keyNotFound, "The biometric credential key was not found.")
+ }
+ return result as! SecKey
+ case errSecItemNotFound:
+ throw BiometricsError(.keyNotFound, "The biometric credential key was not found.")
+ default:
+ throw BiometricsError.status(status, fallback: .signingFailed)
+ }
+ }
+
+ static func accessControlFlags(for policy: BiometricCredentialPolicy) -> SecAccessControlCreateFlags {
+ switch policy {
+ case .biometryCurrentSet:
+ return [.privateKeyUsage, .biometryCurrentSet]
+ case .biometryAny:
+ return [.privateKeyUsage, .biometryAny]
+ case .biometryOrDevicePasscode:
+ return [.privateKeyUsage, .userPresence]
+ }
+ }
+
+ static func accessControl(policy: BiometricCredentialPolicy) throws -> SecAccessControl {
+ var error: Unmanaged?
+ guard let accessControl = SecAccessControlCreateWithFlags(
+ kCFAllocatorDefault,
+ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
+ accessControlFlags(for: policy),
+ &error
+ ) else {
+ throw cfError(error, fallback: .keyGenerationFailed)
+ }
+ return accessControl
+ }
+
+ static func privateKeyAttributes(localKeyId: String, accessControl: SecAccessControl) -> [String: Any] {
+ [
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
+ kSecAttrKeySizeInBits as String: 256,
+ kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
+ kSecPrivateKeyAttrs as String: [
+ kSecAttrIsPermanent as String: true,
+ kSecAttrApplicationTag as String: BiometricCredentialCoding.applicationTag(localKeyId: localKeyId),
+ kSecAttrAccessControl as String: accessControl,
+ ] as [String: Any],
+ ]
+ }
+
+ static func privateKeyQuery(localKeyId: String) -> [String: Any] {
+ [
+ kSecClass as String: kSecClassKey,
+ kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
+ kSecAttrApplicationTag as String: BiometricCredentialCoding.applicationTag(localKeyId: localKeyId),
+ ]
+ }
+
+ static func biometryTypeName(_ type: LABiometryType) -> String {
+ switch type {
+ case .faceID:
+ return "faceID"
+ case .touchID:
+ return "touchID"
+ default:
+ if #available(iOS 17.0, *), type == .opticID {
+ return "opticID"
+ }
+ return "none"
+ }
+ }
+
+ private static func publicKeyJWK(for privateKey: SecKey) throws -> String {
+ guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
+ throw BiometricsError(.keyGenerationFailed, "Unable to copy the public key.")
+ }
+ var error: Unmanaged?
+ guard let representation = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else {
+ throw cfError(error, fallback: .keyGenerationFailed)
+ }
+ return try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: representation)
+ }
+
+ private static func cfError(_ error: Unmanaged?, fallback: BiometricsError.Code) -> BiometricsError {
+ guard let error else {
+ return BiometricsError(fallback, "Unknown Security framework error.")
+ }
+ return BiometricsError.security(error.takeRetainedValue() as Error, fallback: fallback)
+ }
+}
diff --git a/packages/expo-biometrics/ios/BiometricsError.swift b/packages/expo-biometrics/ios/BiometricsError.swift
new file mode 100644
index 00000000000..f8e5bb82375
--- /dev/null
+++ b/packages/expo-biometrics/ios/BiometricsError.swift
@@ -0,0 +1,116 @@
+import Foundation
+import LocalAuthentication
+import Security
+
+struct BiometricsError: Error, Equatable {
+ enum Code: String {
+ case userCanceled = "user_canceled"
+ case systemCanceled = "system_canceled"
+ case userFallback = "user_fallback"
+ case authenticationFailed = "authentication_failed"
+ case biometryNotAvailable = "biometry_not_available"
+ case biometryNotEnrolled = "biometry_not_enrolled"
+ case biometryLockout = "biometry_lockout"
+ case passcodeNotSet = "passcode_not_set"
+ case keyNotFound = "key_not_found"
+ case keyInvalidated = "key_invalidated"
+ case keyGenerationFailed = "key_generation_failed"
+ case signingFailed = "signing_failed"
+ case storageFailed = "storage_failed"
+ case invalidArgument = "invalid_argument"
+ }
+
+ let code: Code
+ let message: String
+
+ init(_ code: Code, _ message: String) {
+ self.code = code
+ self.message = message
+ }
+
+ static func localAuthentication(_ error: Error?, fallback: Code) -> BiometricsError {
+ guard let error = error as NSError?, error.domain == LAErrorDomain else {
+ return BiometricsError(fallback, error?.localizedDescription ?? "Local authentication failed.")
+ }
+ return BiometricsError(code(forLAErrorCode: error.code) ?? fallback, error.localizedDescription)
+ }
+
+ static func security(_ error: Error, fallback: Code) -> BiometricsError {
+ let nsError = error as NSError
+ let code: Code?
+ switch nsError.domain {
+ case LAErrorDomain:
+ code = Self.code(forLAErrorCode: nsError.code)
+ case NSOSStatusErrorDomain:
+ code = Self.code(forStatus: OSStatus(truncatingIfNeeded: nsError.code))
+ case cryptoTokenKitErrorDomain:
+ code = Self.code(forCryptoTokenKitErrorCode: nsError.code)
+ default:
+ code = nil
+ }
+ return BiometricsError(code ?? fallback, nsError.localizedDescription)
+ }
+
+ static func status(_ status: OSStatus, fallback: Code) -> BiometricsError {
+ BiometricsError(code(forStatus: status) ?? fallback, statusMessage(status))
+ }
+
+ static func statusMessage(_ status: OSStatus) -> String {
+ let description = SecCopyErrorMessageString(status, nil) as String? ?? "Unknown error"
+ return "\(description) (OSStatus \(status))"
+ }
+
+ static func code(forLAErrorCode rawValue: Int) -> Code? {
+ switch LAError.Code(rawValue: rawValue) {
+ case .userCancel:
+ return .userCanceled
+ case .appCancel, .systemCancel:
+ return .systemCanceled
+ case .userFallback:
+ return .userFallback
+ case .authenticationFailed:
+ return .authenticationFailed
+ case .biometryNotAvailable:
+ return .biometryNotAvailable
+ case .biometryNotEnrolled:
+ return .biometryNotEnrolled
+ case .biometryLockout:
+ return .biometryLockout
+ case .passcodeNotSet:
+ return .passcodeNotSet
+ default:
+ return nil
+ }
+ }
+
+ static func code(forStatus status: OSStatus) -> Code? {
+ switch status {
+ case errSecUserCanceled:
+ return .userCanceled
+ case errSecAuthFailed:
+ return .authenticationFailed
+ case errSecInteractionNotAllowed:
+ return .biometryNotAvailable
+ case errSecItemNotFound:
+ return .keyNotFound
+ default:
+ return nil
+ }
+ }
+
+ private static let cryptoTokenKitErrorDomain = "CryptoTokenKit"
+
+ // TKError.Code values; the Secure Enclave reports these when the key is unusable.
+ static func code(forCryptoTokenKitErrorCode rawValue: Int) -> Code? {
+ switch rawValue {
+ case -4:
+ return .userCanceled
+ case -5:
+ return .authenticationFailed
+ case -6:
+ return .keyInvalidated
+ default:
+ return nil
+ }
+ }
+}
diff --git a/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec b/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec
new file mode 100644
index 00000000000..bce85d59e29
--- /dev/null
+++ b/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec
@@ -0,0 +1,32 @@
+require 'json'
+
+package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
+
+Pod::Spec.new do |s|
+ s.name = 'ClerkExpoBiometrics'
+ s.version = package['version']
+ s.summary = package['description']
+ s.description = package['description']
+ s.license = package['license']
+ s.author = package['author']
+ s.homepage = package['homepage']
+ s.platforms = { :ios => '15.1' }
+ s.swift_version = '5.9'
+ s.source = { git: 'https://github.com/clerk/javascript.git' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+ s.frameworks = 'LocalAuthentication', 'Security'
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+
+ s.source_files = '*.swift'
+
+ s.test_spec 'Tests' do |test_spec|
+ test_spec.source_files = 'Tests/**/*.swift'
+ test_spec.resources = 'Tests/Resources/*.json'
+ end
+end
diff --git a/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift b/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift
new file mode 100644
index 00000000000..ce017190500
--- /dev/null
+++ b/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift
@@ -0,0 +1,158 @@
+import ExpoModulesCore
+import Foundation
+
+final class ClerkBiometricsException: Exception {
+ private let errorCode: String
+ private let message: String
+
+ init(_ error: BiometricsError) {
+ errorCode = error.code.rawValue
+ message = error.message
+ super.init()
+ }
+
+ override var code: String { errorCode }
+ override var reason: String { message }
+}
+
+struct BiometricAvailabilityResult: Record {
+ @Field var biometryType: String = "none"
+ @Field var canEvaluateBiometrics: Bool = false
+ @Field var canEvaluateDeviceOwner: Bool = false
+ @Field var errorCode: String?
+}
+
+struct BiometricCredentialKeyResult: Record {
+ @Field var localKeyId: String = ""
+ @Field var publicKeyJwk: String = ""
+}
+
+struct BiometricCredentialRecordInput: Record {
+ @Field var id: String = ""
+ @Field var localKeyId: String = ""
+ @Field var userId: String = ""
+ @Field var appIdentifier: String = ""
+ @Field var identifierHint: String?
+ @Field var policy: String = ""
+ @Field var createdAt: Double = -1
+ @Field var updatedAt: Double = -1
+}
+
+struct SaveRecordOptions: Record {
+ @Field var removeOtherRecordsForApp: Bool = false
+}
+
+struct InstallationMarkerResult: Record {
+ @Field var wiped: Bool = false
+}
+
+public final class ClerkExpoBiometricsModule: Module {
+ private let keyManager = BiometricKeyManager()
+ private lazy var store = BiometricCredentialStore.live(keyManager: keyManager)
+
+ public func definition() -> ModuleDefinition {
+ Name("ClerkExpoBiometrics")
+
+ Function("getAppIdentifier") { () -> String in
+ Bundle.main.bundleIdentifier ?? ""
+ }
+
+ AsyncFunction("getAvailability") { () -> BiometricAvailabilityResult in
+ let availability = self.keyManager.availability()
+ let result = BiometricAvailabilityResult()
+ result.biometryType = availability.biometryType
+ result.canEvaluateBiometrics = availability.canEvaluateBiometrics
+ result.canEvaluateDeviceOwner = availability.canEvaluateDeviceOwner
+ result.errorCode = availability.errorCode?.rawValue
+ return result
+ }
+
+ AsyncFunction("createKey") { (policy: String) throws -> BiometricCredentialKeyResult in
+ try Self.bridge {
+ guard let policy = BiometricCredentialPolicy(rawValue: policy) else {
+ throw BiometricsError(.invalidArgument, "Unknown biometric credential policy '\(policy)'.")
+ }
+ let key = try self.keyManager.createKey(policy: policy)
+ let result = BiometricCredentialKeyResult()
+ result.localKeyId = key.localKeyId
+ result.publicKeyJwk = key.publicKeyJWK
+ return result
+ }
+ }
+
+ AsyncFunction("sign") { (localKeyId: String, clientData: String, reason: String?) throws -> String in
+ try Self.bridge {
+ try self.keyManager.sign(localKeyId: localKeyId, clientData: clientData, reason: reason)
+ }
+ }
+
+ AsyncFunction("hasKey") { (localKeyId: String) throws -> Bool in
+ try Self.bridge { try self.keyManager.hasKey(localKeyId: localKeyId) }
+ }
+
+ AsyncFunction("deleteKey") { (localKeyId: String) throws in
+ try Self.bridge { try self.keyManager.deleteKey(localKeyId: localKeyId) }
+ }
+
+ // Store operations run on the main queue so they cannot interleave with ClerkKit's @MainActor store access.
+ AsyncFunction("listRecords") { () throws -> String in
+ try Self.bridge { try self.store.listRecordsJSON() }
+ }.runOnQueue(.main)
+
+ AsyncFunction("saveRecord") { (input: BiometricCredentialRecordInput, options: SaveRecordOptions) throws in
+ try Self.bridge {
+ try self.store.save(Self.record(from: input), removeOtherRecordsForApp: options.removeOtherRecordsForApp)
+ }
+ }.runOnQueue(.main)
+
+ AsyncFunction("deleteRecord") { (localKeyId: String) throws in
+ try Self.bridge { try self.store.deleteRecords(localKeyId: localKeyId) }
+ }.runOnQueue(.main)
+
+ AsyncFunction("ensureInstallationMarker") { () throws -> InstallationMarkerResult in
+ try Self.bridge {
+ let result = InstallationMarkerResult()
+ result.wiped = try self.store.ensureInstallationMarker()
+ return result
+ }
+ }.runOnQueue(.main)
+ }
+
+ static func record(from input: BiometricCredentialRecordInput) throws -> BiometricCredentialRecord {
+ for (name, value) in [
+ ("id", input.id),
+ ("localKeyId", input.localKeyId),
+ ("userId", input.userId),
+ ("appIdentifier", input.appIdentifier),
+ ] where value.isEmpty {
+ throw BiometricsError(.invalidArgument, "record.\(name) must be a non-empty string.")
+ }
+ guard let policy = BiometricCredentialPolicy(rawValue: input.policy) else {
+ throw BiometricsError(.invalidArgument, "Unknown biometric credential policy '\(input.policy)'.")
+ }
+ guard
+ BiometricCredentialRecord.isValidTimestamp(input.createdAt),
+ BiometricCredentialRecord.isValidTimestamp(input.updatedAt)
+ else {
+ throw BiometricsError(.invalidArgument, "record.createdAt and record.updatedAt must be milliseconds since the Unix epoch.")
+ }
+ return BiometricCredentialRecord(
+ id: input.id,
+ localKeyId: input.localKeyId,
+ userId: input.userId,
+ appIdentifier: input.appIdentifier,
+ identifierHint: input.identifierHint,
+ policy: policy,
+ createdAt: input.createdAt,
+ updatedAt: input.updatedAt
+ )
+ }
+
+ private static func bridge(_ body: () throws -> T) throws -> T {
+ do {
+ return try body()
+ } catch let error as BiometricsError {
+ throw ClerkBiometricsException(error)
+ }
+ }
+}
diff --git a/packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift b/packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift
new file mode 100644
index 00000000000..c4461614eb8
--- /dev/null
+++ b/packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift
@@ -0,0 +1,277 @@
+import CryptoKit
+import Foundation
+import Security
+import XCTest
+@testable import ClerkExpoBiometrics
+
+/// Mirrors clerk-ios `BiometricCredentialStorageContractTests` (contract version 1) with the same vectors and fixture.
+final class BiometricCredentialContractTests: XCTestCase {
+ private let fixtureLocalKeyId = "tdlk_0123456789abcdef0123456789abcdef"
+
+ // MARK: - Secure Enclave key
+
+ func testPrivateKeyQueryUsesContractApplicationTagWithoutAccessGroup() {
+ let query = BiometricKeyManager.privateKeyQuery(localKeyId: fixtureLocalKeyId)
+
+ XCTAssertEqual(query[kSecClass as String] as? String, kSecClassKey as String)
+ XCTAssertEqual(query[kSecAttrKeyClass as String] as? String, kSecAttrKeyClassPrivate as String)
+ XCTAssertEqual(query[kSecAttrKeyType as String] as? String, kSecAttrKeyTypeECSECPrimeRandom as String)
+ XCTAssertEqual(
+ query[kSecAttrApplicationTag as String] as? Data,
+ Data("dev.clerk.trusted_device.tdlk_0123456789abcdef0123456789abcdef".utf8)
+ )
+ XCTAssertNil(query[kSecAttrAccessGroup as String])
+ XCTAssertNil(query[kSecAttrLabel as String])
+ XCTAssertNil(query[kSecAttrApplicationLabel as String])
+ XCTAssertEqual(query.count, 4)
+ }
+
+ func testPrivateKeyAttributesMatchContract() throws {
+ let attributes = BiometricKeyManager.privateKeyAttributes(
+ localKeyId: fixtureLocalKeyId,
+ accessControl: try BiometricKeyManager.accessControl(policy: .biometryCurrentSet)
+ )
+
+ XCTAssertEqual(attributes[kSecAttrKeyType as String] as? String, kSecAttrKeyTypeECSECPrimeRandom as String)
+ XCTAssertEqual(attributes[kSecAttrKeySizeInBits as String] as? Int, 256)
+ XCTAssertEqual(attributes[kSecAttrTokenID as String] as? String, kSecAttrTokenIDSecureEnclave as String)
+ XCTAssertNil(attributes[kSecAttrAccessGroup as String])
+ XCTAssertEqual(attributes.count, 4)
+
+ let privateKeyAttributes = try XCTUnwrap(attributes[kSecPrivateKeyAttrs as String] as? [String: Any])
+ XCTAssertEqual(privateKeyAttributes[kSecAttrIsPermanent as String] as? Bool, true)
+ XCTAssertEqual(
+ privateKeyAttributes[kSecAttrApplicationTag as String] as? Data,
+ Data("dev.clerk.trusted_device.tdlk_0123456789abcdef0123456789abcdef".utf8)
+ )
+ XCTAssertNotNil(privateKeyAttributes[kSecAttrAccessControl as String])
+ XCTAssertNil(privateKeyAttributes[kSecAttrAccessGroup as String])
+ XCTAssertEqual(privateKeyAttributes.count, 3)
+ }
+
+ func testPolicyRawValuesAndAccessControlFlagsMatchContract() throws {
+ XCTAssertEqual(BiometricCredentialPolicy.biometryCurrentSet.rawValue, "biometry_current_set")
+ XCTAssertEqual(BiometricCredentialPolicy.biometryAny.rawValue, "biometry_any")
+ XCTAssertEqual(BiometricCredentialPolicy.biometryOrDevicePasscode.rawValue, "biometry_or_device_passcode")
+
+ XCTAssertEqual(BiometricKeyManager.accessControlFlags(for: .biometryCurrentSet), [.privateKeyUsage, .biometryCurrentSet])
+ XCTAssertEqual(BiometricKeyManager.accessControlFlags(for: .biometryAny), [.privateKeyUsage, .biometryAny])
+ XCTAssertEqual(BiometricKeyManager.accessControlFlags(for: .biometryOrDevicePasscode), [.privateKeyUsage, .userPresence])
+
+ for policy in BiometricCredentialPolicy.allCases {
+ XCTAssertNoThrow(try BiometricKeyManager.accessControl(policy: policy))
+ }
+ }
+
+ func testLocalKeyIdFormat() {
+ let localKeyId = BiometricCredentialCoding.makeLocalKeyId()
+
+ XCTAssertTrue(localKeyId.hasPrefix("tdlk_"))
+ let suffix = localKeyId.dropFirst("tdlk_".count)
+ XCTAssertEqual(suffix.count, 32)
+ XCTAssertTrue(suffix.allSatisfy { "0123456789abcdef".contains($0) })
+ XCTAssertNotEqual(localKeyId, BiometricCredentialCoding.makeLocalKeyId())
+ }
+
+ // MARK: - Signing and public key
+
+ func testPublicKeyJWKMatchesContractFormat() throws {
+ var representation = Data([0x04])
+ representation.append(Data(repeating: 0x01, count: 32))
+ representation.append(Data(repeating: 0x02, count: 32))
+
+ XCTAssertEqual(
+ try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: representation),
+ #"{"kty":"EC","crv":"P-256","x":"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE","y":"AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI","alg":"ES256"}"#
+ )
+ }
+
+ func testPublicKeyJWKRejectsCompressedOrShortKeys() {
+ XCTAssertThrowsError(try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: Data([0x02] + Array(repeating: 1, count: 32))))
+ XCTAssertThrowsError(try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: Data([0x05] + Array(repeating: 1, count: 64))))
+ }
+
+ func testSignatureIsRawRAndSEncodedAsUnpaddedBase64URL() throws {
+ let rComponent: [UInt8] = [0x00, 0x80] + Array(repeating: 0xAA, count: 31)
+ let sComponent: [UInt8] = Array(repeating: 0x11, count: 31)
+ let der = Data([0x30, 0x44, 0x02, UInt8(rComponent.count)] + rComponent + [0x02, UInt8(sComponent.count)] + sComponent)
+
+ let raw = try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: der)
+
+ XCTAssertEqual(raw.count, 64)
+ XCTAssertEqual(
+ BiometricCredentialCoding.base64URLEncodedString(raw),
+ "gKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqoAEREREREREREREREREREREREREREREREREREREREREQ"
+ )
+ }
+
+ func testRawSignatureRejectsMalformedDER() {
+ let valid: [UInt8] = [0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]
+ XCTAssertNoThrow(try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: Data(valid)))
+
+ let malformed: [[UInt8]] = [
+ [],
+ [0x31] + valid.dropFirst(),
+ [0x30, 0x07] + valid.dropFirst(2),
+ valid + [0x00],
+ [0x30, 0x06, 0x02, 0x01, 0x81, 0x02, 0x01, 0x02],
+ [0x30, 0x06, 0x02, 0x00, 0x02, 0x02, 0x01, 0x02],
+ [0x30, 0x25, 0x02, 0x21, 0x01] + Array(repeating: 0x01, count: 32) + [0x02, 0x01, 0x02],
+ ]
+ for bytes in malformed {
+ XCTAssertThrowsError(try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: Data(bytes)), "\(bytes)") { error in
+ XCTAssertEqual((error as? BiometricsError)?.code, .signingFailed)
+ }
+ }
+ }
+
+ func testSecuritySignatureConvertsToVerifiableRawSignature() throws {
+ var error: Unmanaged?
+ let attributes: [String: Any] = [
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
+ kSecAttrKeySizeInBits as String: 256,
+ ]
+ let privateKey = try XCTUnwrap(SecKeyCreateRandomKey(attributes as CFDictionary, &error))
+ let publicKey = try XCTUnwrap(SecKeyCopyPublicKey(privateKey))
+ let publicKeyData = try XCTUnwrap(SecKeyCopyExternalRepresentation(publicKey, &error) as Data?)
+ let clientData = #"{"challenge":"abc","nonce":"é"}"#
+
+ for _ in 0 ..< 32 {
+ let der = try XCTUnwrap(SecKeyCreateSignature(
+ privateKey,
+ .ecdsaSignatureMessageX962SHA256,
+ Data(clientData.utf8) as CFData,
+ &error
+ ) as Data?)
+ let raw = try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: der)
+
+ XCTAssertEqual(raw.count, 64)
+ let signature = try P256.Signing.ECDSASignature(rawRepresentation: raw)
+ let verifyingKey = try P256.Signing.PublicKey(x963Representation: publicKeyData)
+ XCTAssertTrue(verifyingKey.isValidSignature(signature, for: Data(clientData.utf8)))
+ }
+
+ let jwk = try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: publicKeyData)
+ let decoded = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(jwk.utf8)) as? [String: String])
+ XCTAssertEqual(decoded["x"], BiometricCredentialCoding.base64URLEncodedString(publicKeyData[1 ..< 33]))
+ XCTAssertEqual(decoded["y"], BiometricCredentialCoding.base64URLEncodedString(publicKeyData[33 ..< 65]))
+ XCTAssertFalse(jwk.contains("="))
+ }
+
+ // MARK: - Metadata Keychain item
+
+ func testMetadataItemQueryMatchesContract() {
+ let query = KeychainMetadataStorage(service: "com.clerk.example").baseQuery()
+
+ XCTAssertEqual(query[kSecClass as String] as? String, kSecClassGenericPassword as String)
+ XCTAssertEqual(query[kSecAttrService as String] as? String, "com.clerk.example")
+ XCTAssertEqual(query[kSecAttrAccount as String] as? String, "trustedDeviceCredentials")
+ XCTAssertNil(query[kSecAttrAccessGroup as String])
+ XCTAssertEqual(query.count, 3)
+ }
+
+ func testMetadataServicePrefersNonEmptyClerkKeychainService() {
+ XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: "com.example.shared", bundleIdentifier: "com.example.app"), "com.example.shared")
+ XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: "", bundleIdentifier: "com.example.app"), "com.example.app")
+ XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: nil, bundleIdentifier: "com.example.app"), "com.example.app")
+ XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: nil, bundleIdentifier: nil), "")
+ }
+
+ func testV1FixtureDecodesToExpectedRecords() throws {
+ let records = try BiometricCredentialRecordList.decode(Self.fixtureData())
+
+ XCTAssertEqual(records.compactMap(BiometricCredentialRecord.init(jsonObject:)), Self.fixtureRecords)
+ }
+
+ func testExpectedRecordsEncodeToV1Fixture() throws {
+ var records: [[String: Any]] = []
+ for record in Self.fixtureRecords {
+ records = BiometricCredentialRecordList.saving(record, into: records).records
+ }
+
+ let stored = try XCTUnwrap(BiometricCredentialRecordList.encode(records))
+ let storedJSON = try JSONSerialization.jsonObject(with: stored) as? NSArray
+ let fixtureJSON = try JSONSerialization.jsonObject(with: Self.fixtureData()) as? NSArray
+ XCTAssertNotNil(storedJSON)
+ XCTAssertEqual(storedJSON, fixtureJSON)
+ }
+
+ func testRecordFieldNamesAndIntegerMilliseconds() throws {
+ let data = try XCTUnwrap(BiometricCredentialRecordList.encode(Self.fixtureRecords.prefix(2).map(\.jsonObject)))
+ let records = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]])
+
+ XCTAssertEqual(Set(records[0].keys), ["id", "localKeyId", "userId", "appIdentifier", "identifierHint", "policy", "createdAt", "updatedAt"])
+ XCTAssertEqual(Set(records[1].keys), ["id", "localKeyId", "userId", "appIdentifier", "policy", "createdAt", "updatedAt"])
+ XCTAssertEqual((records[0]["createdAt"] as? NSNumber)?.int64Value, 1_714_000_000_500)
+ XCTAssertTrue(String(decoding: data, as: UTF8.self).contains(#""createdAt":1714000000500"#))
+ }
+
+ func testReadersIgnoreUnknownFieldsAndNormalizeIdentifierHints() throws {
+ let json = """
+ [{"id":"tdc_future","localKeyId":"tdlk_future","userId":"user_1","appIdentifier":"com.clerk.example",\
+ "identifierHint":" User@Example.COM ","policy":"biometry_any","createdAt":1714000000000.25,\
+ "updatedAt":1714000000000,"futureField":{"nested":true}}]
+ """
+ let listed = BiometricCredentialRecordList.listable(try BiometricCredentialRecordList.decode(Data(json.utf8)))
+
+ XCTAssertEqual(listed.count, 1)
+ XCTAssertEqual(listed[0]["identifierHint"] as? String, "user@example.com")
+ XCTAssertEqual(listed[0]["futureField"] as? [String: Bool], ["nested": true])
+ XCTAssertEqual((listed[0]["createdAt"] as? NSNumber)?.doubleValue, 1_714_000_000_000.25)
+ }
+
+ func testInstallationMarkerKeyMatchesContractFormat() {
+ XCTAssertEqual(
+ BiometricCredentialCoding.installationMarkerKey(service: "com.clerk.example", accessGroup: nil, appIdentifier: "com.clerk.example"),
+ "com.clerk.trusted-device-installation-marker.s17:com.clerk.example.n.s17:com.clerk.example"
+ )
+ XCTAssertEqual(
+ BiometricCredentialCoding.installationMarkerKey(service: "com.clerk.é", accessGroup: "TEAMID.com.clerk.shared", appIdentifier: "com.clerk.example"),
+ "com.clerk.trusted-device-installation-marker.s12:com.clerk.é.s23:TEAMID.com.clerk.shared.s17:com.clerk.example"
+ )
+ XCTAssertEqual(
+ BiometricCredentialCoding.installationMarkerKey(service: "com.clerk.example", accessGroup: "", appIdentifier: "com.clerk.example"),
+ "com.clerk.trusted-device-installation-marker.s17:com.clerk.example.s0:.s17:com.clerk.example"
+ )
+ }
+
+ static let fixtureRecords: [BiometricCredentialRecord] = [
+ BiometricCredentialRecord(
+ id: "tdc_contract_current_set",
+ localKeyId: "tdlk_0123456789abcdef0123456789abcdef",
+ userId: "user_contract_1",
+ appIdentifier: "com.clerk.example",
+ identifierHint: "user@example.com",
+ policy: .biometryCurrentSet,
+ createdAt: 1_714_000_000_500,
+ updatedAt: 1_714_000_001_500
+ ),
+ BiometricCredentialRecord(
+ id: "tdc_contract_any",
+ localKeyId: "tdlk_fedcba9876543210fedcba9876543210",
+ userId: "user_contract_2",
+ appIdentifier: "com.clerk.example",
+ identifierHint: nil,
+ policy: .biometryAny,
+ createdAt: 1_714_000_002_000,
+ updatedAt: 1_714_000_003_000
+ ),
+ BiometricCredentialRecord(
+ id: "tdc_contract_passcode",
+ localKeyId: "tdlk_00000000000000000000000000000000",
+ userId: "user_contract_1",
+ appIdentifier: "com.clerk.other",
+ identifierHint: "+15555550100",
+ policy: .biometryOrDevicePasscode,
+ createdAt: 1_714_000_004_000,
+ updatedAt: 1_714_000_004_000
+ ),
+ ]
+
+ static func fixtureData() throws -> Data {
+ let url = try XCTUnwrap(
+ Bundle(for: BiometricCredentialContractTests.self).url(forResource: "BiometricCredentialStorageContractV1", withExtension: "json")
+ )
+ return try Data(contentsOf: url)
+ }
+}
diff --git a/packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift b/packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift
new file mode 100644
index 00000000000..63efe800fdb
--- /dev/null
+++ b/packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift
@@ -0,0 +1,320 @@
+import Foundation
+import Security
+import XCTest
+@testable import ClerkExpoBiometrics
+
+final class BiometricCredentialStoreTests: XCTestCase {
+ private final class InMemoryStorage: BiometricCredentialMetadataStorage {
+ var data: Data?
+ var writes = 0
+ var deletes = 0
+
+ func read() throws -> Data? { data }
+ func write(_ data: Data) throws {
+ writes += 1
+ self.data = data
+ }
+
+ func delete() throws {
+ deletes += 1
+ data = nil
+ }
+
+ func records() throws -> [[String: Any]] {
+ try BiometricCredentialRecordList.decode(data)
+ }
+
+ func ids() throws -> [String?] {
+ try records().map { $0["id"] as? String }
+ }
+ }
+
+ private var storage: InMemoryStorage!
+ private var userDefaults: UserDefaults!
+ private var suiteName: String!
+ private var deletedKeys: [String] = []
+ private var failingKeys: Set = []
+
+ override func setUp() {
+ super.setUp()
+ storage = InMemoryStorage()
+ suiteName = "ClerkExpoBiometricsTests.\(UUID().uuidString)"
+ userDefaults = UserDefaults(suiteName: suiteName)
+ deletedKeys = []
+ failingKeys = []
+ }
+
+ override func tearDown() {
+ userDefaults.removePersistentDomain(forName: suiteName)
+ super.tearDown()
+ }
+
+ private func makeStore(appIdentifier: String? = "com.clerk.example") -> BiometricCredentialStore {
+ BiometricCredentialStore(
+ storage: storage,
+ deleteKey: { [unowned self] localKeyId in
+ if failingKeys.contains(localKeyId) {
+ throw BiometricsError(.storageFailed, "delete failed")
+ }
+ deletedKeys.append(localKeyId)
+ },
+ userDefaults: userDefaults,
+ service: "com.clerk.example",
+ appIdentifier: appIdentifier
+ )
+ }
+
+ private let markerKey = "com.clerk.trusted-device-installation-marker.s17:com.clerk.example.n.s17:com.clerk.example"
+
+ private func setMarker() {
+ userDefaults.set(true, forKey: markerKey)
+ }
+
+ private func record(
+ id: String,
+ localKeyId: String? = nil,
+ userId: String = "user_1",
+ appIdentifier: String = "com.clerk.example",
+ identifierHint: String? = nil
+ ) -> BiometricCredentialRecord {
+ BiometricCredentialRecord(
+ id: id,
+ localKeyId: localKeyId ?? "tdlk_\(id)",
+ userId: userId,
+ appIdentifier: appIdentifier,
+ identifierHint: identifierHint,
+ policy: .biometryCurrentSet,
+ createdAt: 1_714_000_000_000,
+ updatedAt: 1_714_000_000_000
+ )
+ }
+
+ // MARK: - Reinstall marker
+
+ func testMissingMarkerWipesOnlyThisAppsRecordsAndKeysThenSetsMarker() throws {
+ storage.data = try BiometricCredentialContractTests.fixtureData()
+ let store = makeStore()
+
+ XCTAssertEqual(store.installationMarkerKey, markerKey)
+ XCTAssertTrue(try store.ensureInstallationMarker())
+
+ XCTAssertEqual(deletedKeys, ["tdlk_0123456789abcdef0123456789abcdef", "tdlk_fedcba9876543210fedcba9876543210"])
+ XCTAssertEqual(try storage.ids(), ["tdc_contract_passcode"])
+ XCTAssertEqual(userDefaults.object(forKey: markerKey) as? Bool, true)
+ XCTAssertEqual(UserDefaults.standard.object(forKey: markerKey) as? Bool, nil)
+ }
+
+ func testMarkerIsIdempotent() throws {
+ let store = makeStore()
+
+ XCTAssertTrue(try store.ensureInstallationMarker())
+ storage.data = try BiometricCredentialContractTests.fixtureData()
+ XCTAssertFalse(try store.ensureInstallationMarker())
+
+ XCTAssertEqual(deletedKeys, [])
+ XCTAssertEqual(try storage.records().count, 3)
+ }
+
+ func testMarkerIsNotSetWhenAKeyCannotBeDeleted() throws {
+ storage.data = try BiometricCredentialContractTests.fixtureData()
+ failingKeys = ["tdlk_fedcba9876543210fedcba9876543210"]
+ let store = makeStore()
+
+ XCTAssertThrowsError(try store.ensureInstallationMarker())
+
+ XCTAssertEqual(try storage.ids(), ["tdc_contract_any", "tdc_contract_passcode"])
+ XCTAssertNil(userDefaults.object(forKey: markerKey))
+
+ failingKeys = []
+ XCTAssertTrue(try store.ensureInstallationMarker())
+ XCTAssertEqual(try storage.ids(), ["tdc_contract_passcode"])
+ }
+
+ func testMarkerWipesMalformedRecordsForThisApp() throws {
+ storage.data = Data(#"[{"appIdentifier":"com.clerk.example","localKeyId":"tdlk_bad"},{"appIdentifier":"com.clerk.example"}]"#.utf8)
+
+ XCTAssertTrue(try makeStore().ensureInstallationMarker())
+
+ XCTAssertEqual(deletedKeys, ["tdlk_bad"])
+ XCTAssertNil(storage.data)
+ XCTAssertEqual(storage.deletes, 1)
+ }
+
+ func testMarkerIsNotSetWhenMetadataIsNotAnArray() {
+ storage.data = Data(#"{"id":"tdc_1"}"#.utf8)
+
+ XCTAssertThrowsError(try makeStore().ensureInstallationMarker()) { error in
+ XCTAssertEqual((error as? BiometricsError)?.code, .storageFailed)
+ }
+ XCTAssertNil(userDefaults.object(forKey: markerKey))
+ }
+
+ func testMissingAppIdentifierSkipsTheMarker() throws {
+ storage.data = try BiometricCredentialContractTests.fixtureData()
+
+ XCTAssertFalse(try makeStore(appIdentifier: nil).ensureInstallationMarker())
+ XCTAssertEqual(try storage.records().count, 3)
+ }
+
+ func testStoreOperationsApplyTheMarkerFirst() throws {
+ storage.data = try BiometricCredentialContractTests.fixtureData()
+ let store = makeStore()
+
+ let listed = try JSONSerialization.jsonObject(with: Data(store.listRecordsJSON().utf8)) as? [[String: Any]]
+
+ XCTAssertEqual(listed?.map { $0["id"] as? String }, ["tdc_contract_passcode"])
+ XCTAssertEqual(userDefaults.object(forKey: markerKey) as? Bool, true)
+ }
+
+ // MARK: - List
+
+ func testListReturnsWellFormedRecordsForEveryAppWithUnknownFields() throws {
+ setMarker()
+ storage.data = Data("""
+ [{"id":"tdc_1","localKeyId":"tdlk_1","userId":"user_1","appIdentifier":"com.clerk.example","identifierHint":" A@B.co ",\
+ "policy":"biometry_any","createdAt":1714000000000,"updatedAt":1714000000001,"futureField":[1,true,null]},\
+ {"id":"tdc_2","localKeyId":"tdlk_2","userId":"user_2","appIdentifier":"com.clerk.other","identifierHint":" ",\
+ "policy":"biometry_current_set","createdAt":1714000000000,"updatedAt":1714000000000},\
+ {"id":"tdc_bad_policy","localKeyId":"tdlk_3","userId":"user_1","appIdentifier":"com.clerk.example",\
+ "policy":"face","createdAt":1,"updatedAt":1},\
+ {"id":"tdc_bool_date","localKeyId":"tdlk_4","userId":"user_1","appIdentifier":"com.clerk.example",\
+ "policy":"biometry_any","createdAt":true,"updatedAt":1},\
+ {"id":"tdc_missing","userId":"user_1","appIdentifier":"com.clerk.example","policy":"biometry_any","createdAt":1,"updatedAt":1},\
+ {"id":"tdc_hint_number","localKeyId":"tdlk_5","userId":"user_1","appIdentifier":"com.clerk.example",\
+ "identifierHint":5,"policy":"biometry_any","createdAt":1,"updatedAt":1},\
+ {"id":"tdc_null_hint","localKeyId":"tdlk_6","userId":"user_1","appIdentifier":"com.clerk.example",\
+ "identifierHint":null,"policy":"biometry_or_device_passcode","createdAt":1,"updatedAt":1}]
+ """.utf8)
+
+ let json = try makeStore().listRecordsJSON()
+ let listed = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: Any]])
+
+ XCTAssertEqual(listed.map { $0["id"] as? String }, ["tdc_1", "tdc_2", "tdc_null_hint"])
+ XCTAssertEqual(listed[0]["identifierHint"] as? String, "a@b.co")
+ XCTAssertNil(listed[1]["identifierHint"])
+ XCTAssertNil(listed[2]["identifierHint"])
+ XCTAssertTrue(json.contains(#""futureField":[1,true,null]"#))
+ XCTAssertTrue(json.contains(#""updatedAt":1714000000001"#))
+ }
+
+ func testListIsEmptyWithoutAnItem() throws {
+ setMarker()
+ XCTAssertEqual(try makeStore().listRecordsJSON(), "[]")
+ }
+
+ // MARK: - Save
+
+ func testSaveAddsRecordAndPreservesOtherAppsAndUnknownFields() throws {
+ setMarker()
+ storage.data = Data("""
+ [{"id":"tdc_other","localKeyId":"tdlk_other","userId":"user_1","appIdentifier":"com.clerk.other",\
+ "policy":"biometry_any","createdAt":1714000000000,"updatedAt":1714000000000,"futureField":"kept"},\
+ {"id":"tdc_same","localKeyId":"tdlk_same","userId":"user_2","appIdentifier":"com.clerk.example",\
+ "policy":"biometry_any","createdAt":1714000000000,"updatedAt":1714000000000,"futureField":"also kept"}]
+ """.utf8)
+
+ try makeStore().save(record(id: "tdc_new", identifierHint: " New@Example.com"), removeOtherRecordsForApp: false)
+
+ let records = try storage.records()
+ XCTAssertEqual(records.map { $0["id"] as? String }, ["tdc_other", "tdc_same", "tdc_new"])
+ XCTAssertEqual(records[0]["futureField"] as? String, "kept")
+ XCTAssertEqual(records[1]["futureField"] as? String, "also kept")
+ XCTAssertEqual(records[2]["identifierHint"] as? String, "new@example.com")
+ XCTAssertEqual(deletedKeys, [])
+ }
+
+ func testSaveReplacesSameIdAndDeletesItsOldKey() throws {
+ setMarker()
+ let store = makeStore()
+ try store.save(record(id: "tdc_1", localKeyId: "tdlk_old"), removeOtherRecordsForApp: false)
+
+ try store.save(record(id: "tdc_1", localKeyId: "tdlk_new"), removeOtherRecordsForApp: false)
+
+ let records = try storage.records()
+ XCTAssertEqual(records.count, 1)
+ XCTAssertEqual(records[0]["localKeyId"] as? String, "tdlk_new")
+ XCTAssertEqual(deletedKeys, ["tdlk_old"])
+ }
+
+ func testSaveDropsMalformedRecordsForTheSameAppOnly() throws {
+ setMarker()
+ storage.data = Data("""
+ [{"id":"tdc_bad","appIdentifier":"com.clerk.example"},{"id":"tdc_bad_other","appIdentifier":"com.clerk.other"}]
+ """.utf8)
+
+ try makeStore().save(record(id: "tdc_1"), removeOtherRecordsForApp: false)
+
+ XCTAssertEqual(try storage.ids(), ["tdc_bad_other", "tdc_1"])
+ XCTAssertEqual(deletedKeys, [])
+ }
+
+ func testSaveRemovingOtherRecordsDeletesThisAppsOtherRecordsAndKeys() throws {
+ setMarker()
+ let store = makeStore()
+ try store.save(record(id: "tdc_a", userId: "user_1"), removeOtherRecordsForApp: false)
+ try store.save(record(id: "tdc_b", userId: "user_2"), removeOtherRecordsForApp: false)
+ try store.save(record(id: "tdc_other_app", appIdentifier: "com.clerk.other"), removeOtherRecordsForApp: false)
+ failingKeys = ["tdlk_tdc_b"]
+
+ try store.save(record(id: "tdc_new"), removeOtherRecordsForApp: true)
+
+ XCTAssertEqual(deletedKeys, ["tdlk_tdc_a"])
+ XCTAssertEqual(try storage.ids(), ["tdc_b", "tdc_other_app", "tdc_new"])
+ }
+
+ func testSaveAppliesTheMarkerBeforeWriting() throws {
+ storage.data = try BiometricCredentialContractTests.fixtureData()
+
+ try makeStore().save(record(id: "tdc_new"), removeOtherRecordsForApp: false)
+
+ XCTAssertEqual(try storage.ids(), ["tdc_contract_passcode", "tdc_new"])
+ XCTAssertEqual(userDefaults.object(forKey: markerKey) as? Bool, true)
+ }
+
+ func testSaveFailsWithoutWritingWhenTheMarkerCannotBeSet() throws {
+ storage.data = try BiometricCredentialContractTests.fixtureData()
+ failingKeys = ["tdlk_0123456789abcdef0123456789abcdef"]
+
+ XCTAssertThrowsError(try makeStore().save(record(id: "tdc_new"), removeOtherRecordsForApp: false))
+
+ XCTAssertFalse(try storage.ids().contains("tdc_new"))
+ }
+
+ // MARK: - Delete
+
+ func testDeleteRecordsDeletesKeyThenRecordsAndDropsTheItemWhenEmpty() throws {
+ setMarker()
+ let store = makeStore()
+ try store.save(record(id: "tdc_1", localKeyId: "tdlk_1"), removeOtherRecordsForApp: false)
+
+ try store.deleteRecords(localKeyId: "tdlk_1")
+
+ XCTAssertEqual(deletedKeys, ["tdlk_1"])
+ XCTAssertNil(storage.data)
+ XCTAssertEqual(storage.deletes, 1)
+ }
+
+ func testDeleteRecordsKeepsRecordsWhenTheKeyCannotBeDeleted() throws {
+ setMarker()
+ let store = makeStore()
+ try store.save(record(id: "tdc_1", localKeyId: "tdlk_1"), removeOtherRecordsForApp: false)
+ failingKeys = ["tdlk_1"]
+
+ XCTAssertThrowsError(try store.deleteRecords(localKeyId: "tdlk_1"))
+
+ XCTAssertEqual(try storage.ids(), ["tdc_1"])
+ }
+
+ func testDeleteRecordsPreservesUnknownFieldsOnOtherRecords() throws {
+ setMarker()
+ storage.data = Data("""
+ [{"id":"tdc_1","localKeyId":"tdlk_1","futureField":"kept"},{"id":"tdc_2","localKeyId":"tdlk_2"}]
+ """.utf8)
+
+ try makeStore().deleteRecords(localKeyId: "tdlk_2")
+
+ let records = try storage.records()
+ XCTAssertEqual(records.count, 1)
+ XCTAssertEqual(records[0]["futureField"] as? String, "kept")
+ }
+}
diff --git a/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift b/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift
new file mode 100644
index 00000000000..06d43ec04bb
--- /dev/null
+++ b/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift
@@ -0,0 +1,146 @@
+import Foundation
+import LocalAuthentication
+import Security
+import XCTest
+@testable import ClerkExpoBiometrics
+
+/// Skips tests that need the real Keychain when the test bundle runs without a host app.
+func skipUnlessKeychainIsAvailable() throws {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "ClerkExpoBiometricsTests.probe",
+ ]
+ let status = SecItemCopyMatching(query as CFDictionary, nil)
+ try XCTSkipIf(status == errSecMissingEntitlement, "The Keychain is only available to tests running in a host app.")
+}
+
+final class KeychainMetadataStorageTests: XCTestCase {
+ private var storage: KeychainMetadataStorage!
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ try skipUnlessKeychainIsAvailable()
+ storage = KeychainMetadataStorage(service: "ClerkExpoBiometricsTests.\(UUID().uuidString)")
+ }
+
+ override func tearDownWithError() throws {
+ try storage?.delete()
+ try super.tearDownWithError()
+ }
+
+ func testWriteAddsThenUpdatesTheGenericPasswordItem() throws {
+ XCTAssertNil(try storage.read())
+
+ try storage.write(Data("[1]".utf8))
+ try storage.write(Data("[2]".utf8))
+
+ XCTAssertEqual(try storage.read(), Data("[2]".utf8))
+
+ var query = storage.baseQuery()
+ query[kSecReturnAttributes as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitAll
+ var result: CFTypeRef?
+ XCTAssertEqual(SecItemCopyMatching(query as CFDictionary, &result), errSecSuccess)
+ let items = try XCTUnwrap(result as? [[String: Any]])
+ XCTAssertEqual(items.count, 1)
+ XCTAssertEqual(items[0][kSecAttrAccessible as String] as? String, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
+ XCTAssertEqual(items[0][kSecAttrAccount as String] as? String, "trustedDeviceCredentials")
+ }
+
+ func testDeleteIsIdempotent() throws {
+ try storage.write(Data("[]".utf8))
+ try storage.delete()
+ try storage.delete()
+
+ XCTAssertNil(try storage.read())
+ }
+}
+
+final class BiometricKeyManagerTests: XCTestCase {
+ func testMissingKeyIsReportedWithoutPrompting() throws {
+ try skipUnlessKeychainIsAvailable()
+ let keyManager = BiometricKeyManager()
+ let localKeyId = BiometricCredentialCoding.makeLocalKeyId()
+
+ XCTAssertFalse(try keyManager.hasKey(localKeyId: localKeyId))
+ XCTAssertNoThrow(try keyManager.deleteKey(localKeyId: localKeyId))
+ XCTAssertThrowsError(try keyManager.sign(localKeyId: localKeyId, clientData: "data", reason: "Sign in")) { error in
+ XCTAssertEqual((error as? BiometricsError)?.code, .keyNotFound)
+ }
+ }
+
+ func testAvailabilityReportsAKnownBiometryType() {
+ let availability = BiometricKeyManager().availability()
+
+ XCTAssertTrue(["faceID", "touchID", "opticID", "none"].contains(availability.biometryType))
+ XCTAssertEqual(availability.errorCode == nil, availability.canEvaluateBiometrics)
+ }
+
+ func testErrorMapping() {
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.userCancel.rawValue), .userCanceled)
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.systemCancel.rawValue), .systemCanceled)
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.appCancel.rawValue), .systemCanceled)
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.userFallback.rawValue), .userFallback)
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.authenticationFailed.rawValue), .authenticationFailed)
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.biometryNotAvailable.rawValue), .biometryNotAvailable)
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.biometryNotEnrolled.rawValue), .biometryNotEnrolled)
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.biometryLockout.rawValue), .biometryLockout)
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.passcodeNotSet.rawValue), .passcodeNotSet)
+
+ XCTAssertEqual(BiometricsError.code(forStatus: errSecUserCanceled), .userCanceled)
+ XCTAssertEqual(BiometricsError.code(forStatus: errSecAuthFailed), .authenticationFailed)
+ XCTAssertEqual(BiometricsError.code(forStatus: errSecInteractionNotAllowed), .biometryNotAvailable)
+ XCTAssertEqual(BiometricsError.code(forStatus: errSecItemNotFound), .keyNotFound)
+ XCTAssertNil(BiometricsError.code(forStatus: errSecParam))
+
+ let laError = NSError(domain: LAErrorDomain, code: LAError.Code.biometryLockout.rawValue)
+ XCTAssertEqual(BiometricsError.security(laError, fallback: .signingFailed).code, .biometryLockout)
+ XCTAssertEqual(BiometricsError.localAuthentication(laError, fallback: .biometryNotAvailable).code, .biometryLockout)
+ XCTAssertEqual(BiometricsError.localAuthentication(nil, fallback: .biometryNotAvailable).code, .biometryNotAvailable)
+ let statusError = NSError(domain: NSOSStatusErrorDomain, code: Int(errSecUserCanceled))
+ XCTAssertEqual(BiometricsError.security(statusError, fallback: .signingFailed).code, .userCanceled)
+ let tokenError = NSError(domain: "CryptoTokenKit", code: -6)
+ XCTAssertEqual(BiometricsError.security(tokenError, fallback: .signingFailed).code, .keyInvalidated)
+ let unknownError = NSError(domain: "Other", code: 1)
+ XCTAssertEqual(BiometricsError.security(unknownError, fallback: .signingFailed).code, .signingFailed)
+ }
+
+ // Record fields are reference-backed, so each case builds a fresh input.
+ private func makeInput(_ configure: (BiometricCredentialRecordInput) -> Void = { _ in }) -> BiometricCredentialRecordInput {
+ let input = BiometricCredentialRecordInput()
+ input.id = "tdc_1"
+ input.localKeyId = "tdlk_1"
+ input.userId = "user_1"
+ input.appIdentifier = "com.clerk.example"
+ input.identifierHint = " Hint "
+ input.policy = "biometry_any"
+ input.createdAt = 1_714_000_000_000.4
+ input.updatedAt = 1_714_000_000_001
+ configure(input)
+ return input
+ }
+
+ func testRecordInputValidation() throws {
+ let record = try ClerkExpoBiometricsModule.record(from: makeInput())
+ XCTAssertEqual(record.identifierHint, "hint")
+ XCTAssertEqual(record.policy, .biometryAny)
+ XCTAssertEqual(record.jsonObject["createdAt"] as? Int64, 1_714_000_000_000)
+ XCTAssertEqual(record.jsonObject["updatedAt"] as? Int64, 1_714_000_000_001)
+
+ let invalidInputs: [(String, (BiometricCredentialRecordInput) -> Void)] = [
+ ("policy", { $0.policy = "face_id" }),
+ ("id", { $0.id = "" }),
+ ("localKeyId", { $0.localKeyId = "" }),
+ ("userId", { $0.userId = "" }),
+ ("appIdentifier", { $0.appIdentifier = "" }),
+ ("nan createdAt", { $0.createdAt = .nan }),
+ ("negative createdAt", { $0.createdAt = -1 }),
+ ("infinite updatedAt", { $0.updatedAt = .infinity }),
+ ]
+ for (name, configure) in invalidInputs {
+ XCTAssertThrowsError(try ClerkExpoBiometricsModule.record(from: makeInput(configure)), name) { error in
+ XCTAssertEqual((error as? BiometricsError)?.code, .invalidArgument, name)
+ }
+ }
+ }
+}
diff --git a/packages/expo-biometrics/ios/Tests/Resources/BiometricCredentialStorageContractV1.json b/packages/expo-biometrics/ios/Tests/Resources/BiometricCredentialStorageContractV1.json
new file mode 100644
index 00000000000..0bbdf1f1a63
--- /dev/null
+++ b/packages/expo-biometrics/ios/Tests/Resources/BiometricCredentialStorageContractV1.json
@@ -0,0 +1,31 @@
+[
+ {
+ "id": "tdc_contract_current_set",
+ "localKeyId": "tdlk_0123456789abcdef0123456789abcdef",
+ "userId": "user_contract_1",
+ "appIdentifier": "com.clerk.example",
+ "identifierHint": "user@example.com",
+ "policy": "biometry_current_set",
+ "createdAt": 1714000000500,
+ "updatedAt": 1714000001500
+ },
+ {
+ "id": "tdc_contract_any",
+ "localKeyId": "tdlk_fedcba9876543210fedcba9876543210",
+ "userId": "user_contract_2",
+ "appIdentifier": "com.clerk.example",
+ "policy": "biometry_any",
+ "createdAt": 1714000002000,
+ "updatedAt": 1714000003000
+ },
+ {
+ "id": "tdc_contract_passcode",
+ "localKeyId": "tdlk_00000000000000000000000000000000",
+ "userId": "user_contract_1",
+ "appIdentifier": "com.clerk.other",
+ "identifierHint": "+15555550100",
+ "policy": "biometry_or_device_passcode",
+ "createdAt": 1714000004000,
+ "updatedAt": 1714000004000
+ }
+]
diff --git a/packages/expo-biometrics/package.json b/packages/expo-biometrics/package.json
new file mode 100644
index 00000000000..759bbe24955
--- /dev/null
+++ b/packages/expo-biometrics/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@clerk/expo-biometrics",
+ "version": "0.0.0",
+ "description": "Native biometric credential module to be used with Clerk for Expo",
+ "keywords": [
+ "react-native",
+ "expo",
+ "biometrics",
+ "face-id",
+ "touch-id",
+ "ClerkExpoBiometrics",
+ "clerk"
+ ],
+ "homepage": "https://clerk.com/",
+ "bugs": {
+ "url": "https://github.com/clerk/javascript/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/clerk/javascript.git",
+ "directory": "packages/expo-biometrics"
+ },
+ "license": "MIT",
+ "author": "Clerk",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "files": [
+ "dist",
+ "android",
+ "ios",
+ "expo-module.config.json"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "build:declarations": "tsc -p tsconfig.declarations.json",
+ "clean": "rimraf ./dist",
+ "dev": "tsup --watch",
+ "format": "node ../../scripts/format-package.mjs",
+ "format:check": "node ../../scripts/format-package.mjs --check",
+ "lint": "eslint src",
+ "test": "vitest run",
+ "test:watch": "vitest watch"
+ },
+ "devDependencies": {
+ "expo": "~54.0.36"
+ },
+ "peerDependencies": {
+ "expo": "catalog:peer-expo"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts b/packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts
new file mode 100644
index 00000000000..a9adc9aafc8
--- /dev/null
+++ b/packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts
@@ -0,0 +1,26 @@
+import { requireOptionalNativeModule } from 'expo';
+
+import type {
+ BiometricAvailability,
+ BiometricCredentialKey,
+ BiometricCredentialPolicy,
+ BiometricCredentialRecord,
+ InstallationMarkerResult,
+ SaveRecordOptions,
+} from './types';
+
+export interface ClerkExpoBiometricsNativeModule {
+ getAppIdentifier(): string;
+ getAvailability(): Promise;
+ createKey(policy: BiometricCredentialPolicy): Promise;
+ sign(localKeyId: string, clientData: string, reason: string | null): Promise;
+ hasKey(localKeyId: string): Promise;
+ deleteKey(localKeyId: string): Promise;
+ /** JSON-encoded array of records, so fields the bridge cannot represent survive unchanged. */
+ listRecords(): Promise;
+ saveRecord(record: BiometricCredentialRecord, options: SaveRecordOptions): Promise;
+ deleteRecord(localKeyId: string): Promise;
+ ensureInstallationMarker(): Promise;
+}
+
+export default requireOptionalNativeModule('ClerkExpoBiometrics');
diff --git a/packages/expo-biometrics/src/__tests__/index.test.ts b/packages/expo-biometrics/src/__tests__/index.test.ts
new file mode 100644
index 00000000000..fb78fe7178b
--- /dev/null
+++ b/packages/expo-biometrics/src/__tests__/index.test.ts
@@ -0,0 +1,272 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+import type { BiometricCredentialRecord } from '../types';
+
+const mocks = vi.hoisted(() => ({
+ nativeModule: null as Record> | null,
+ requestedName: null as string | null,
+}));
+
+vi.mock('expo', () => ({
+ requireOptionalNativeModule: (name: string) => {
+ mocks.requestedName = name;
+ return mocks.nativeModule;
+ },
+}));
+
+const createNativeModule = () => ({
+ getAppIdentifier: vi.fn().mockReturnValue('com.clerk.example'),
+ getAvailability: vi.fn().mockResolvedValue({
+ biometryType: 'faceID',
+ canEvaluateBiometrics: true,
+ canEvaluateDeviceOwner: true,
+ errorCode: null,
+ }),
+ createKey: vi.fn().mockResolvedValue({ localKeyId: 'tdlk_1', publicKeyJwk: '{"kty":"EC"}' }),
+ sign: vi.fn().mockResolvedValue('c2lnbmF0dXJl'),
+ hasKey: vi.fn().mockResolvedValue(true),
+ deleteKey: vi.fn().mockResolvedValue(undefined),
+ listRecords: vi.fn().mockResolvedValue('[]'),
+ saveRecord: vi.fn().mockResolvedValue(undefined),
+ deleteRecord: vi.fn().mockResolvedValue(undefined),
+ ensureInstallationMarker: vi.fn().mockResolvedValue({ wiped: false }),
+});
+
+const nativeError = (code: string, message = 'native failure') => Object.assign(new Error(message), { code });
+
+const record: BiometricCredentialRecord = {
+ id: 'tdc_1',
+ localKeyId: 'tdlk_1',
+ userId: 'user_1',
+ appIdentifier: 'com.clerk.example',
+ identifierHint: 'user@example.com',
+ policy: 'biometry_current_set',
+ createdAt: 1714000000500,
+ updatedAt: 1714000001500,
+};
+
+const load = async () => import('../index.js');
+
+describe('@clerk/expo-biometrics', () => {
+ let native: ReturnType;
+
+ beforeEach(() => {
+ vi.resetModules();
+ native = createNativeModule();
+ mocks.nativeModule = native;
+ mocks.requestedName = null;
+ });
+
+ test('loads the ClerkExpoBiometrics native module', async () => {
+ const biometrics = await load();
+
+ expect(mocks.requestedName).toBe('ClerkExpoBiometrics');
+ expect(biometrics.getAppIdentifier()).toBe('com.clerk.example');
+ await expect(biometrics.getAvailability()).resolves.toEqual({
+ biometryType: 'faceID',
+ canEvaluateBiometrics: true,
+ canEvaluateDeviceOwner: true,
+ errorCode: null,
+ });
+ });
+
+ test('rejects with native_module_unavailable when the native module is missing', async () => {
+ mocks.nativeModule = null;
+ const biometrics = await load();
+
+ expect(() => biometrics.getAppIdentifier()).toThrow(
+ expect.objectContaining({ name: 'ClerkBiometricsError', code: 'native_module_unavailable' }),
+ );
+ await expect(biometrics.getAvailability()).rejects.toMatchObject({ code: 'native_module_unavailable' });
+ await expect(biometrics.listRecords()).rejects.toMatchObject({ code: 'native_module_unavailable' });
+ });
+
+ describe('keys', () => {
+ test('createKey forwards the policy', async () => {
+ const biometrics = await load();
+
+ await expect(biometrics.createKey('biometry_or_device_passcode')).resolves.toEqual({
+ localKeyId: 'tdlk_1',
+ publicKeyJwk: '{"kty":"EC"}',
+ });
+ expect(native.createKey).toHaveBeenCalledWith('biometry_or_device_passcode');
+ });
+
+ test('createKey rejects unknown policies without calling native', async () => {
+ const biometrics = await load();
+
+ // @ts-expect-error testing an invalid policy
+ await expect(biometrics.createKey('face_id')).rejects.toMatchObject({ code: 'invalid_argument' });
+ expect(native.createKey).not.toHaveBeenCalled();
+ });
+
+ test('sign forwards arguments and defaults the reason to null', async () => {
+ const biometrics = await load();
+
+ await expect(biometrics.sign('tdlk_1', 'client-data', 'Sign in')).resolves.toBe('c2lnbmF0dXJl');
+ await biometrics.sign('tdlk_1', 'client-data');
+
+ expect(native.sign).toHaveBeenNthCalledWith(1, 'tdlk_1', 'client-data', 'Sign in');
+ expect(native.sign).toHaveBeenNthCalledWith(2, 'tdlk_1', 'client-data', null);
+ });
+
+ test('sign validates its arguments', async () => {
+ const biometrics = await load();
+
+ await expect(biometrics.sign('', 'client-data')).rejects.toMatchObject({ code: 'invalid_argument' });
+ // @ts-expect-error testing a non-string client data
+ await expect(biometrics.sign('tdlk_1', 42)).rejects.toMatchObject({ code: 'invalid_argument' });
+ expect(native.sign).not.toHaveBeenCalled();
+ });
+
+ test('hasKey and deleteKey forward the local key id', async () => {
+ const biometrics = await load();
+
+ await expect(biometrics.hasKey('tdlk_1')).resolves.toBe(true);
+ await expect(biometrics.deleteKey('tdlk_1')).resolves.toBeUndefined();
+ expect(native.hasKey).toHaveBeenCalledWith('tdlk_1');
+ expect(native.deleteKey).toHaveBeenCalledWith('tdlk_1');
+ });
+ });
+
+ describe('errors', () => {
+ test.each([
+ 'user_canceled',
+ 'system_canceled',
+ 'user_fallback',
+ 'authentication_failed',
+ 'biometry_not_available',
+ 'biometry_not_enrolled',
+ 'biometry_lockout',
+ 'passcode_not_set',
+ 'key_not_found',
+ 'key_invalidated',
+ 'key_generation_failed',
+ 'signing_failed',
+ 'storage_failed',
+ 'not_implemented',
+ ])('preserves the native %s code', async code => {
+ native.sign.mockRejectedValueOnce(nativeError(code, 'details'));
+ const biometrics = await load();
+
+ const error = await biometrics.sign('tdlk_1', 'client-data').catch((e: unknown) => e);
+
+ expect(biometrics.isClerkBiometricsError(error)).toBe(true);
+ expect(error).toMatchObject({ code, message: 'details' });
+ expect((error as Error).cause).toBeInstanceOf(Error);
+ });
+
+ test('maps unrecognized native codes to unknown', async () => {
+ native.hasKey.mockRejectedValueOnce(nativeError('ERR_ARGUMENT_CAST'));
+ const biometrics = await load();
+
+ await expect(biometrics.hasKey('tdlk_1')).rejects.toMatchObject({ code: 'unknown' });
+ });
+
+ test('wraps synchronous native errors', async () => {
+ native.getAppIdentifier.mockImplementationOnce(() => {
+ throw nativeError('not_implemented');
+ });
+ const biometrics = await load();
+
+ expect(() => biometrics.getAppIdentifier()).toThrow(expect.objectContaining({ code: 'not_implemented' }));
+ });
+
+ test('isBiometricsErrorCode recognizes known codes', async () => {
+ const { isBiometricsErrorCode } = await load();
+
+ expect(isBiometricsErrorCode('biometry_lockout')).toBe(true);
+ expect(isBiometricsErrorCode('ERR_UNKNOWN')).toBe(false);
+ expect(isBiometricsErrorCode(undefined)).toBe(false);
+ });
+ });
+
+ describe('store', () => {
+ test('listRecords parses native JSON and passes unknown fields through', async () => {
+ native.listRecords.mockResolvedValueOnce(
+ JSON.stringify([{ ...record, futureField: { nested: [1, true, null] } }]),
+ );
+ const biometrics = await load();
+
+ const records = await biometrics.listRecords();
+
+ expect(records).toEqual([{ ...record, futureField: { nested: [1, true, null] } }]);
+ });
+
+ test('listRecords rejects with storage_failed on invalid native output', async () => {
+ native.listRecords.mockResolvedValueOnce('not json');
+ native.listRecords.mockResolvedValueOnce('{}');
+ const biometrics = await load();
+
+ await expect(biometrics.listRecords()).rejects.toMatchObject({ code: 'storage_failed' });
+ await expect(biometrics.listRecords()).rejects.toMatchObject({ code: 'storage_failed' });
+ });
+
+ test('saveRecord sends only contract fields', async () => {
+ const biometrics = await load();
+
+ await biometrics.saveRecord({ ...record, extra: 'dropped' } as BiometricCredentialRecord, {
+ removeOtherRecordsForApp: true,
+ });
+
+ expect(native.saveRecord).toHaveBeenCalledWith(record, { removeOtherRecordsForApp: true });
+ });
+
+ test('saveRecord omits an absent identifier hint', async () => {
+ const biometrics = await load();
+ const { identifierHint: _, ...withoutHint } = record;
+
+ await biometrics.saveRecord(withoutHint, { removeOtherRecordsForApp: false });
+
+ expect(native.saveRecord.mock.calls[0][0]).not.toHaveProperty('identifierHint');
+ });
+
+ test.each<[string, Partial>]>([
+ ['an empty id', { id: '' }],
+ ['a missing localKeyId', { localKeyId: undefined }],
+ ['a non-string userId', { userId: 1 }],
+ ['an empty appIdentifier', { appIdentifier: '' }],
+ ['a non-string identifierHint', { identifierHint: 1 }],
+ ['an unknown policy', { policy: 'face_id' }],
+ ['a negative createdAt', { createdAt: -1 }],
+ ['a non-finite updatedAt', { updatedAt: Number.NaN }],
+ ['a string createdAt', { createdAt: '1714000000500' }],
+ ])('saveRecord rejects %s', async (_, override) => {
+ const biometrics = await load();
+
+ await expect(
+ biometrics.saveRecord({ ...record, ...override } as BiometricCredentialRecord, {
+ removeOtherRecordsForApp: false,
+ }),
+ ).rejects.toMatchObject({ code: 'invalid_argument' });
+ expect(native.saveRecord).not.toHaveBeenCalled();
+ });
+
+ test('saveRecord requires removeOtherRecordsForApp', async () => {
+ const biometrics = await load();
+
+ // @ts-expect-error testing missing options
+ await expect(biometrics.saveRecord(record)).rejects.toMatchObject({ code: 'invalid_argument' });
+ });
+
+ test('deleteRecord and ensureInstallationMarker forward to native', async () => {
+ native.ensureInstallationMarker.mockResolvedValueOnce({ wiped: true });
+ const biometrics = await load();
+
+ await biometrics.deleteRecord('tdlk_1');
+ await expect(biometrics.ensureInstallationMarker()).resolves.toEqual({ wiped: true });
+
+ expect(native.deleteRecord).toHaveBeenCalledWith('tdlk_1');
+ await expect(biometrics.deleteRecord('')).rejects.toMatchObject({ code: 'invalid_argument' });
+ });
+
+ test('store errors keep their native code', async () => {
+ native.saveRecord.mockRejectedValueOnce(nativeError('storage_failed'));
+ const biometrics = await load();
+
+ await expect(biometrics.saveRecord(record, { removeOtherRecordsForApp: false })).rejects.toMatchObject({
+ code: 'storage_failed',
+ });
+ });
+ });
+});
diff --git a/packages/expo-biometrics/src/errors.ts b/packages/expo-biometrics/src/errors.ts
new file mode 100644
index 00000000000..ef47e236418
--- /dev/null
+++ b/packages/expo-biometrics/src/errors.ts
@@ -0,0 +1,48 @@
+import type { BiometricsErrorCode } from './types';
+
+const ERROR_CODES: ReadonlySet = new Set([
+ 'user_canceled',
+ 'system_canceled',
+ 'user_fallback',
+ 'authentication_failed',
+ 'biometry_not_available',
+ 'biometry_not_enrolled',
+ 'biometry_lockout',
+ 'passcode_not_set',
+ 'key_not_found',
+ 'key_invalidated',
+ 'key_generation_failed',
+ 'signing_failed',
+ 'storage_failed',
+ 'invalid_argument',
+ 'not_implemented',
+ 'native_module_unavailable',
+ 'unknown',
+]);
+
+export class ClerkBiometricsError extends Error {
+ readonly code: BiometricsErrorCode;
+
+ constructor(code: BiometricsErrorCode, message: string, options?: { cause?: unknown }) {
+ super(message, options);
+ this.name = 'ClerkBiometricsError';
+ this.code = code;
+ }
+}
+
+export function isClerkBiometricsError(error: unknown): error is ClerkBiometricsError {
+ return error instanceof ClerkBiometricsError;
+}
+
+export function isBiometricsErrorCode(value: unknown): value is BiometricsErrorCode {
+ return typeof value === 'string' && ERROR_CODES.has(value);
+}
+
+export function toClerkBiometricsError(error: unknown): ClerkBiometricsError {
+ if (error instanceof ClerkBiometricsError) {
+ return error;
+ }
+ const code = (error as { code?: unknown } | null)?.code;
+ const message = error instanceof Error ? error.message : String(error);
+ return new ClerkBiometricsError(isBiometricsErrorCode(code) ? code : 'unknown', message, { cause: error });
+}
diff --git a/packages/expo-biometrics/src/index.ts b/packages/expo-biometrics/src/index.ts
new file mode 100644
index 00000000000..94347e54fbb
--- /dev/null
+++ b/packages/expo-biometrics/src/index.ts
@@ -0,0 +1,207 @@
+import type { ClerkExpoBiometricsNativeModule } from './ClerkExpoBiometricsModule';
+import ClerkExpoBiometrics from './ClerkExpoBiometricsModule';
+import { ClerkBiometricsError, toClerkBiometricsError } from './errors';
+import type {
+ BiometricAvailability,
+ BiometricCredentialKey,
+ BiometricCredentialPolicy,
+ BiometricCredentialRecord,
+ InstallationMarkerResult,
+ SaveRecordOptions,
+ StoredBiometricCredentialRecord,
+} from './types';
+
+export { ClerkBiometricsError, isBiometricsErrorCode, isClerkBiometricsError } from './errors';
+export type {
+ BiometricAvailability,
+ BiometricCredentialKey,
+ BiometricCredentialPolicy,
+ BiometricCredentialRecord,
+ BiometricsErrorCode,
+ BiometryType,
+ InstallationMarkerResult,
+ SaveRecordOptions,
+ StoredBiometricCredentialRecord,
+} from './types';
+
+const POLICIES: ReadonlySet = new Set([
+ 'biometry_current_set',
+ 'biometry_any',
+ 'biometry_or_device_passcode',
+]);
+
+function nativeModule(): ClerkExpoBiometricsNativeModule {
+ if (!ClerkExpoBiometrics) {
+ throw new ClerkBiometricsError(
+ 'native_module_unavailable',
+ 'The ClerkExpoBiometrics native module is not available. Rebuild your app with @clerk/expo-biometrics installed; it cannot run in Expo Go or on the web.',
+ );
+ }
+ return ClerkExpoBiometrics;
+}
+
+async function callNative(fn: (module: ClerkExpoBiometricsNativeModule) => Promise): Promise {
+ const module = nativeModule();
+ try {
+ return await fn(module);
+ } catch (error) {
+ throw toClerkBiometricsError(error);
+ }
+}
+
+function invalidArgument(message: string): ClerkBiometricsError {
+ return new ClerkBiometricsError('invalid_argument', message);
+}
+
+function assertNonEmptyString(value: unknown, name: string): asserts value is string {
+ if (typeof value !== 'string' || value.length === 0) {
+ throw invalidArgument(`${name} must be a non-empty string.`);
+ }
+}
+
+function assertPolicy(value: unknown): asserts value is BiometricCredentialPolicy {
+ if (typeof value !== 'string' || !POLICIES.has(value)) {
+ throw invalidArgument(`policy must be one of ${[...POLICIES].join(', ')}.`);
+ }
+}
+
+function assertTimestamp(value: unknown, name: string): asserts value is number {
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
+ throw invalidArgument(`${name} must be a non-negative number of milliseconds since the Unix epoch.`);
+ }
+}
+
+function assertRecord(record: BiometricCredentialRecord): void {
+ if (typeof record !== 'object' || record === null) {
+ throw invalidArgument('record must be an object.');
+ }
+ assertNonEmptyString(record.id, 'record.id');
+ assertNonEmptyString(record.localKeyId, 'record.localKeyId');
+ assertNonEmptyString(record.userId, 'record.userId');
+ assertNonEmptyString(record.appIdentifier, 'record.appIdentifier');
+ if (record.identifierHint !== undefined && typeof record.identifierHint !== 'string') {
+ throw invalidArgument('record.identifierHint must be a string when provided.');
+ }
+ assertPolicy(record.policy);
+ assertTimestamp(record.createdAt, 'record.createdAt');
+ assertTimestamp(record.updatedAt, 'record.updatedAt');
+}
+
+/**
+ * Returns the identifier Clerk uses as the credential's `app_identifier` (the iOS bundle identifier).
+ */
+export function getAppIdentifier(): string {
+ const module = nativeModule();
+ try {
+ return module.getAppIdentifier();
+ } catch (error) {
+ throw toClerkBiometricsError(error);
+ }
+}
+
+/**
+ * Reports which biometry the device supports and whether it can be used right now.
+ */
+export function getAvailability(): Promise {
+ return callNative(module => module.getAvailability());
+}
+
+/**
+ * Creates a new hardware-backed P-256 private key protected by `policy`.
+ * Rejects with `biometry_not_available`, `biometry_not_enrolled`, or `biometry_lockout` when biometrics cannot be used.
+ */
+export async function createKey(policy: BiometricCredentialPolicy): Promise {
+ assertPolicy(policy);
+ return callNative(module => module.createKey(policy));
+}
+
+/**
+ * Prompts for local authentication and signs the UTF-8 bytes of `clientData` with ES256.
+ * Resolves with the raw `r || s` signature, base64url-encoded without padding.
+ *
+ * @param reason - The message shown in the authentication prompt.
+ */
+export async function sign(localKeyId: string, clientData: string, reason: string | null = null): Promise {
+ assertNonEmptyString(localKeyId, 'localKeyId');
+ if (typeof clientData !== 'string') {
+ throw invalidArgument('clientData must be a string.');
+ }
+ return callNative(module => module.sign(localKeyId, clientData, reason));
+}
+
+/**
+ * Resolves whether the private key for `localKeyId` exists. Does not prompt for authentication.
+ */
+export async function hasKey(localKeyId: string): Promise {
+ assertNonEmptyString(localKeyId, 'localKeyId');
+ return callNative(module => module.hasKey(localKeyId));
+}
+
+/**
+ * Deletes the private key for `localKeyId`. Resolves when the key does not exist.
+ */
+export async function deleteKey(localKeyId: string): Promise {
+ assertNonEmptyString(localKeyId, 'localKeyId');
+ return callNative(module => module.deleteKey(localKeyId));
+}
+
+/**
+ * Lists every well-formed credential record on the device, for every app identifier.
+ */
+export async function listRecords(): Promise {
+ const json = await callNative(module => module.listRecords());
+ let records: unknown;
+ try {
+ records = JSON.parse(json);
+ } catch (error) {
+ throw new ClerkBiometricsError('storage_failed', 'The native module returned invalid record data.', {
+ cause: error,
+ });
+ }
+ if (!Array.isArray(records)) {
+ throw new ClerkBiometricsError('storage_failed', 'The native module returned invalid record data.');
+ }
+ return records as StoredBiometricCredentialRecord[];
+}
+
+/**
+ * Saves `record`, replacing any record with the same `id`. When the replaced record used a different key, that key is deleted.
+ */
+export async function saveRecord(record: BiometricCredentialRecord, options: SaveRecordOptions): Promise {
+ assertRecord(record);
+ if (typeof options?.removeOtherRecordsForApp !== 'boolean') {
+ throw invalidArgument('options.removeOtherRecordsForApp must be a boolean.');
+ }
+ const nativeRecord: BiometricCredentialRecord = {
+ id: record.id,
+ localKeyId: record.localKeyId,
+ userId: record.userId,
+ appIdentifier: record.appIdentifier,
+ policy: record.policy,
+ createdAt: record.createdAt,
+ updatedAt: record.updatedAt,
+ };
+ if (record.identifierHint !== undefined) {
+ nativeRecord.identifierHint = record.identifierHint;
+ }
+ return callNative(module =>
+ module.saveRecord(nativeRecord, { removeOtherRecordsForApp: options.removeOtherRecordsForApp }),
+ );
+}
+
+/**
+ * Deletes the private key for `localKeyId`, then every record that references it.
+ * When the key cannot be deleted the records are kept and the promise rejects.
+ */
+export async function deleteRecord(localKeyId: string): Promise {
+ assertNonEmptyString(localKeyId, 'localKeyId');
+ return callNative(module => module.deleteRecord(localKeyId));
+}
+
+/**
+ * Detects a new installation and deletes the records and keys a previous installation of this app left in the Keychain.
+ * Safe to call repeatedly. The store operations above call it before they read or write.
+ */
+export function ensureInstallationMarker(): Promise {
+ return callNative(module => module.ensureInstallationMarker());
+}
diff --git a/packages/expo-biometrics/src/types.ts b/packages/expo-biometrics/src/types.ts
new file mode 100644
index 00000000000..9a5673fb971
--- /dev/null
+++ b/packages/expo-biometrics/src/types.ts
@@ -0,0 +1,86 @@
+/**
+ * The local authentication policy that protects a biometric credential's private key.
+ *
+ * - `biometry_current_set`: requires a biometric from the currently enrolled set. Adding or removing a Face ID / Touch ID enrollment invalidates the key.
+ * - `biometry_any`: requires a biometric, and survives biometric enrollment changes.
+ * - `biometry_or_device_passcode`: requires biometrics to be available at creation, then accepts a biometric or the device passcode.
+ */
+export type BiometricCredentialPolicy = 'biometry_current_set' | 'biometry_any' | 'biometry_or_device_passcode';
+
+export type BiometryType = 'faceID' | 'touchID' | 'opticID' | 'none';
+
+export interface BiometricAvailability {
+ /** The biometry the device supports, or `none`. */
+ biometryType: BiometryType;
+ /** Whether biometric authentication can be evaluated now. Key creation requires this for every policy. */
+ canEvaluateBiometrics: boolean;
+ /** Whether device owner authentication (biometrics or passcode) can be evaluated now. */
+ canEvaluateDeviceOwner: boolean;
+ /** Why biometric authentication cannot be evaluated, or `null` when it can. */
+ errorCode: BiometricsErrorCode | null;
+}
+
+export interface BiometricCredentialKey {
+ /** Opaque identifier of the private key. Stored in the credential record as `localKeyId`. */
+ localKeyId: string;
+ /** P-256 public key as a compact JWK string: `{"kty":"EC","crv":"P-256","x":"…","y":"…","alg":"ES256"}`. */
+ publicKeyJwk: string;
+}
+
+/**
+ * On-device metadata linking a Clerk biometric credential to its private key.
+ */
+export interface BiometricCredentialRecord {
+ /** Server credential ID. */
+ id: string;
+ /** Identifier of the private key returned by `createKey()`. */
+ localKeyId: string;
+ /** Clerk user ID that enrolled the credential. */
+ userId: string;
+ /** App identifier the credential was enrolled for (see `getAppIdentifier()`). */
+ appIdentifier: string;
+ /** Local-only identifier hint. Normalized (trimmed, lowercased) when stored; empty values are omitted. */
+ identifierHint?: string;
+ policy: BiometricCredentialPolicy;
+ /** Server credential creation time, in milliseconds since the Unix epoch. */
+ createdAt: number;
+ /** Server credential update time, in milliseconds since the Unix epoch. */
+ updatedAt: number;
+}
+
+/**
+ * A record read from the store. Fields written by other SDK versions are passed through unchanged.
+ */
+export type StoredBiometricCredentialRecord = BiometricCredentialRecord & { readonly [field: string]: unknown };
+
+export interface SaveRecordOptions {
+ /**
+ * Delete every other record for the same `appIdentifier`, along with its private key, after the record is saved.
+ * Set this after a successful enrollment, since the server has already replaced those credentials.
+ */
+ removeOtherRecordsForApp: boolean;
+}
+
+export interface InstallationMarkerResult {
+ /** `true` when this is a new installation and the records left behind by a previous one were deleted. */
+ wiped: boolean;
+}
+
+export type BiometricsErrorCode =
+ | 'user_canceled'
+ | 'system_canceled'
+ | 'user_fallback'
+ | 'authentication_failed'
+ | 'biometry_not_available'
+ | 'biometry_not_enrolled'
+ | 'biometry_lockout'
+ | 'passcode_not_set'
+ | 'key_not_found'
+ | 'key_invalidated'
+ | 'key_generation_failed'
+ | 'signing_failed'
+ | 'storage_failed'
+ | 'invalid_argument'
+ | 'not_implemented'
+ | 'native_module_unavailable'
+ | 'unknown';
diff --git a/packages/expo-biometrics/tsconfig.declarations.json b/packages/expo-biometrics/tsconfig.declarations.json
new file mode 100644
index 00000000000..30037049bb1
--- /dev/null
+++ b/packages/expo-biometrics/tsconfig.declarations.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "./src",
+ "incremental": false,
+ "skipLibCheck": true,
+ "noEmit": false,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "declarationMap": true,
+ "sourceMap": false,
+ "declarationDir": "./dist"
+ },
+ "exclude": ["**/__tests__/**/*"]
+}
diff --git a/packages/expo-biometrics/tsconfig.json b/packages/expo-biometrics/tsconfig.json
new file mode 100644
index 00000000000..ba72a15ff1d
--- /dev/null
+++ b/packages/expo-biometrics/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "declaration": true,
+ "declarationMap": false,
+ "esModuleInterop": true,
+ "importHelpers": true,
+ "incremental": true,
+ "jsx": "react-jsx",
+ "lib": ["ESNext", "dom"],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "noEmitOnError": false,
+ "noImplicitReturns": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "outDir": "dist",
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": false,
+ "strict": true,
+ "target": "ES2019",
+ "types": ["node"],
+ "rootDir": "./src"
+ },
+ "include": ["src"]
+}
diff --git a/packages/expo-biometrics/tsup.config.ts b/packages/expo-biometrics/tsup.config.ts
new file mode 100644
index 00000000000..f705ef7fde7
--- /dev/null
+++ b/packages/expo-biometrics/tsup.config.ts
@@ -0,0 +1,19 @@
+import type { Options } from 'tsup';
+import { defineConfig } from 'tsup';
+
+import { runAfterLast } from '../../scripts/utils';
+
+export default defineConfig(() => {
+ const options: Options = {
+ format: 'cjs',
+ outDir: './dist',
+ entry: ['./src/**/*.{ts,tsx,js,jsx}', '!./src/**/__tests__/**'],
+ bundle: false,
+ clean: true,
+ minify: false,
+ sourcemap: true,
+ legacyOutput: true,
+ };
+
+ return runAfterLast(['pnpm build:declarations'])(options);
+});
diff --git a/packages/expo-biometrics/vitest.config.mts b/packages/expo-biometrics/vitest.config.mts
new file mode 100644
index 00000000000..4ac6027d578
--- /dev/null
+++ b/packages/expo-biometrics/vitest.config.mts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d37b3a454fa..a3084ae1dec 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -702,6 +702,12 @@ importers:
specifier: ^0.86.0
version: 0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10)
+ packages/expo-biometrics:
+ devDependencies:
+ expo:
+ specifier: ~54.0.36
+ version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)
+
packages/expo-google-signin:
devDependencies:
'@expo/config-plugins':
From 15c36e348b580c182fa1a01143280322d1b92774 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Mon, 28 Sep 2026 11:03:06 -0400
Subject: [PATCH 2/3] chore(expo-biometrics): point iOS storage comment at the
clerk-ios contract tests
Co-Authored-By: Claude Opus 5.5 (1M context)
---
packages/expo-biometrics/ios/BiometricCredentialCoding.swift | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/expo-biometrics/ios/BiometricCredentialCoding.swift b/packages/expo-biometrics/ios/BiometricCredentialCoding.swift
index 441245f9ec0..d741022f3c3 100644
--- a/packages/expo-biometrics/ios/BiometricCredentialCoding.swift
+++ b/packages/expo-biometrics/ios/BiometricCredentialCoding.swift
@@ -1,6 +1,6 @@
import Foundation
-// Implements clerk-ios Documentation/BiometricCredentialStorageContract.md (version 1). Any change here must stay
+// Mirrors the format pinned by clerk-ios BiometricCredentialStorageContractTests. Any change here must stay
// byte-compatible with ClerkKit, which reads and writes the same keys, Keychain item, and UserDefaults marker.
enum BiometricCredentialPolicy: String, CaseIterable {
From ca1e44bbe2f83c6d5f831e93f91e4df95cbed36d Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:00:43 -0400
Subject: [PATCH 3/3] fix(expo-biometrics): report missing secure key storage
Add secureKeyStorageAvailable to getAvailability() and reject createKey()
with secure_key_storage_unavailable when the device has no Secure Enclave,
such as the iOS Simulator, instead of failing inside SecKeyCreateRandomKey.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
packages/expo-biometrics/README.md | 3 +-
.../ios/BiometricKeyManager.swift | 24 +++++++++++++++-
.../expo-biometrics/ios/BiometricsError.swift | 1 +
.../ios/ClerkExpoBiometrics.podspec | 2 +-
.../ios/ClerkExpoBiometricsModule.swift | 2 ++
.../BiometricSystemIntegrationTests.swift | 23 +++++++++++++++
.../src/__tests__/index.test.ts | 28 +++++++++++++++++++
packages/expo-biometrics/src/errors.ts | 1 +
packages/expo-biometrics/src/index.ts | 3 +-
packages/expo-biometrics/src/types.ts | 6 ++++
10 files changed, 89 insertions(+), 4 deletions(-)
diff --git a/packages/expo-biometrics/README.md b/packages/expo-biometrics/README.md
index dcac2a997f6..5c39cf5754b 100644
--- a/packages/expo-biometrics/README.md
+++ b/packages/expo-biometrics/README.md
@@ -37,6 +37,7 @@ The key and record layout is shared with the Clerk iOS SDK, so credentials enrol
- Expo SDK 54 or later, in a development build (the module is not available in Expo Go or on the web)
- iOS. Android support is not implemented yet: every call rejects with `not_implemented`.
- `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option.
+- A device with a Secure Enclave. The iOS Simulator has none, so `createKey()` rejects there with `secure_key_storage_unavailable`.
## Installation
@@ -66,7 +67,7 @@ import {
| Function | Description |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier). |
-| `getAvailability()` | The device's biometry type and whether biometrics or device owner authentication can be evaluated. |
+| `getAvailability()` | The device's biometry type, whether biometrics or device owner authentication can be evaluated, and secure key storage. |
| `createKey(policy)` | Creates a Secure Enclave P-256 key and returns its `localKeyId` and public key JWK. |
| `sign(localKeyId, clientData, reason?)` | Prompts for authentication and returns an ES256 signature over `clientData` (raw `r \|\| s`, base64url without padding). |
| `hasKey(localKeyId)` / `deleteKey(localKeyId)` | Checks for or deletes a key. |
diff --git a/packages/expo-biometrics/ios/BiometricKeyManager.swift b/packages/expo-biometrics/ios/BiometricKeyManager.swift
index 53e306a8b2a..ff4d34bf893 100644
--- a/packages/expo-biometrics/ios/BiometricKeyManager.swift
+++ b/packages/expo-biometrics/ios/BiometricKeyManager.swift
@@ -1,3 +1,4 @@
+import CryptoKit
import Foundation
import LocalAuthentication
import Security
@@ -7,6 +8,7 @@ struct BiometricAvailability: Equatable {
let canEvaluateBiometrics: Bool
let canEvaluateDeviceOwner: Bool
let errorCode: BiometricsError.Code?
+ let secureKeyStorageAvailable: Bool
}
struct BiometricCredentialKey: Equatable {
@@ -16,6 +18,21 @@ struct BiometricCredentialKey: Equatable {
/// Secure Enclave keys laid out as ClerkKit's `BiometricCredentialKeyManager` creates them.
final class BiometricKeyManager {
+ private let isSecureEnclaveAvailable: () -> Bool
+
+ init(isSecureEnclaveAvailable: @escaping () -> Bool = BiometricKeyManager.deviceHasSecureEnclave) {
+ self.isSecureEnclaveAvailable = isSecureEnclaveAvailable
+ }
+
+ static func deviceHasSecureEnclave() -> Bool {
+ // SecureEnclave.isAvailable is true on the Simulator, where Secure Enclave keys still fail with errSecAuthFailed.
+ #if targetEnvironment(simulator)
+ return false
+ #else
+ return SecureEnclave.isAvailable
+ #endif
+ }
+
func availability() -> BiometricAvailability {
let context = LAContext()
var biometricsError: NSError?
@@ -31,11 +48,16 @@ final class BiometricKeyManager {
canEvaluateDeviceOwner: canEvaluateDeviceOwner,
errorCode: canEvaluateBiometrics
? nil
- : BiometricsError.localAuthentication(biometricsError, fallback: .biometryNotAvailable).code
+ : BiometricsError.localAuthentication(biometricsError, fallback: .biometryNotAvailable).code,
+ secureKeyStorageAvailable: isSecureEnclaveAvailable()
)
}
func createKey(policy: BiometricCredentialPolicy) throws -> BiometricCredentialKey {
+ guard isSecureEnclaveAvailable() else {
+ throw BiometricsError(.secureKeyStorageUnavailable, "This device has no Secure Enclave to hold the biometric credential key.")
+ }
+
let context = LAContext()
var laError: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &laError) else {
diff --git a/packages/expo-biometrics/ios/BiometricsError.swift b/packages/expo-biometrics/ios/BiometricsError.swift
index f8e5bb82375..c33ddf13561 100644
--- a/packages/expo-biometrics/ios/BiometricsError.swift
+++ b/packages/expo-biometrics/ios/BiometricsError.swift
@@ -12,6 +12,7 @@ struct BiometricsError: Error, Equatable {
case biometryNotEnrolled = "biometry_not_enrolled"
case biometryLockout = "biometry_lockout"
case passcodeNotSet = "passcode_not_set"
+ case secureKeyStorageUnavailable = "secure_key_storage_unavailable"
case keyNotFound = "key_not_found"
case keyInvalidated = "key_invalidated"
case keyGenerationFailed = "key_generation_failed"
diff --git a/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec b/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec
index bce85d59e29..05c8cecf532 100644
--- a/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec
+++ b/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec
@@ -16,7 +16,7 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
- s.frameworks = 'LocalAuthentication', 'Security'
+ s.frameworks = 'CryptoKit', 'LocalAuthentication', 'Security'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
diff --git a/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift b/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift
index ce017190500..aefc24929b2 100644
--- a/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift
+++ b/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift
@@ -20,6 +20,7 @@ struct BiometricAvailabilityResult: Record {
@Field var canEvaluateBiometrics: Bool = false
@Field var canEvaluateDeviceOwner: Bool = false
@Field var errorCode: String?
+ @Field var secureKeyStorageAvailable: Bool = false
}
struct BiometricCredentialKeyResult: Record {
@@ -64,6 +65,7 @@ public final class ClerkExpoBiometricsModule: Module {
result.canEvaluateBiometrics = availability.canEvaluateBiometrics
result.canEvaluateDeviceOwner = availability.canEvaluateDeviceOwner
result.errorCode = availability.errorCode?.rawValue
+ result.secureKeyStorageAvailable = availability.secureKeyStorageAvailable
return result
}
diff --git a/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift b/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift
index 06d43ec04bb..0dd3bfcb402 100644
--- a/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift
+++ b/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift
@@ -76,6 +76,29 @@ final class BiometricKeyManagerTests: XCTestCase {
XCTAssertEqual(availability.errorCode == nil, availability.canEvaluateBiometrics)
}
+ func testSimulatorReportsNoSecureKeyStorage() throws {
+ #if targetEnvironment(simulator)
+ XCTAssertFalse(BiometricKeyManager().availability().secureKeyStorageAvailable)
+ #else
+ throw XCTSkip("Only the Simulator is known to lack a Secure Enclave.")
+ #endif
+ }
+
+ func testAvailabilityReportsSecureKeyStorage() {
+ XCTAssertTrue(BiometricKeyManager(isSecureEnclaveAvailable: { true }).availability().secureKeyStorageAvailable)
+ XCTAssertFalse(BiometricKeyManager(isSecureEnclaveAvailable: { false }).availability().secureKeyStorageAvailable)
+ }
+
+ func testCreateKeyRejectsWithoutSecureKeyStorage() {
+ let keyManager = BiometricKeyManager(isSecureEnclaveAvailable: { false })
+
+ for policy in BiometricCredentialPolicy.allCases {
+ XCTAssertThrowsError(try keyManager.createKey(policy: policy)) { error in
+ XCTAssertEqual((error as? BiometricsError)?.code, .secureKeyStorageUnavailable)
+ }
+ }
+ }
+
func testErrorMapping() {
XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.userCancel.rawValue), .userCanceled)
XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.systemCancel.rawValue), .systemCanceled)
diff --git a/packages/expo-biometrics/src/__tests__/index.test.ts b/packages/expo-biometrics/src/__tests__/index.test.ts
index fb78fe7178b..d214347149b 100644
--- a/packages/expo-biometrics/src/__tests__/index.test.ts
+++ b/packages/expo-biometrics/src/__tests__/index.test.ts
@@ -21,6 +21,7 @@ const createNativeModule = () => ({
canEvaluateBiometrics: true,
canEvaluateDeviceOwner: true,
errorCode: null,
+ secureKeyStorageAvailable: true,
}),
createKey: vi.fn().mockResolvedValue({ localKeyId: 'tdlk_1', publicKeyJwk: '{"kty":"EC"}' }),
sign: vi.fn().mockResolvedValue('c2lnbmF0dXJl'),
@@ -67,9 +68,23 @@ describe('@clerk/expo-biometrics', () => {
canEvaluateBiometrics: true,
canEvaluateDeviceOwner: true,
errorCode: null,
+ secureKeyStorageAvailable: true,
});
});
+ test('getAvailability reports missing secure key storage', async () => {
+ native.getAvailability.mockResolvedValueOnce({
+ biometryType: 'faceID',
+ canEvaluateBiometrics: true,
+ canEvaluateDeviceOwner: true,
+ errorCode: null,
+ secureKeyStorageAvailable: false,
+ });
+ const biometrics = await load();
+
+ await expect(biometrics.getAvailability()).resolves.toMatchObject({ secureKeyStorageAvailable: false });
+ });
+
test('rejects with native_module_unavailable when the native module is missing', async () => {
mocks.nativeModule = null;
const biometrics = await load();
@@ -92,6 +107,17 @@ describe('@clerk/expo-biometrics', () => {
expect(native.createKey).toHaveBeenCalledWith('biometry_or_device_passcode');
});
+ test('createKey preserves secure_key_storage_unavailable', async () => {
+ native.createKey.mockRejectedValueOnce(nativeError('secure_key_storage_unavailable', 'no Secure Enclave'));
+ const biometrics = await load();
+
+ await expect(biometrics.createKey('biometry_current_set')).rejects.toMatchObject({
+ name: 'ClerkBiometricsError',
+ code: 'secure_key_storage_unavailable',
+ message: 'no Secure Enclave',
+ });
+ });
+
test('createKey rejects unknown policies without calling native', async () => {
const biometrics = await load();
@@ -139,6 +165,7 @@ describe('@clerk/expo-biometrics', () => {
'biometry_not_enrolled',
'biometry_lockout',
'passcode_not_set',
+ 'secure_key_storage_unavailable',
'key_not_found',
'key_invalidated',
'key_generation_failed',
@@ -176,6 +203,7 @@ describe('@clerk/expo-biometrics', () => {
const { isBiometricsErrorCode } = await load();
expect(isBiometricsErrorCode('biometry_lockout')).toBe(true);
+ expect(isBiometricsErrorCode('secure_key_storage_unavailable')).toBe(true);
expect(isBiometricsErrorCode('ERR_UNKNOWN')).toBe(false);
expect(isBiometricsErrorCode(undefined)).toBe(false);
});
diff --git a/packages/expo-biometrics/src/errors.ts b/packages/expo-biometrics/src/errors.ts
index ef47e236418..3b7552aac9b 100644
--- a/packages/expo-biometrics/src/errors.ts
+++ b/packages/expo-biometrics/src/errors.ts
@@ -9,6 +9,7 @@ const ERROR_CODES: ReadonlySet = new Set([
'biometry_not_enrolled',
'biometry_lockout',
'passcode_not_set',
+ 'secure_key_storage_unavailable',
'key_not_found',
'key_invalidated',
'key_generation_failed',
diff --git a/packages/expo-biometrics/src/index.ts b/packages/expo-biometrics/src/index.ts
index 94347e54fbb..7000dfdbb30 100644
--- a/packages/expo-biometrics/src/index.ts
+++ b/packages/expo-biometrics/src/index.ts
@@ -108,7 +108,8 @@ export function getAvailability(): Promise {
/**
* Creates a new hardware-backed P-256 private key protected by `policy`.
- * Rejects with `biometry_not_available`, `biometry_not_enrolled`, or `biometry_lockout` when biometrics cannot be used.
+ * Rejects with `secure_key_storage_unavailable` when the device has no hardware-backed key storage (see `getAvailability()`),
+ * and with `biometry_not_available`, `biometry_not_enrolled`, or `biometry_lockout` when biometrics cannot be used.
*/
export async function createKey(policy: BiometricCredentialPolicy): Promise {
assertPolicy(policy);
diff --git a/packages/expo-biometrics/src/types.ts b/packages/expo-biometrics/src/types.ts
index 9a5673fb971..6c537067e71 100644
--- a/packages/expo-biometrics/src/types.ts
+++ b/packages/expo-biometrics/src/types.ts
@@ -18,6 +18,11 @@ export interface BiometricAvailability {
canEvaluateDeviceOwner: boolean;
/** Why biometric authentication cannot be evaluated, or `null` when it can. */
errorCode: BiometricsErrorCode | null;
+ /**
+ * Whether the device has hardware-backed key storage (the Secure Enclave on iOS). `false` on the iOS Simulator.
+ * `createKey()` rejects with `secure_key_storage_unavailable` when this is `false`.
+ */
+ secureKeyStorageAvailable: boolean;
}
export interface BiometricCredentialKey {
@@ -75,6 +80,7 @@ export type BiometricsErrorCode =
| 'biometry_not_enrolled'
| 'biometry_lockout'
| 'passcode_not_set'
+ | 'secure_key_storage_unavailable'
| 'key_not_found'
| 'key_invalidated'
| 'key_generation_failed'