@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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/android/build.gradle +49 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialCoding.kt +147 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialFileStore.kt +304 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialStore.kt +96 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/BiometricKeyManager.kt +216 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/BiometricsError.kt +58 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt +139 -0
- package/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStorageContractTest.kt +450 -0
- package/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStoreTest.kt +179 -0
- package/android/src/test/java/expo/modules/clerk/biometrics/BiometricKeyManagerTest.kt +49 -0
- package/android/src/test/resources/biometric-credential-storage/v2/biometric_credentials.v2.json +50 -0
- package/android/src/test/resources/robolectric.properties +1 -0
- package/dist/ClerkExpoBiometricsModule.d.ts +18 -0
- package/dist/ClerkExpoBiometricsModule.d.ts.map +1 -0
- package/dist/ClerkExpoBiometricsModule.js +26 -0
- package/dist/ClerkExpoBiometricsModule.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +75 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +204 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/BiometricCredentialCoding.swift +335 -0
- package/ios/BiometricCredentialStore.swift +230 -0
- package/ios/BiometricKeyManager.swift +228 -0
- package/ios/BiometricsError.swift +117 -0
- package/ios/ClerkExpoBiometrics.podspec +32 -0
- package/ios/ClerkExpoBiometricsModule.swift +164 -0
- package/ios/Tests/BiometricCredentialContractTests.swift +286 -0
- package/ios/Tests/BiometricCredentialStoreTests.swift +323 -0
- package/ios/Tests/BiometricSystemIntegrationTests.swift +169 -0
- package/ios/Tests/Resources/BiometricCredentialStorageContractV1.json +31 -0
- 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
|
+
}
|