@idealyst/biometrics 1.2.108
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/package.json +80 -0
- package/src/biometrics.native.ts +173 -0
- package/src/biometrics.web.ts +138 -0
- package/src/index.native.ts +13 -0
- package/src/index.ts +15 -0
- package/src/index.web.ts +13 -0
- package/src/passkeys.native.ts +146 -0
- package/src/passkeys.web.ts +203 -0
- package/src/types.ts +160 -0
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@idealyst/biometrics",
|
|
3
|
+
"version": "1.2.108",
|
|
4
|
+
"description": "Cross-platform biometric authentication and passkeys for React and React Native",
|
|
5
|
+
"documentation": "https://github.com/IdealystIO/idealyst-framework/tree/main/packages/biometrics#readme",
|
|
6
|
+
"readme": "README.md",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"module": "src/index.ts",
|
|
9
|
+
"types": "src/index.ts",
|
|
10
|
+
"react-native": "src/index.native.ts",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/IdealystIO/idealyst-framework.git",
|
|
14
|
+
"directory": "packages/biometrics"
|
|
15
|
+
},
|
|
16
|
+
"author": "Your Name <your.email@example.com>",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"react-native": "./src/index.native.ts",
|
|
24
|
+
"browser": {
|
|
25
|
+
"types": "./src/index.web.ts",
|
|
26
|
+
"import": "./src/index.web.ts",
|
|
27
|
+
"require": "./src/index.web.ts"
|
|
28
|
+
},
|
|
29
|
+
"default": {
|
|
30
|
+
"types": "./src/index.ts",
|
|
31
|
+
"import": "./src/index.ts",
|
|
32
|
+
"require": "./src/index.ts"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"prepublishOnly": "echo 'Publishing TypeScript source directly'",
|
|
38
|
+
"publish:npm": "npm publish"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"expo-local-authentication": ">=14.0.0",
|
|
42
|
+
"react": ">=16.8.0",
|
|
43
|
+
"react-native": ">=0.60.0",
|
|
44
|
+
"react-native-passkeys": ">=0.4.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"expo-local-authentication": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"react-native": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"react-native-passkeys": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/react": "^19.1.0",
|
|
59
|
+
"expo-local-authentication": "^16.0.0",
|
|
60
|
+
"react-native-passkeys": "^0.4.0",
|
|
61
|
+
"typescript": "^5.0.0"
|
|
62
|
+
},
|
|
63
|
+
"files": [
|
|
64
|
+
"src",
|
|
65
|
+
"README.md"
|
|
66
|
+
],
|
|
67
|
+
"keywords": [
|
|
68
|
+
"react",
|
|
69
|
+
"react-native",
|
|
70
|
+
"biometrics",
|
|
71
|
+
"fingerprint",
|
|
72
|
+
"faceid",
|
|
73
|
+
"touchid",
|
|
74
|
+
"passkeys",
|
|
75
|
+
"webauthn",
|
|
76
|
+
"fido2",
|
|
77
|
+
"authentication",
|
|
78
|
+
"cross-platform"
|
|
79
|
+
]
|
|
80
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Native Biometric Authentication
|
|
3
|
+
// Uses expo-local-authentication for fingerprint, FaceID, iris
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
BiometricType,
|
|
8
|
+
SecurityLevel,
|
|
9
|
+
AuthenticateOptions,
|
|
10
|
+
AuthResult,
|
|
11
|
+
} from './types';
|
|
12
|
+
|
|
13
|
+
let LocalAuthentication: typeof import('expo-local-authentication') | null =
|
|
14
|
+
null;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
LocalAuthentication = require('expo-local-authentication');
|
|
18
|
+
} catch {
|
|
19
|
+
// expo-local-authentication not installed — all functions degrade gracefully
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check whether any biometric hardware is available and enrolled.
|
|
24
|
+
*/
|
|
25
|
+
export async function isBiometricAvailable(): Promise<boolean> {
|
|
26
|
+
if (!LocalAuthentication) return false;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const hasHardware = await LocalAuthentication.hasHardwareAsync();
|
|
30
|
+
if (!hasHardware) return false;
|
|
31
|
+
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
|
32
|
+
return isEnrolled;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Return the biometric types available on this device.
|
|
40
|
+
*/
|
|
41
|
+
export async function getBiometricTypes(): Promise<BiometricType[]> {
|
|
42
|
+
if (!LocalAuthentication) return [];
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const types =
|
|
46
|
+
await LocalAuthentication.supportedAuthenticationTypesAsync();
|
|
47
|
+
const result: BiometricType[] = [];
|
|
48
|
+
|
|
49
|
+
for (const t of types) {
|
|
50
|
+
switch (t) {
|
|
51
|
+
case LocalAuthentication.AuthenticationType.FINGERPRINT:
|
|
52
|
+
result.push('fingerprint');
|
|
53
|
+
break;
|
|
54
|
+
case LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION:
|
|
55
|
+
result.push('facial_recognition');
|
|
56
|
+
break;
|
|
57
|
+
case LocalAuthentication.AuthenticationType.IRIS:
|
|
58
|
+
result.push('iris');
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return result;
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get the security level of biometric auth on the device.
|
|
71
|
+
*/
|
|
72
|
+
export async function getSecurityLevel(): Promise<SecurityLevel> {
|
|
73
|
+
if (!LocalAuthentication) return 'none';
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const level = await LocalAuthentication.getEnrolledLevelAsync();
|
|
77
|
+
|
|
78
|
+
switch (level) {
|
|
79
|
+
case LocalAuthentication.SecurityLevel.NONE:
|
|
80
|
+
return 'none';
|
|
81
|
+
case LocalAuthentication.SecurityLevel.SECRET:
|
|
82
|
+
return 'device_credential';
|
|
83
|
+
case LocalAuthentication.SecurityLevel.BIOMETRIC_WEAK:
|
|
84
|
+
return 'biometric_weak';
|
|
85
|
+
case LocalAuthentication.SecurityLevel.BIOMETRIC_STRONG:
|
|
86
|
+
return 'biometric_strong';
|
|
87
|
+
default:
|
|
88
|
+
return 'none';
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
return 'none';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Prompt the user for biometric authentication.
|
|
97
|
+
*/
|
|
98
|
+
export async function authenticate(
|
|
99
|
+
options?: AuthenticateOptions,
|
|
100
|
+
): Promise<AuthResult> {
|
|
101
|
+
if (!LocalAuthentication) {
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
error: 'not_available',
|
|
105
|
+
message: 'expo-local-authentication is not installed',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const result = await LocalAuthentication.authenticateAsync({
|
|
111
|
+
promptMessage: options?.promptMessage ?? 'Authenticate',
|
|
112
|
+
cancelLabel: options?.cancelLabel,
|
|
113
|
+
fallbackLabel: options?.fallbackLabel,
|
|
114
|
+
disableDeviceFallback: options?.disableDeviceFallback,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (result.success) {
|
|
118
|
+
return { success: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Map expo error codes to our AuthError type
|
|
122
|
+
const errorCode = result.error;
|
|
123
|
+
switch (errorCode) {
|
|
124
|
+
case 'not_available':
|
|
125
|
+
return { success: false, error: 'not_available', message: result.warning };
|
|
126
|
+
case 'not_enrolled':
|
|
127
|
+
return { success: false, error: 'not_enrolled', message: result.warning };
|
|
128
|
+
case 'user_cancel':
|
|
129
|
+
return { success: false, error: 'user_cancel', message: result.warning };
|
|
130
|
+
case 'lockout':
|
|
131
|
+
return { success: false, error: 'lockout', message: result.warning };
|
|
132
|
+
case 'system_cancel':
|
|
133
|
+
return { success: false, error: 'system_cancel', message: result.warning };
|
|
134
|
+
case 'passcode_not_set':
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
error: 'passcode_not_set',
|
|
138
|
+
message: result.warning,
|
|
139
|
+
};
|
|
140
|
+
case 'authentication_failed':
|
|
141
|
+
return {
|
|
142
|
+
success: false,
|
|
143
|
+
error: 'authentication_failed',
|
|
144
|
+
message: result.warning,
|
|
145
|
+
};
|
|
146
|
+
default:
|
|
147
|
+
return {
|
|
148
|
+
success: false,
|
|
149
|
+
error: 'unknown',
|
|
150
|
+
message: result.warning ?? 'Authentication failed',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
} catch (err: unknown) {
|
|
154
|
+
return {
|
|
155
|
+
success: false,
|
|
156
|
+
error: 'unknown',
|
|
157
|
+
message: err instanceof Error ? err.message : 'Unknown error',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Cancel an in-progress authentication (Android only).
|
|
164
|
+
*/
|
|
165
|
+
export async function cancelAuthentication(): Promise<void> {
|
|
166
|
+
if (!LocalAuthentication) return;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await LocalAuthentication.cancelAuthenticate();
|
|
170
|
+
} catch {
|
|
171
|
+
// Ignore — may not be supported or no auth in progress
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Web Biometric Authentication
|
|
3
|
+
// Uses WebAuthn's userVerification to check for platform authenticator
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
BiometricType,
|
|
8
|
+
SecurityLevel,
|
|
9
|
+
AuthenticateOptions,
|
|
10
|
+
AuthResult,
|
|
11
|
+
} from './types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a user-verifying platform authenticator is available.
|
|
15
|
+
* This indicates the device has biometric or PIN-based auth (e.g. Windows Hello,
|
|
16
|
+
* Touch ID in Safari, Android biometric prompt in Chrome).
|
|
17
|
+
*/
|
|
18
|
+
export async function isBiometricAvailable(): Promise<boolean> {
|
|
19
|
+
if (
|
|
20
|
+
typeof window === 'undefined' ||
|
|
21
|
+
typeof PublicKeyCredential === 'undefined'
|
|
22
|
+
) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* On web, we cannot distinguish between fingerprint, face, etc.
|
|
35
|
+
* If a platform authenticator is available, we report it generically.
|
|
36
|
+
*/
|
|
37
|
+
export async function getBiometricTypes(): Promise<BiometricType[]> {
|
|
38
|
+
const available = await isBiometricAvailable();
|
|
39
|
+
// Web cannot distinguish biometric type — report fingerprint as a generic indicator
|
|
40
|
+
return available ? ['fingerprint'] : [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* On web, security level is binary: either a platform authenticator exists
|
|
45
|
+
* (biometric_strong) or it doesn't (none).
|
|
46
|
+
*/
|
|
47
|
+
export async function getSecurityLevel(): Promise<SecurityLevel> {
|
|
48
|
+
const available = await isBiometricAvailable();
|
|
49
|
+
return available ? 'biometric_strong' : 'none';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Trigger a WebAuthn user-verification ceremony.
|
|
54
|
+
*
|
|
55
|
+
* This creates a throwaway credential with `userVerification: 'required'`
|
|
56
|
+
* which forces the browser to verify the user via biometrics or device PIN.
|
|
57
|
+
* The credential itself is not stored — this is purely for local verification.
|
|
58
|
+
*/
|
|
59
|
+
export async function authenticate(
|
|
60
|
+
options?: AuthenticateOptions,
|
|
61
|
+
): Promise<AuthResult> {
|
|
62
|
+
if (!(await isBiometricAvailable())) {
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
error: 'not_available',
|
|
66
|
+
message: 'No platform authenticator available',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// Generate a random challenge
|
|
72
|
+
const challenge = new Uint8Array(32);
|
|
73
|
+
crypto.getRandomValues(challenge);
|
|
74
|
+
|
|
75
|
+
// Generate a random user ID
|
|
76
|
+
const userId = new Uint8Array(16);
|
|
77
|
+
crypto.getRandomValues(userId);
|
|
78
|
+
|
|
79
|
+
const credential = await navigator.credentials.create({
|
|
80
|
+
publicKey: {
|
|
81
|
+
challenge,
|
|
82
|
+
rp: {
|
|
83
|
+
name: options?.promptMessage ?? 'Biometric Verification',
|
|
84
|
+
id: window.location.hostname,
|
|
85
|
+
},
|
|
86
|
+
user: {
|
|
87
|
+
id: userId,
|
|
88
|
+
name: 'biometric-check',
|
|
89
|
+
displayName: 'Biometric Verification',
|
|
90
|
+
},
|
|
91
|
+
pubKeyCredParams: [
|
|
92
|
+
{ type: 'public-key', alg: -7 }, // ES256
|
|
93
|
+
],
|
|
94
|
+
authenticatorSelection: {
|
|
95
|
+
authenticatorAttachment: 'platform',
|
|
96
|
+
userVerification: 'required',
|
|
97
|
+
residentKey: 'discouraged',
|
|
98
|
+
},
|
|
99
|
+
timeout: 60000,
|
|
100
|
+
attestation: 'none',
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return credential ? { success: true } : {
|
|
105
|
+
success: false,
|
|
106
|
+
error: 'authentication_failed',
|
|
107
|
+
message: 'No credential returned',
|
|
108
|
+
};
|
|
109
|
+
} catch (err: unknown) {
|
|
110
|
+
const error = err as DOMException;
|
|
111
|
+
|
|
112
|
+
if (error.name === 'NotAllowedError') {
|
|
113
|
+
return {
|
|
114
|
+
success: false,
|
|
115
|
+
error: 'user_cancel',
|
|
116
|
+
message: error.message || 'User cancelled or not allowed',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (error.name === 'InvalidStateError') {
|
|
121
|
+
// Credential already exists for this rp+user — still means auth succeeded
|
|
122
|
+
return { success: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
success: false,
|
|
127
|
+
error: 'unknown',
|
|
128
|
+
message: error.message || 'Unknown error during authentication',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* No-op on web — cancellation is handled by the browser's native UI.
|
|
135
|
+
*/
|
|
136
|
+
export async function cancelAuthentication(): Promise<void> {
|
|
137
|
+
// Not supported on web; the browser handles dismissal
|
|
138
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './types';
|
|
2
|
+
export {
|
|
3
|
+
isBiometricAvailable,
|
|
4
|
+
getBiometricTypes,
|
|
5
|
+
getSecurityLevel,
|
|
6
|
+
authenticate,
|
|
7
|
+
cancelAuthentication,
|
|
8
|
+
} from './biometrics.native';
|
|
9
|
+
export {
|
|
10
|
+
isPasskeySupported,
|
|
11
|
+
createPasskey,
|
|
12
|
+
getPasskey,
|
|
13
|
+
} from './passkeys.native';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Default entry — re-exports types.
|
|
2
|
+
// Platform-specific entry points (index.web.ts, index.native.ts) provide real implementations.
|
|
3
|
+
export * from './types';
|
|
4
|
+
export {
|
|
5
|
+
isBiometricAvailable,
|
|
6
|
+
getBiometricTypes,
|
|
7
|
+
getSecurityLevel,
|
|
8
|
+
authenticate,
|
|
9
|
+
cancelAuthentication,
|
|
10
|
+
} from './biometrics.web';
|
|
11
|
+
export {
|
|
12
|
+
isPasskeySupported,
|
|
13
|
+
createPasskey,
|
|
14
|
+
getPasskey,
|
|
15
|
+
} from './passkeys.web';
|
package/src/index.web.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './types';
|
|
2
|
+
export {
|
|
3
|
+
isBiometricAvailable,
|
|
4
|
+
getBiometricTypes,
|
|
5
|
+
getSecurityLevel,
|
|
6
|
+
authenticate,
|
|
7
|
+
cancelAuthentication,
|
|
8
|
+
} from './biometrics.web';
|
|
9
|
+
export {
|
|
10
|
+
isPasskeySupported,
|
|
11
|
+
createPasskey,
|
|
12
|
+
getPasskey,
|
|
13
|
+
} from './passkeys.web';
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Native Passkeys (FIDO2 / WebAuthn via react-native-passkeys)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
PasskeyCreateOptions,
|
|
7
|
+
PasskeyCreateResult,
|
|
8
|
+
PasskeyGetOptions,
|
|
9
|
+
PasskeyGetResult,
|
|
10
|
+
PasskeyError,
|
|
11
|
+
} from './types';
|
|
12
|
+
|
|
13
|
+
let Passkey: {
|
|
14
|
+
isSupported: () => boolean;
|
|
15
|
+
create: (request: unknown) => Promise<unknown>;
|
|
16
|
+
get: (request: unknown) => Promise<unknown>;
|
|
17
|
+
} | null = null;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const mod = require('react-native-passkeys');
|
|
21
|
+
Passkey = mod.Passkey ?? mod.default ?? mod;
|
|
22
|
+
} catch {
|
|
23
|
+
// react-native-passkeys not installed — functions throw PasskeyError
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if passkeys are supported on this device.
|
|
28
|
+
*/
|
|
29
|
+
export async function isPasskeySupported(): Promise<boolean> {
|
|
30
|
+
if (!Passkey) return false;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return Passkey.isSupported();
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a new passkey (registration / attestation).
|
|
41
|
+
*
|
|
42
|
+
* @throws {PasskeyError} on failure
|
|
43
|
+
*/
|
|
44
|
+
export async function createPasskey(
|
|
45
|
+
options: PasskeyCreateOptions,
|
|
46
|
+
): Promise<PasskeyCreateResult> {
|
|
47
|
+
if (!Passkey) {
|
|
48
|
+
throw {
|
|
49
|
+
code: 'not_supported',
|
|
50
|
+
message: 'react-native-passkeys is not installed',
|
|
51
|
+
} satisfies PasskeyError;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const result = (await Passkey.create({
|
|
56
|
+
challenge: options.challenge,
|
|
57
|
+
rp: options.rp,
|
|
58
|
+
user: options.user,
|
|
59
|
+
pubKeyCredParams: options.pubKeyCredParams ?? [
|
|
60
|
+
{ type: 'public-key', alg: -7 },
|
|
61
|
+
{ type: 'public-key', alg: -257 },
|
|
62
|
+
],
|
|
63
|
+
timeout: options.timeout,
|
|
64
|
+
authenticatorSelection: options.authenticatorSelection ?? {
|
|
65
|
+
residentKey: 'preferred',
|
|
66
|
+
userVerification: 'preferred',
|
|
67
|
+
},
|
|
68
|
+
excludeCredentials: options.excludeCredentials,
|
|
69
|
+
attestation: options.attestation ?? 'none',
|
|
70
|
+
})) as PasskeyCreateResult;
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
} catch (err: unknown) {
|
|
74
|
+
throw mapNativeError(err);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get an existing passkey (authentication / assertion).
|
|
80
|
+
*
|
|
81
|
+
* @throws {PasskeyError} on failure
|
|
82
|
+
*/
|
|
83
|
+
export async function getPasskey(
|
|
84
|
+
options: PasskeyGetOptions,
|
|
85
|
+
): Promise<PasskeyGetResult> {
|
|
86
|
+
if (!Passkey) {
|
|
87
|
+
throw {
|
|
88
|
+
code: 'not_supported',
|
|
89
|
+
message: 'react-native-passkeys is not installed',
|
|
90
|
+
} satisfies PasskeyError;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const result = (await Passkey.get({
|
|
95
|
+
challenge: options.challenge,
|
|
96
|
+
rpId: options.rpId,
|
|
97
|
+
allowCredentials: options.allowCredentials,
|
|
98
|
+
timeout: options.timeout,
|
|
99
|
+
userVerification: options.userVerification ?? 'preferred',
|
|
100
|
+
})) as PasskeyGetResult;
|
|
101
|
+
|
|
102
|
+
return result;
|
|
103
|
+
} catch (err: unknown) {
|
|
104
|
+
throw mapNativeError(err);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// Helpers
|
|
110
|
+
// ============================================================================
|
|
111
|
+
|
|
112
|
+
function mapNativeError(err: unknown): PasskeyError {
|
|
113
|
+
if (
|
|
114
|
+
err !== null &&
|
|
115
|
+
typeof err === 'object' &&
|
|
116
|
+
'code' in err &&
|
|
117
|
+
'message' in err
|
|
118
|
+
) {
|
|
119
|
+
const e = err as { code: string; message: string };
|
|
120
|
+
|
|
121
|
+
// react-native-passkeys error codes
|
|
122
|
+
if (
|
|
123
|
+
e.code === 'cancelled' ||
|
|
124
|
+
e.code === 'UserCancelled' ||
|
|
125
|
+
e.message.includes('cancel')
|
|
126
|
+
) {
|
|
127
|
+
return { code: 'cancelled', message: e.message };
|
|
128
|
+
}
|
|
129
|
+
if (e.code === 'InvalidStateError' || e.code === 'invalid_state') {
|
|
130
|
+
return { code: 'invalid_state', message: e.message };
|
|
131
|
+
}
|
|
132
|
+
if (e.code === 'NotAllowedError' || e.code === 'not_allowed') {
|
|
133
|
+
return { code: 'not_allowed', message: e.message };
|
|
134
|
+
}
|
|
135
|
+
if (e.code === 'NotSupportedError' || e.code === 'not_supported') {
|
|
136
|
+
return { code: 'not_supported', message: e.message };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { code: 'unknown', message: e.message };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
code: 'unknown',
|
|
144
|
+
message: err instanceof Error ? err.message : 'Unknown passkey error',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Web Passkeys (WebAuthn / FIDO2)
|
|
3
|
+
// Uses navigator.credentials.create / get with publicKey
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
PasskeyCreateOptions,
|
|
8
|
+
PasskeyCreateResult,
|
|
9
|
+
PasskeyGetOptions,
|
|
10
|
+
PasskeyGetResult,
|
|
11
|
+
PasskeyError,
|
|
12
|
+
} from './types';
|
|
13
|
+
import { base64urlToBuffer, bufferToBase64url } from './types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if WebAuthn / passkeys are supported in this browser.
|
|
17
|
+
*/
|
|
18
|
+
export async function isPasskeySupported(): Promise<boolean> {
|
|
19
|
+
if (
|
|
20
|
+
typeof window === 'undefined' ||
|
|
21
|
+
typeof PublicKeyCredential === 'undefined'
|
|
22
|
+
) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
// Check for conditional mediation support (autofill-assisted passkeys)
|
|
28
|
+
// Falls back to basic PublicKeyCredential check
|
|
29
|
+
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
30
|
+
} catch {
|
|
31
|
+
// PublicKeyCredential exists but the check failed — still likely supported
|
|
32
|
+
return typeof navigator.credentials !== 'undefined';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a new passkey (WebAuthn registration / attestation).
|
|
38
|
+
*
|
|
39
|
+
* @throws {PasskeyError} on failure
|
|
40
|
+
*/
|
|
41
|
+
export async function createPasskey(
|
|
42
|
+
options: PasskeyCreateOptions,
|
|
43
|
+
): Promise<PasskeyCreateResult> {
|
|
44
|
+
if (typeof PublicKeyCredential === 'undefined') {
|
|
45
|
+
throw {
|
|
46
|
+
code: 'not_supported',
|
|
47
|
+
message: 'WebAuthn is not supported in this browser',
|
|
48
|
+
} satisfies PasskeyError;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const publicKey: PublicKeyCredentialCreationOptions = {
|
|
52
|
+
challenge: base64urlToBuffer(options.challenge),
|
|
53
|
+
rp: options.rp,
|
|
54
|
+
user: {
|
|
55
|
+
id: base64urlToBuffer(options.user.id),
|
|
56
|
+
name: options.user.name,
|
|
57
|
+
displayName: options.user.displayName,
|
|
58
|
+
},
|
|
59
|
+
pubKeyCredParams: options.pubKeyCredParams ?? [
|
|
60
|
+
{ type: 'public-key', alg: -7 }, // ES256
|
|
61
|
+
{ type: 'public-key', alg: -257 }, // RS256
|
|
62
|
+
],
|
|
63
|
+
timeout: options.timeout ?? 60000,
|
|
64
|
+
authenticatorSelection: options.authenticatorSelection ?? {
|
|
65
|
+
residentKey: 'preferred',
|
|
66
|
+
userVerification: 'preferred',
|
|
67
|
+
},
|
|
68
|
+
excludeCredentials: options.excludeCredentials?.map((cred) => ({
|
|
69
|
+
type: cred.type,
|
|
70
|
+
id: base64urlToBuffer(cred.id),
|
|
71
|
+
transports: cred.transports,
|
|
72
|
+
})),
|
|
73
|
+
attestation: options.attestation ?? 'none',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
let credential: PublicKeyCredential;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const result = await navigator.credentials.create({ publicKey });
|
|
80
|
+
if (!result) {
|
|
81
|
+
throw {
|
|
82
|
+
code: 'unknown',
|
|
83
|
+
message: 'navigator.credentials.create returned null',
|
|
84
|
+
} satisfies PasskeyError;
|
|
85
|
+
}
|
|
86
|
+
credential = result as PublicKeyCredential;
|
|
87
|
+
} catch (err: unknown) {
|
|
88
|
+
throw mapDOMError(err);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const response =
|
|
92
|
+
credential.response as AuthenticatorAttestationResponse;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
id: credential.id,
|
|
96
|
+
rawId: bufferToBase64url(credential.rawId),
|
|
97
|
+
type: 'public-key',
|
|
98
|
+
response: {
|
|
99
|
+
clientDataJSON: bufferToBase64url(response.clientDataJSON),
|
|
100
|
+
attestationObject: bufferToBase64url(response.attestationObject),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get an existing passkey (WebAuthn authentication / assertion).
|
|
107
|
+
*
|
|
108
|
+
* @throws {PasskeyError} on failure
|
|
109
|
+
*/
|
|
110
|
+
export async function getPasskey(
|
|
111
|
+
options: PasskeyGetOptions,
|
|
112
|
+
): Promise<PasskeyGetResult> {
|
|
113
|
+
if (typeof PublicKeyCredential === 'undefined') {
|
|
114
|
+
throw {
|
|
115
|
+
code: 'not_supported',
|
|
116
|
+
message: 'WebAuthn is not supported in this browser',
|
|
117
|
+
} satisfies PasskeyError;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const publicKey: PublicKeyCredentialRequestOptions = {
|
|
121
|
+
challenge: base64urlToBuffer(options.challenge),
|
|
122
|
+
rpId: options.rpId,
|
|
123
|
+
allowCredentials: options.allowCredentials?.map((cred) => ({
|
|
124
|
+
type: cred.type,
|
|
125
|
+
id: base64urlToBuffer(cred.id),
|
|
126
|
+
transports: cred.transports,
|
|
127
|
+
})),
|
|
128
|
+
timeout: options.timeout ?? 60000,
|
|
129
|
+
userVerification: options.userVerification ?? 'preferred',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
let credential: PublicKeyCredential;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const result = await navigator.credentials.get({ publicKey });
|
|
136
|
+
if (!result) {
|
|
137
|
+
throw {
|
|
138
|
+
code: 'unknown',
|
|
139
|
+
message: 'navigator.credentials.get returned null',
|
|
140
|
+
} satisfies PasskeyError;
|
|
141
|
+
}
|
|
142
|
+
credential = result as PublicKeyCredential;
|
|
143
|
+
} catch (err: unknown) {
|
|
144
|
+
throw mapDOMError(err);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const response =
|
|
148
|
+
credential.response as AuthenticatorAssertionResponse;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
id: credential.id,
|
|
152
|
+
rawId: bufferToBase64url(credential.rawId),
|
|
153
|
+
type: 'public-key',
|
|
154
|
+
response: {
|
|
155
|
+
clientDataJSON: bufferToBase64url(response.clientDataJSON),
|
|
156
|
+
authenticatorData: bufferToBase64url(response.authenticatorData),
|
|
157
|
+
signature: bufferToBase64url(response.signature),
|
|
158
|
+
userHandle: response.userHandle
|
|
159
|
+
? bufferToBase64url(response.userHandle)
|
|
160
|
+
: undefined,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// Helpers
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
function mapDOMError(err: unknown): PasskeyError {
|
|
170
|
+
if (
|
|
171
|
+
err !== null &&
|
|
172
|
+
typeof err === 'object' &&
|
|
173
|
+
'code' in err &&
|
|
174
|
+
'message' in err
|
|
175
|
+
) {
|
|
176
|
+
// Already a PasskeyError
|
|
177
|
+
return err as PasskeyError;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const error = err as DOMException;
|
|
181
|
+
|
|
182
|
+
switch (error.name) {
|
|
183
|
+
case 'NotAllowedError':
|
|
184
|
+
return { code: 'cancelled', message: error.message || 'User cancelled' };
|
|
185
|
+
case 'InvalidStateError':
|
|
186
|
+
return {
|
|
187
|
+
code: 'invalid_state',
|
|
188
|
+
message: error.message || 'Credential already exists',
|
|
189
|
+
};
|
|
190
|
+
case 'NotSupportedError':
|
|
191
|
+
return { code: 'not_supported', message: error.message || 'Not supported' };
|
|
192
|
+
case 'SecurityError':
|
|
193
|
+
return {
|
|
194
|
+
code: 'not_allowed',
|
|
195
|
+
message: error.message || 'Security error (wrong origin or RP ID)',
|
|
196
|
+
};
|
|
197
|
+
default:
|
|
198
|
+
return {
|
|
199
|
+
code: 'unknown',
|
|
200
|
+
message: error.message || 'Unknown error',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Biometric Authentication Types
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
export type BiometricType = 'fingerprint' | 'facial_recognition' | 'iris';
|
|
6
|
+
|
|
7
|
+
export type SecurityLevel =
|
|
8
|
+
| 'none'
|
|
9
|
+
| 'device_credential'
|
|
10
|
+
| 'biometric_weak'
|
|
11
|
+
| 'biometric_strong';
|
|
12
|
+
|
|
13
|
+
export type AuthError =
|
|
14
|
+
| 'not_available'
|
|
15
|
+
| 'not_enrolled'
|
|
16
|
+
| 'user_cancel'
|
|
17
|
+
| 'lockout'
|
|
18
|
+
| 'system_cancel'
|
|
19
|
+
| 'passcode_not_set'
|
|
20
|
+
| 'authentication_failed'
|
|
21
|
+
| 'unknown';
|
|
22
|
+
|
|
23
|
+
export interface AuthenticateOptions {
|
|
24
|
+
/** Message displayed alongside the biometric prompt. */
|
|
25
|
+
promptMessage?: string;
|
|
26
|
+
/** Label for the cancel button. */
|
|
27
|
+
cancelLabel?: string;
|
|
28
|
+
/** iOS: label for the passcode fallback button. */
|
|
29
|
+
fallbackLabel?: string;
|
|
30
|
+
/** Prevent PIN/passcode fallback after biometric failure. */
|
|
31
|
+
disableDeviceFallback?: boolean;
|
|
32
|
+
/** Android: require Class 3 (strong) biometric instead of weak. */
|
|
33
|
+
requireStrongBiometric?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type AuthResult =
|
|
37
|
+
| { success: true }
|
|
38
|
+
| { success: false; error: AuthError; message?: string };
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// Passkey Types (WebAuthn / FIDO2)
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
export interface PublicKeyCredentialParam {
|
|
45
|
+
type: 'public-key';
|
|
46
|
+
/** COSE algorithm identifier. -7 = ES256, -257 = RS256. */
|
|
47
|
+
alg: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CredentialDescriptor {
|
|
51
|
+
type: 'public-key';
|
|
52
|
+
/** Credential ID as base64url string. */
|
|
53
|
+
id: string;
|
|
54
|
+
transports?: Array<'usb' | 'ble' | 'nfc' | 'internal' | 'hybrid'>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PasskeyCreateOptions {
|
|
58
|
+
/** Base64url-encoded challenge from server. */
|
|
59
|
+
challenge: string;
|
|
60
|
+
/** Relying party information. */
|
|
61
|
+
rp: {
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
};
|
|
65
|
+
/** User information. */
|
|
66
|
+
user: {
|
|
67
|
+
/** Base64url-encoded user ID. */
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
displayName: string;
|
|
71
|
+
};
|
|
72
|
+
/** Supported public key credential parameters. Defaults to ES256 + RS256. */
|
|
73
|
+
pubKeyCredParams?: PublicKeyCredentialParam[];
|
|
74
|
+
/** Timeout in milliseconds. */
|
|
75
|
+
timeout?: number;
|
|
76
|
+
/** Authenticator selection criteria. */
|
|
77
|
+
authenticatorSelection?: {
|
|
78
|
+
authenticatorAttachment?: 'platform' | 'cross-platform';
|
|
79
|
+
residentKey?: 'required' | 'preferred' | 'discouraged';
|
|
80
|
+
userVerification?: 'required' | 'preferred' | 'discouraged';
|
|
81
|
+
};
|
|
82
|
+
/** Credentials to exclude (prevent re-registration). */
|
|
83
|
+
excludeCredentials?: CredentialDescriptor[];
|
|
84
|
+
/** Attestation conveyance preference. */
|
|
85
|
+
attestation?: 'none' | 'indirect' | 'direct' | 'enterprise';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface PasskeyCreateResult {
|
|
89
|
+
/** Credential ID as base64url string. */
|
|
90
|
+
id: string;
|
|
91
|
+
/** Raw credential ID as base64url string. */
|
|
92
|
+
rawId: string;
|
|
93
|
+
type: 'public-key';
|
|
94
|
+
response: {
|
|
95
|
+
/** Base64url-encoded client data JSON. */
|
|
96
|
+
clientDataJSON: string;
|
|
97
|
+
/** Base64url-encoded attestation object. */
|
|
98
|
+
attestationObject: string;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface PasskeyGetOptions {
|
|
103
|
+
/** Base64url-encoded challenge from server. */
|
|
104
|
+
challenge: string;
|
|
105
|
+
/** Relying party ID. */
|
|
106
|
+
rpId?: string;
|
|
107
|
+
/** Allowed credentials. If empty, discoverable credentials are used. */
|
|
108
|
+
allowCredentials?: CredentialDescriptor[];
|
|
109
|
+
/** Timeout in milliseconds. */
|
|
110
|
+
timeout?: number;
|
|
111
|
+
/** User verification requirement. */
|
|
112
|
+
userVerification?: 'required' | 'preferred' | 'discouraged';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface PasskeyGetResult {
|
|
116
|
+
/** Credential ID as base64url string. */
|
|
117
|
+
id: string;
|
|
118
|
+
/** Raw credential ID as base64url string. */
|
|
119
|
+
rawId: string;
|
|
120
|
+
type: 'public-key';
|
|
121
|
+
response: {
|
|
122
|
+
/** Base64url-encoded client data JSON. */
|
|
123
|
+
clientDataJSON: string;
|
|
124
|
+
/** Base64url-encoded authenticator data. */
|
|
125
|
+
authenticatorData: string;
|
|
126
|
+
/** Base64url-encoded signature. */
|
|
127
|
+
signature: string;
|
|
128
|
+
/** Base64url-encoded user handle (may be absent). */
|
|
129
|
+
userHandle?: string;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface PasskeyError {
|
|
134
|
+
code: 'not_supported' | 'cancelled' | 'invalid_state' | 'not_allowed' | 'unknown';
|
|
135
|
+
message: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// Base64url Helpers (shared)
|
|
140
|
+
// ============================================================================
|
|
141
|
+
|
|
142
|
+
export function base64urlToBuffer(base64url: string): ArrayBuffer {
|
|
143
|
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
144
|
+
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
|
145
|
+
const binary = atob(base64 + padding);
|
|
146
|
+
const bytes = new Uint8Array(binary.length);
|
|
147
|
+
for (let i = 0; i < binary.length; i++) {
|
|
148
|
+
bytes[i] = binary.charCodeAt(i);
|
|
149
|
+
}
|
|
150
|
+
return bytes.buffer;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function bufferToBase64url(buffer: ArrayBuffer): string {
|
|
154
|
+
const bytes = new Uint8Array(buffer);
|
|
155
|
+
let binary = '';
|
|
156
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
157
|
+
binary += String.fromCharCode(bytes[i]);
|
|
158
|
+
}
|
|
159
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
160
|
+
}
|