@clerk/expo-biometrics 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/android/build.gradle +49 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialCoding.kt +147 -0
  6. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialFileStore.kt +304 -0
  7. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialStore.kt +96 -0
  8. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricKeyManager.kt +216 -0
  9. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricsError.kt +58 -0
  10. package/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt +139 -0
  11. package/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStorageContractTest.kt +450 -0
  12. package/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStoreTest.kt +179 -0
  13. package/android/src/test/java/expo/modules/clerk/biometrics/BiometricKeyManagerTest.kt +49 -0
  14. package/android/src/test/resources/biometric-credential-storage/v2/biometric_credentials.v2.json +50 -0
  15. package/android/src/test/resources/robolectric.properties +1 -0
  16. package/dist/ClerkExpoBiometricsModule.d.ts +18 -0
  17. package/dist/ClerkExpoBiometricsModule.d.ts.map +1 -0
  18. package/dist/ClerkExpoBiometricsModule.js +26 -0
  19. package/dist/ClerkExpoBiometricsModule.js.map +1 -0
  20. package/dist/errors.d.ts +11 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/errors.js +75 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/index.d.ts +57 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +204 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/types.d.ts +85 -0
  29. package/dist/types.d.ts.map +1 -0
  30. package/dist/types.js +17 -0
  31. package/dist/types.js.map +1 -0
  32. package/expo-module.config.json +9 -0
  33. package/ios/BiometricCredentialCoding.swift +335 -0
  34. package/ios/BiometricCredentialStore.swift +230 -0
  35. package/ios/BiometricKeyManager.swift +228 -0
  36. package/ios/BiometricsError.swift +117 -0
  37. package/ios/ClerkExpoBiometrics.podspec +32 -0
  38. package/ios/ClerkExpoBiometricsModule.swift +164 -0
  39. package/ios/Tests/BiometricCredentialContractTests.swift +286 -0
  40. package/ios/Tests/BiometricCredentialStoreTests.swift +323 -0
  41. package/ios/Tests/BiometricSystemIntegrationTests.swift +169 -0
  42. package/ios/Tests/Resources/BiometricCredentialStorageContractV1.json +31 -0
  43. package/package.json +53 -0
@@ -0,0 +1,169 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+ import Security
4
+ import XCTest
5
+ @testable import ClerkExpoBiometrics
6
+
7
+ /// Skips tests that need the real Keychain when the test bundle runs without a host app.
8
+ func skipUnlessKeychainIsAvailable() throws {
9
+ let query: [String: Any] = [
10
+ kSecClass as String: kSecClassGenericPassword,
11
+ kSecAttrService as String: "ClerkExpoBiometricsTests.probe",
12
+ ]
13
+ let status = SecItemCopyMatching(query as CFDictionary, nil)
14
+ try XCTSkipIf(status == errSecMissingEntitlement, "The Keychain is only available to tests running in a host app.")
15
+ }
16
+
17
+ final class KeychainMetadataStorageTests: XCTestCase {
18
+ private var storage: KeychainMetadataStorage!
19
+
20
+ override func setUpWithError() throws {
21
+ try super.setUpWithError()
22
+ try skipUnlessKeychainIsAvailable()
23
+ storage = KeychainMetadataStorage(service: "ClerkExpoBiometricsTests.\(UUID().uuidString)")
24
+ }
25
+
26
+ override func tearDownWithError() throws {
27
+ try storage?.delete()
28
+ try super.tearDownWithError()
29
+ }
30
+
31
+ func testWriteAddsThenUpdatesTheGenericPasswordItem() throws {
32
+ XCTAssertNil(try storage.read())
33
+
34
+ try storage.write(Data("[1]".utf8))
35
+ try storage.write(Data("[2]".utf8))
36
+
37
+ XCTAssertEqual(try storage.read(), Data("[2]".utf8))
38
+
39
+ var query = storage.baseQuery()
40
+ query[kSecReturnAttributes as String] = true
41
+ query[kSecMatchLimit as String] = kSecMatchLimitAll
42
+ var result: CFTypeRef?
43
+ XCTAssertEqual(SecItemCopyMatching(query as CFDictionary, &result), errSecSuccess)
44
+ let items = try XCTUnwrap(result as? [[String: Any]])
45
+ XCTAssertEqual(items.count, 1)
46
+ XCTAssertEqual(items[0][kSecAttrAccessible as String] as? String, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
47
+ XCTAssertEqual(items[0][kSecAttrAccount as String] as? String, "trustedDeviceCredentials")
48
+ }
49
+
50
+ func testDeleteIsIdempotent() throws {
51
+ try storage.write(Data("[]".utf8))
52
+ try storage.delete()
53
+ try storage.delete()
54
+
55
+ XCTAssertNil(try storage.read())
56
+ }
57
+ }
58
+
59
+ final class BiometricKeyManagerTests: XCTestCase {
60
+ func testMissingKeyIsReportedWithoutPrompting() throws {
61
+ try skipUnlessKeychainIsAvailable()
62
+ let keyManager = BiometricKeyManager()
63
+ let localKeyId = BiometricCredentialCoding.makeLocalKeyId()
64
+
65
+ XCTAssertFalse(try keyManager.hasKey(localKeyId: localKeyId))
66
+ XCTAssertNoThrow(try keyManager.deleteKey(localKeyId: localKeyId))
67
+ XCTAssertThrowsError(try keyManager.sign(localKeyId: localKeyId, clientData: "data", reason: "Sign in")) { error in
68
+ XCTAssertEqual((error as? BiometricsError)?.code, .keyNotFound)
69
+ }
70
+ }
71
+
72
+ func testAvailabilityReportsAKnownBiometryType() {
73
+ let availability = BiometricKeyManager().availability()
74
+
75
+ XCTAssertTrue(["faceID", "touchID", "opticID", "none"].contains(availability.biometryType))
76
+ XCTAssertEqual(availability.errorCode == nil, availability.canEvaluateBiometrics)
77
+ }
78
+
79
+ func testSimulatorReportsNoSecureKeyStorage() throws {
80
+ #if targetEnvironment(simulator)
81
+ XCTAssertFalse(BiometricKeyManager().availability().secureKeyStorageAvailable)
82
+ #else
83
+ throw XCTSkip("Only the Simulator is known to lack a Secure Enclave.")
84
+ #endif
85
+ }
86
+
87
+ func testAvailabilityReportsSecureKeyStorage() {
88
+ XCTAssertTrue(BiometricKeyManager(isSecureEnclaveAvailable: { true }).availability().secureKeyStorageAvailable)
89
+ XCTAssertFalse(BiometricKeyManager(isSecureEnclaveAvailable: { false }).availability().secureKeyStorageAvailable)
90
+ }
91
+
92
+ func testCreateKeyRejectsWithoutSecureKeyStorage() {
93
+ let keyManager = BiometricKeyManager(isSecureEnclaveAvailable: { false })
94
+
95
+ for policy in BiometricCredentialPolicy.allCases {
96
+ XCTAssertThrowsError(try keyManager.createKey(policy: policy)) { error in
97
+ XCTAssertEqual((error as? BiometricsError)?.code, .secureKeyStorageUnavailable)
98
+ }
99
+ }
100
+ }
101
+
102
+ func testErrorMapping() {
103
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.userCancel.rawValue), .userCanceled)
104
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.systemCancel.rawValue), .systemCanceled)
105
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.appCancel.rawValue), .systemCanceled)
106
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.userFallback.rawValue), .userFallback)
107
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.authenticationFailed.rawValue), .authenticationFailed)
108
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.biometryNotAvailable.rawValue), .biometryNotAvailable)
109
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.biometryNotEnrolled.rawValue), .biometryNotEnrolled)
110
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.biometryLockout.rawValue), .biometryLockout)
111
+ XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.passcodeNotSet.rawValue), .passcodeNotSet)
112
+
113
+ XCTAssertEqual(BiometricsError.code(forStatus: errSecUserCanceled), .userCanceled)
114
+ XCTAssertEqual(BiometricsError.code(forStatus: errSecAuthFailed), .authenticationFailed)
115
+ XCTAssertEqual(BiometricsError.code(forStatus: errSecInteractionNotAllowed), .biometryNotAvailable)
116
+ XCTAssertEqual(BiometricsError.code(forStatus: errSecItemNotFound), .keyNotFound)
117
+ XCTAssertNil(BiometricsError.code(forStatus: errSecParam))
118
+
119
+ let laError = NSError(domain: LAErrorDomain, code: LAError.Code.biometryLockout.rawValue)
120
+ XCTAssertEqual(BiometricsError.security(laError, fallback: .signingFailed).code, .biometryLockout)
121
+ XCTAssertEqual(BiometricsError.localAuthentication(laError, fallback: .biometryNotAvailable).code, .biometryLockout)
122
+ XCTAssertEqual(BiometricsError.localAuthentication(nil, fallback: .biometryNotAvailable).code, .biometryNotAvailable)
123
+ let statusError = NSError(domain: NSOSStatusErrorDomain, code: Int(errSecUserCanceled))
124
+ XCTAssertEqual(BiometricsError.security(statusError, fallback: .signingFailed).code, .userCanceled)
125
+ let tokenError = NSError(domain: "CryptoTokenKit", code: -6)
126
+ XCTAssertEqual(BiometricsError.security(tokenError, fallback: .signingFailed).code, .keyInvalidated)
127
+ let unknownError = NSError(domain: "Other", code: 1)
128
+ XCTAssertEqual(BiometricsError.security(unknownError, fallback: .signingFailed).code, .signingFailed)
129
+ }
130
+
131
+ // Record fields are reference-backed, so each case builds a fresh input.
132
+ private func makeInput(_ configure: (BiometricCredentialRecordInput) -> Void = { _ in }) -> BiometricCredentialRecordInput {
133
+ let input = BiometricCredentialRecordInput()
134
+ input.id = "tdc_1"
135
+ input.localKeyId = "tdlk_1"
136
+ input.userId = "user_1"
137
+ input.appIdentifier = "com.clerk.example"
138
+ input.identifierHint = " Hint "
139
+ input.policy = "biometry_any"
140
+ input.createdAt = 1_714_000_000_000.4
141
+ input.updatedAt = 1_714_000_000_001
142
+ configure(input)
143
+ return input
144
+ }
145
+
146
+ func testRecordInputValidation() throws {
147
+ let record = try ClerkExpoBiometricsModule.record(from: makeInput())
148
+ XCTAssertEqual(record.identifierHint, "hint")
149
+ XCTAssertEqual(record.policy, .biometryAny)
150
+ XCTAssertEqual(record.jsonObject["createdAt"] as? Int64, 1_714_000_000_000)
151
+ XCTAssertEqual(record.jsonObject["updatedAt"] as? Int64, 1_714_000_000_001)
152
+
153
+ let invalidInputs: [(String, (BiometricCredentialRecordInput) -> Void)] = [
154
+ ("policy", { $0.policy = "face_id" }),
155
+ ("id", { $0.id = "" }),
156
+ ("localKeyId", { $0.localKeyId = "" }),
157
+ ("userId", { $0.userId = "" }),
158
+ ("appIdentifier", { $0.appIdentifier = "" }),
159
+ ("nan createdAt", { $0.createdAt = .nan }),
160
+ ("negative createdAt", { $0.createdAt = -1 }),
161
+ ("infinite updatedAt", { $0.updatedAt = .infinity }),
162
+ ]
163
+ for (name, configure) in invalidInputs {
164
+ XCTAssertThrowsError(try ClerkExpoBiometricsModule.record(from: makeInput(configure)), name) { error in
165
+ XCTAssertEqual((error as? BiometricsError)?.code, .invalidArgument, name)
166
+ }
167
+ }
168
+ }
169
+ }
@@ -0,0 +1,31 @@
1
+ [
2
+ {
3
+ "id": "tdc_contract_current_set",
4
+ "localKeyId": "tdlk_0123456789abcdef0123456789abcdef",
5
+ "userId": "user_contract_1",
6
+ "appIdentifier": "com.clerk.example",
7
+ "identifierHint": "user@example.com",
8
+ "policy": "biometry_current_set",
9
+ "createdAt": 1714000000500,
10
+ "updatedAt": 1714000001500
11
+ },
12
+ {
13
+ "id": "tdc_contract_any",
14
+ "localKeyId": "tdlk_fedcba9876543210fedcba9876543210",
15
+ "userId": "user_contract_2",
16
+ "appIdentifier": "com.clerk.example",
17
+ "policy": "biometry_any",
18
+ "createdAt": 1714000002000,
19
+ "updatedAt": 1714000003000
20
+ },
21
+ {
22
+ "id": "tdc_contract_passcode",
23
+ "localKeyId": "tdlk_00000000000000000000000000000000",
24
+ "userId": "user_contract_1",
25
+ "appIdentifier": "com.clerk.other",
26
+ "identifierHint": "+15555550100",
27
+ "policy": "biometry_or_device_passcode",
28
+ "createdAt": 1714000004000,
29
+ "updatedAt": 1714000004000
30
+ }
31
+ ]
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@clerk/expo-biometrics",
3
+ "version": "0.0.1",
4
+ "description": "Native biometric credential module to be used with Clerk for Expo",
5
+ "keywords": [
6
+ "react-native",
7
+ "expo",
8
+ "biometrics",
9
+ "face-id",
10
+ "touch-id",
11
+ "ClerkExpoBiometrics",
12
+ "clerk"
13
+ ],
14
+ "homepage": "https://clerk.com/",
15
+ "bugs": {
16
+ "url": "https://github.com/clerk/javascript/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/clerk/javascript.git",
21
+ "directory": "packages/expo-biometrics"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Clerk",
25
+ "main": "dist/index.js",
26
+ "types": "dist/index.d.ts",
27
+ "files": [
28
+ "dist",
29
+ "android",
30
+ "ios",
31
+ "expo-module.config.json"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsup",
35
+ "build:declarations": "tsc -p tsconfig.declarations.json",
36
+ "clean": "rimraf ./dist",
37
+ "dev": "tsup --watch",
38
+ "format": "node ../../scripts/format-package.mjs",
39
+ "format:check": "node ../../scripts/format-package.mjs --check",
40
+ "lint": "eslint src",
41
+ "test": "vitest run",
42
+ "test:watch": "vitest watch"
43
+ },
44
+ "devDependencies": {
45
+ "expo": "~54.0.36"
46
+ },
47
+ "peerDependencies": {
48
+ "expo": "catalog:peer-expo"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }