@ideem/zsm-client-sdk 2.7.1 → 2.8.0

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/CHANGELOG.md CHANGED
@@ -12,7 +12,33 @@ and Universal MFA into a browser application.
12
12
 
13
13
  ----
14
14
 
15
- ## Release 2.7.1 - Mar 3, 2026 [ NPM PACKAGE RELEASE ] **[ LATEST ]**
15
+ ## Release 2.8.0 - April 15, 2026 [ NPM PACKAGE RELEASE ] **latest release**
16
+ - Migrates all Passkeys+ functionality into the Core package
17
+ - "Zeroes out" the Passkeys+ Plug-In package (basically now throws a warning in the console explaining it is now unnecessary, and that it can be removed, along with instructions on how to do so.
18
+ - Includes "wrapper" function stub for userIdentifier (encodeUserIdentifier.js) for future hashing operations.
19
+ - Expands error messaging (this is some of the precursor work to my intention to standardize all outputs into a risk engine-compliant format) such that recommended actions are included inside of error messages (e.g. "Cannot authenticate with passkey; user does not have a passkey registered for this ZSM account, but you have set the userVerification parameter to 'required'! Either: enroll the user's passkey before attempting to authenticate, downgrade the userVerification requirement's strictness to 'preferred' or 'discouraged', or attempt to authenticate the user using only their ZSM credential."
20
+ - Sets up the requested "fallback" behavior for userVerification = "preferred"(/true): the PKP operations now only fail when the UV flag is set to "required".
21
+ - Adds additional support to userVerification parameter:
22
+ - Now accepts "zsm" - same behavior as "prevented": ZSM-only operation
23
+ - Now accepts "re-enroll"/"reenroll" - forcibly overrides the existing passkey and allows for registration of a new one
24
+ - Sets up fairly exhaustive checks for device support of passkeys, and includes same inside of the enhanced checkAllEnrollments function (see below)
25
+ - Extensive enhancements to checkAllEnrollments:
26
+ - Expands data returned to include all of the following data:
27
+ - "userIdentifier" - Now includes the customer_defined_identifier in the data object
28
+ - "enrollmentActive" - Whether or not the credential has been finalized via an OOB validate-token call
29
+ - "passkeySupported" - Whether or not the DEVICE supports passkeys
30
+ - "passkeyEligible" - Whether on not passkeys ARE AVAILABLE (i.e. passkeySupported && one is registered)
31
+ - "hasLocalPasskey" - Whether or not a passkey has been registered on this device's enrollment
32
+ - "hasRemotePasskey" - Whether or not a passkey has been registered on ANY device, under this identity
33
+ - "pkpCredential": - The challenge object representing the PKP credential
34
+ - "pkpCredentialID" - The PKP credential ID (or a collection of ID's if multiple are available)
35
+ - "hasZSMCred" - Whether or not a ZSM Credential exists for CDI
36
+ - "zsmCredential" - The ZSM Credential's Data
37
+ - "zsmCredentialID" - The ID of the local ZSM Credential
38
+ - Automatically consults the remote server when data is negative and not already retrieved (and bypasses consultation when data is known to be up-to-date)
39
+ - Automatically manages the latest retrieval in memory (to minimize RTT's)
40
+
41
+ ## Release 2.7.1 - March 3, 2026 [ NPM PACKAGE RELEASE ]
16
42
  - Minor bug fixes and improvements
17
43
  - Added dynamic versioning headers
18
44
  - Updates to WASM code to remove Base64URL encoding and decoding steps for improved performance and reduced complexity
package/ErrorHandler.js CHANGED
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * @notes IMPORTANT: If unable to unbind the active user at 3+ errors, or either setter value is unset, OR the count reaches 5, the entire ideem IndexedDB gets purged.
13
13
  *
14
- * @notes IMPORTANT: the `reset()` method MUST be called to reset the sequential error count upon successful trannsaction with the RP (see: makePostRequest in RelyingPartyBase.js).
14
+ * @notes IMPORTANT: the `reset()` method MUST be called to reset the sequential error count upon successful transaction with the RP (see: makePostRequest in RelyingPartyBase.js).
15
15
  *
16
16
  * @notes IMPORTANT: This class relies on a valid userIdentifier and zsmAPIInstance being set PRIOR to invoking the error() method to facilitate local credential purging when necessary.
17
17
  * @example ERR.zsmAPI = webAuthnClientInstance; // Set the ZSM API instance (e.g., WebAuthnClient)
@@ -1,6 +1,10 @@
1
1
  import eventCoordinator from './EventCoordinator.js';
2
2
  import {zsmPluginManager} from './PluginManager.js';
3
+ import { encodeUserIdentifier } from './encodeUserIdentifier.js';
3
4
 
5
+ // [J] The lines modified during the concatenation of the PKP Plugin into the Core SDK have been marked with a
6
+ // [J] `[J]` comment, to make it easier to identify during code review and for cross-platform porting purposes.
7
+ // [J] They can all be removed en masse by replacing `/ *\/\/ [J].*/gm` with `""` (and SHOULD be pre-publish).
4
8
 
5
9
  class FIDO2ClientBase {
6
10
  /**
@@ -13,6 +17,7 @@ class FIDO2ClientBase {
13
17
  this.config = config;
14
18
  const WebAuthnClient = zsmPluginManager.classes('WEBAUTHNCLIENT');
15
19
  this.zsmAPI = new WebAuthnClient(config);
20
+
16
21
  this.checkEnrollment = this.checkEnrollment.bind(this);
17
22
  this.checkIdentity = this.checkIdentity.bind(this);
18
23
  this.webauthnRetrieve = this.webauthnRetrieve.bind(this);
@@ -20,6 +25,11 @@ class FIDO2ClientBase {
20
25
  this.resetDevice = this.resetDevice.bind(this);
21
26
  this.webauthnCreate = this.webauthnCreate.bind(this);
22
27
  this.webauthnGet = this.webauthnGet.bind(this);
28
+ // [J] Note that only EXPOSED methods are BOUND in the constructor
29
+ this.checkAllEnrollments = this.checkAllEnrollments.bind(this); // [J] Migrated from PKP Plugin
30
+ this.webauthnPasskeyPlusCreate = this.webauthnPasskeyPlusCreate.bind(this); // [J] Migrated from PKP Plugin
31
+ this.webauthnPasskeyPlusGet = this.webauthnPasskeyPlusGet.bind(this); // [J] Migrated from PKP Plugin
32
+
23
33
  eventCoordinator.update('FIDO2ClientBase', 'READY');
24
34
  }
25
35
 
@@ -27,13 +37,39 @@ class FIDO2ClientBase {
27
37
  get credentialID() { return this.zsmAPI.credentialID; }
28
38
 
29
39
  /**
30
- * @name checkEnrollment
31
- * @description Alias for webauthnRetrieve
32
- * @param {string} userIdentifier The identifier for the user
33
- * @returns {Promise<Object>} Returns the result of webauthnRetrieve
34
- * @memberOf FIDO2ClientBase, WebAuthnClient
40
+ * @name typeChecks
41
+ * @description Performs type and value checks on the userIdentifier and userVerified parameters for the webauthnCreate and webauthnGet methods, and normalizes the userVerified value
42
+ * @param {string} methodName The name of the method calling typeChecks, used for error messages
43
+ * @param {string} userIdentifier The identifier for the user, required to be a non-empty string
44
+ * @param {string|boolean|null} userVerified The level of user verification required for authentication, can be a boolean or a string ("preferred", "prevented", "required", "discouraged")
45
+ * @param {string} addlUVStates Additional acceptable user verification states to be added to the check against the userVerified parameter, passed as a comma-separated string (e.g. "zsm,re-enroll,reenroll")
46
+ * @returns {Object} An object containing the validated and normalized userIdentifier and userVerified values
47
+ * @throws {TypeError} If the userIdentifier is not provided, is empty, or is not a string
48
+ * @throws {TypeError} If the userVerified value is not a boolean or an accepted string value
49
+ * @memberOf FIDO2ClientBase
35
50
  */
36
- checkEnrollment (userIdentifier) { return this.webauthnRetrieve(userIdentifier); }
51
+ typeChecks(methodName, userIdentifier, userVerified=null, addlUVStates="") {
52
+ if(!userIdentifier) throw new TypeError(`[FIDO2Client] :: ${methodName} :: A userIdentifier String is required! Received: ${userIdentifier} (type: ${typeof userIdentifier}).`);
53
+ let enrollmentDetails;
54
+ if(typeof userIdentifier === 'object') {
55
+ enrollmentDetails = {...userIdentifier};
56
+ userIdentifier = userIdentifier.userIdentifier;
57
+ }
58
+
59
+ if(userVerified != null){
60
+ if(typeof userVerified === 'boolean') userVerified = (userVerified === true) ? "preferred" : "prevented";
61
+ else if(typeof userVerified === 'string') userVerified = userVerified.toLowerCase();
62
+ else throw new TypeError(`[FIDO2Client] :: ${methodName} :: userVerified must be a boolean or string. Received type: ${typeof userVerified}.`);
63
+
64
+ const uvList = ['preferred', 'required', 'discouraged', 'prevented', ...addlUVStates.split(/, ?/g).filter(Boolean)];
65
+ const uvRE = new RegExp(`^(${uvList.join('|')})$`);
66
+ if(!uvRE.test(userVerified)) throw new TypeError(`[FIDO2Client] :: ${methodName} :: userVerified must be one of the following Strings: "${uvList.join('", "')}"). Received: ${userVerified}.`);
67
+ }
68
+ userIdentifier = encodeUserIdentifier(userIdentifier); // [J] Encode the userIdentifier before it is used in any downstream operations (no-op stub pending hash algorithm selection)
69
+ let retVal = { userIdentifier, userVerified };
70
+ if(enrollmentDetails) retVal = { enrollmentDetails, ...retVal };
71
+ return retVal;
72
+ }
37
73
 
38
74
  /**
39
75
  * @name checkIdentity
@@ -43,7 +79,32 @@ class FIDO2ClientBase {
43
79
  * @returns {Promise<Object>} Returns the results of zsmAPI.checkIdentity
44
80
  * @memberOf FIDO2ClientBase, WebAuthnClient
45
81
  */
46
- checkIdentity (userIdentifier, primeEnroll) { return this.zsmAPI.checkIdentity(userIdentifier, primeEnroll); }
82
+ async checkIdentity (userIdentifier, primeEnroll) { return this.zsmAPI.checkIdentity(encodeUserIdentifier(userIdentifier), primeEnroll); }
83
+
84
+ /**
85
+ * @name checkEnrollment
86
+ * @description Checks if a ZSM credential is enrolled on the device for the specified user
87
+ * @param {string} userIdentifier The identifier for the user
88
+ * @returns {Promise<Object>|boolean} Resolves with the enrollment details or false if not enrolled, or an error if checking fails
89
+ * @throws {Error} If the userIdentifier is not provided or is empty
90
+ * @throws {Error} If an error occurs while checking enrollment
91
+ * @memberOf FIDO2ClientBase
92
+ */
93
+ async checkEnrollment(userIdentifier) { // [J] Removing now-useless second parameter, as the checkAllEnrollments method returns all details; checkEnrollment returns ONLY the credential ID
94
+ ({ userIdentifier } = this.typeChecks('checkEnrollment', userIdentifier));
95
+ const enrollmentStatus = await this.zsmAPI.webauthnRetrieve(userIdentifier);
96
+
97
+ return enrollmentStatus;
98
+ }
99
+
100
+ /**
101
+ * @name checkAllEnrollments
102
+ * @description Pass-through method to call WebAuthnClient's webauthnRetrieveAll
103
+ * @param {string} userIdentifier The identifier for the user
104
+ * @returns {Promise<Object>} Returns the full enrollment details for the user
105
+ * @memberOf FIDO2ClientBase
106
+ */
107
+ async checkAllEnrollments(userIdentifier, forceRemoteCheck=false) { return this.zsmAPI.webauthnRetrieveAll(encodeUserIdentifier(userIdentifier), forceRemoteCheck); } // [J] Migrated from PKP Plugin
47
108
 
48
109
  /**
49
110
  * @name webauthnRetrieve
@@ -52,7 +113,7 @@ class FIDO2ClientBase {
52
113
  * @returns {Promise<Object>} Returns the results of zsmAPI.webauthnRetrieve
53
114
  * @memberOf FIDO2ClientBase, WebAuthnClient
54
115
  */
55
- webauthnRetrieve (userIdentifier) { return this.zsmAPI.webauthnRetrieve(userIdentifier); }
116
+ webauthnRetrieve (userIdentifier) { return this.zsmAPI.webauthnRetrieve(encodeUserIdentifier(userIdentifier)); }
56
117
 
57
118
  /**
58
119
  * @name webauthnDelete
@@ -61,7 +122,7 @@ class FIDO2ClientBase {
61
122
  * @returns {Promise<Object>} Returns the results of zsmAPI.unbindFromDevice
62
123
  * @memberOf FIDO2ClientBase, WebAuthnClient
63
124
  */
64
- webauthnDelete (userIdentifier) { return this.zsmAPI.unbindFromDevice(userIdentifier); }
125
+ webauthnDelete (userIdentifier) { return this.zsmAPI.unbindFromDevice(encodeUserIdentifier(userIdentifier)); }
65
126
 
66
127
  /**
67
128
  * @name resetDevice
@@ -69,49 +130,207 @@ class FIDO2ClientBase {
69
130
  * @returns {Promise<void>} No results are returned
70
131
  * @memberOf FIDO2ClientBase, WebAuthnClient
71
132
  */
72
- resetDevice () { this.zsmAPI.webauthnReset(); }
133
+ async resetDevice () { this.zsmAPI.webauthnReset(); }
73
134
 
74
135
  /**
75
136
  * @name webauthnCreate
76
137
  * @description Enrolls a user by creating a ZSM credential on the device
77
138
  * @param {string} userIdentifier The identifier for the user
139
+ * @param {string} userVerified The level of user verification required for authentication (true/"preferred", false/"prevented", "required", "discouraged")
78
140
  * @returns {Promise<Object>|Error} Resolves with the created credential, false if user is already enrolled, or an error if enrollment fails
79
141
  * @throws {Error} If the userIdentifier is not provided or is empty
80
142
  * @throws {Error} If an error occurs during enrollment
81
143
  * @memberOf FIDO2ClientBase
82
144
  */
83
- async webauthnCreate (userIdentifier) {
84
- try {
85
- if(userIdentifier == null
86
- || userIdentifier === '') throw new Error(`[FIDO2Client] :: webauthnCreate :: A userIdentifier String is required! Received: ${userIdentifier}.`);
87
-
88
- const createResult = await this.zsmAPI.webauthnCreate(userIdentifier);
89
- if(createResult instanceof Error) throw(`[FIDO2Client] :: webauthnCreate :: Unable to associate ${userIdentifier}'s identity with device profile.\nDetails:\n${createResult}`);
90
- return createResult;
91
- }catch(e) {
92
- return new Error(e.message || e || '[FIDO2Client] :: webauthnCreate :: An error occurred during credential creation.');
145
+ async webauthnCreate (userIdentifier, userVerified="prevented") { // [J] This method has been merged with the PKP Plugin's __webauthnCreate method
146
+ ({ userIdentifier, userVerified } = this.typeChecks('webauthnCreate', userIdentifier, userVerified, 'zsm,re-enroll,reenroll'));
147
+ const isPasskeyReenrollment = /^re-?enroll$/.test(userVerified); // Is this a forced PKP re-enrollment?
148
+ const attemptPasskeyPlusEnroll = isPasskeyReenrollment || /^(preferred|required)$/.test(userVerified); // [J] Does the userVerified value indicate that Passkeys+ enrollment should be attempted alongside ZSM enrollment?
149
+ const continueOnPasskeyFailure = (userVerified === 'preferred');
150
+
151
+ let pkpEnrollmentResult, enrollmentDetails;
152
+ if(attemptPasskeyPlusEnroll) {
153
+ enrollmentDetails = await this.checkAllEnrollments(userIdentifier);
154
+ try {
155
+ if(!enrollmentDetails.passkeySupported) { // If Passkeys+ is not supported, but required, throw an error immediately
156
+ if(!continueOnPasskeyFailure) // ...and if the userVerified is "required", fail the entire enrollment operation...
157
+ return new Error(`[FIDO2Client] :: webauthnCreate :: Passkeys+ is required for enrollment (userVerified set to "required"), but this device does not support Passkeys+. User ${userIdentifier} cannot be enrolled. Please use a Passkeys+-compatible device to enroll.`);
158
+ else
159
+ console.warn(`[FIDO2Client] :: webauthnCreate :: Passkeys+ enrollment has been requested, but this device does not support Passkeys+. User ${userIdentifier} will be enrolled with ZSM-only credentials. Please use a Passkeys+-compatible device to enroll with Passkeys+ in the future.`);
160
+ } else if (isPasskeyReenrollment) { // Current state doesn't matter. User has specified a new passkey is to be added...
161
+ if (!enrollmentDetails.zsmCredentialID && !enrollmentDetails.hasLocalPasskey) // ... so, unless the user doesn't have credentials AT ALL...
162
+ return new Error(`[FIDO2Client] :: webauthnCreate :: Cannot RE-enroll user's passkey; user ${userIdentifier} is not currently enrolled with either ZSM or Passkeys+ credentials. To enroll the user, call webauthnCreate with a userVerified value of "preferred", "required", or "discouraged".`);
163
+ pkpEnrollmentResult = await this.webauthnPasskeyPlusCreate(enrollmentDetails, 'required'); // ... we trigger the Passkeys+ enrollment flow. Failure mode behavior is static: either passkey works or boot them entirely.
164
+
165
+ } else if (enrollmentDetails.zsmCredentialID && enrollmentDetails.hasLocalPasskey) { // ZSM: Y | PKP: Y | Remote PKP: Y - User already fully-registered; fail the operation.
166
+ return new Error(`[FIDO2Client] :: webauthnCreate :: User ${userIdentifier} is already enrolled with both ZSM and Passkeys+ credentials. To authenticate one or both credential, call the webauthnGet method.`);
167
+
168
+ } else if ((enrollmentDetails instanceof Error || enrollmentDetails === false) ||
169
+ (!enrollmentDetails.zsmCredentialID && !enrollmentDetails.hasLocalPasskey)) { // ZSM: N | PKP: N | Remote PKP: N - Dual enrollment for both ZSM and PKP...
170
+ pkpEnrollmentResult = await this.webauthnPasskeyPlusCreate(enrollmentDetails, userVerified); // ...so we attempt Passkeys+ enrollment. Failure mode behavior is dictated by userVerified
171
+
172
+ } else if (enrollmentDetails.zsmCredentialID && !enrollmentDetails.hasLocalPasskey) { // ZSM: Y | PKP: N | Remote PKP: N - User has ZSM only...
173
+ pkpEnrollmentResult = await this.webauthnBindPasskeyToZSM(enrollmentDetails, userVerified); // ...so we attempt to add Passkeys+ to existing ZSM credential. Failure mode behavior is dictated by userVerified
174
+
175
+ } else if (!enrollmentDetails.zsmCredentialID && enrollmentDetails.pkpCredential) { // ZSM: N | PKP: Y | Remote PKP: Y - User ONLY has a (remote) passkey (which the checkAllEnrollments bootstrapped down to local)...
176
+ pkpEnrollmentResult = await this.webauthnBindZSMToPasskey(enrollmentDetails, userVerified); // ...so we attempt to add ZSM credential to existing Passkeys+ credential. Failure mode behavior is dictated by userVerified
177
+ }
178
+ return (pkpEnrollmentResult === undefined)
179
+ ? new Error(`[FIDO2Client] :: webauthnCreate :: Unexpected PKP flow: PKP enrollment attempted but no result was returned for user ${userIdentifier}.`)
180
+ : pkpEnrollmentResult;
181
+ //TODO: Determine the cause of the passkey failure and tailor the below error message accordingly (currently only handles NotAllowedError: user cancellation/timeout/failure).
182
+ } catch (pkpError) { // If the passkey portion of the operation fails (for ANY reason)...
183
+ console.error('ENROLL FLOW :: PASSKEY OPERATION FAILED ::', pkpError);
184
+ if (!continueOnPasskeyFailure) // ...and the userVerified is "required", return an error to fail the entire enrollment operation...
185
+ return new Error(`[FIDO2Client] :: webauthnCreate :: The passkey portion of the Passkeys+ enrollment failed for user ${userIdentifier} with error: ${pkpError.message || pkpError}. Since userVerified is set to "${userVerified}", the entire enrollment process has failed. You may: retry, downgrade the userVerified mandate to "preferred" or "discouraged" to allow for ZSM-only enrollment.`);
186
+ }
187
+ }
188
+
189
+ // The code below handles ZSM-only enrollments, as well as providing the "fallback-to-ZSM-only" behavior for acceptable Passkeys+ failures (e.g. userVerified === "preferred")
190
+ let localZSMCredentialID;
191
+ if(enrollmentDetails?.zsmCredentialID) localZSMCredentialID = enrollmentDetails?.zsmCredentialID // No sense in hitting the ZSM credential check if we already have the enrollment details from the pkp section, above...
192
+ else localZSMCredentialID = await this.checkEnrollment(userIdentifier); // ...but if we didn't, let's check the local-only operation for the credential ID
193
+
194
+ if(typeof localZSMCredentialID === 'string') { // If the user is ALREADY enrolled with a ZSM credential, fail the operation
195
+ return new Error(`[FIDO2Client] :: webauthnCreate :: User ${userIdentifier} is already enrolled with a ZSM credential. To authenticate it, call the webauthnGet method.`);
93
196
  }
197
+
198
+ await this.zsmAPI.checkIdentity(userIdentifier, true); // [J] This checkIdentity call "primes" the enrollment on the server & sets credentialID/userIdentifier on the zsmAPI instance
199
+ const createResult = await this.zsmAPI.webauthnCreate(userIdentifier); // Perform the vanilla ZSM enrollment.
200
+ if(createResult instanceof Error) // Should the operation fail...
201
+ return new Error(`[FIDO2Client] :: webauthnCreate :: Unable to enroll ${userIdentifier}!\nDetails:\n${createResult?.message || createResult}`, {cause: createResult?.stack});
202
+ return createResult; // ...otherwise, all operations either succeeded or failed acceptably, so we return the result of the ZSM enrollment
94
203
  }
95
204
 
96
205
  /**
97
206
  * @name webauthnGet
98
207
  * @description Retrieves the ZSM credential for the specified user
99
208
  * @param {string} userIdentifier The identifier for the user
209
+ * @param {string} userVerified The level of user verification required for authentication (true/"preferred", false/"prevented", "required", "discouraged")
100
210
  * @returns {Promise<Object>} The ZSM credential for the user
101
211
  * @throws {Error} If the userIdentifier is not provided or is empty
102
212
  * @throws {Error} If an error occurs during credential retrieval
103
213
  * @memberOf FIDO2ClientBase
104
214
  */
105
- async webauthnGet (userIdentifier) {
106
-
107
- try {
108
- if(userIdentifier == null
109
- || userIdentifier === '') throw new Error(`[FIDO2Client] :: webauthnGet :: A userIdentifier String is required! Received: ${userIdentifier}.`);
110
- let authCredential = await this.zsmAPI.webauthnGet(userIdentifier);
111
- return authCredential?.credential??authCredential;
112
- }catch(e) {
113
- return new Error(e.message || e || '[FIDO2Client] :: webauthnGet :: An error occurred during credential retrieval.');
215
+ async webauthnGet (userIdentifier, userVerified="prevented") { // [J] This method has been merged with the PKP Plugin's __webauthnGet method
216
+ ({ userIdentifier, userVerified } = this.typeChecks('webauthnGet', userIdentifier, userVerified, 'zsm'));
217
+
218
+
219
+ const attemptPasskeyPlusAuth = /^(preferred|required)$/.test(userVerified); // [J] Does the userVerified value indicate that Passkeys+ authentication should be attempted alongside ZSM authentication?
220
+ const continueOnPasskeyFailure = (userVerified === 'preferred');
221
+
222
+ // Passkeys+ Operations
223
+ let pkpAuthResult, enrollmentDetails;
224
+ if(attemptPasskeyPlusAuth) {
225
+ enrollmentDetails = await this.checkAllEnrollments(userIdentifier);
226
+
227
+ if(!enrollmentDetails.zsmCredentialID) { // [J] Field names updated to match cleanFinalData
228
+
229
+ return new Error(`[FIDO2Client] :: webauthnGet :: User ${userIdentifier} must be enrolled with a ZSM credential before authenticating. Please enroll the user first.`);
230
+ } else if (!enrollmentDetails.passkeySupported) {
231
+
232
+ return new Error(`[FIDO2Client] :: webauthnGet :: User ${userIdentifier} is not eligible for Passkeys+ authentication; their device does not support passkey authentication. To authenticate, call the webauthnGet method with userVerified set to "preferred", "discouraged", or "prevented"/"zsm". To enroll with Passkeys+, call webauthnCreate with userVerified set to "preferred" or "required" on a Passkeys+-compatible device.`);
233
+ } else if(!enrollmentDetails.hasLocalPasskey) {
234
+
235
+ return new Error(`[FIDO2Client] :: webauthnGet :: User ${userIdentifier} must be enrolled with both ZSM and Passkeys+ credentials to authenticate using Passkeys+ (userVerified set to a non-prevented value).`);
236
+ } else if(attemptPasskeyPlusAuth) {
237
+
238
+ try {
239
+ pkpAuthResult = await this.webauthnPasskeyPlusGet(enrollmentDetails, userVerified);
240
+
241
+ } catch (pkpError) {
242
+ if (!continueOnPasskeyFailure) return new Error(`[FIDO2Client] :: webauthnGet :: The passkey portion of the Passkeys+ authentication failed for user ${userIdentifier} with error: ${pkpError.message || pkpError}. Since userVerified is set to "${userVerified}", the entire authentication process has failed. You may: retry or downgrade the userVerified mandate to "preferred" or "discouraged" to allow for ZSM-only authentication.`);
243
+ }
244
+ return (pkpAuthResult === undefined)
245
+ ? new Error(`[FIDO2Client] :: webauthnGet :: Unexpected PKP flow: PKP authentication attempted but no result was returned for user ${userIdentifier}.`)
246
+ : pkpAuthResult;
247
+ }
114
248
  }
249
+
250
+ // The code below handles ZSM-only authentications, as well as providing the "fallback-to-ZSM-only" behavior for acceptable Passkeys+ failures (e.g. userVerified === "preferred")
251
+ let localZSMCredentialID;
252
+ if(enrollmentDetails?.zsmCredentialID) localZSMCredentialID = enrollmentDetails?.zsmCredentialID // No sense in hitting the ZSM credential check if we already have the enrollment details from the pkp section, above...
253
+ else localZSMCredentialID = await this.checkEnrollment(userIdentifier); // ...but if we didn't, let's check the local-only operation for the credential ID
254
+
255
+ const userIsEnrolled = typeof localZSMCredentialID === 'string';
256
+ if(userIsEnrolled === false) return new Error(`[FIDO2Client] :: webauthnGet :: ${userIdentifier} is not enrolled. To authenticate the user, please enroll them first by calling the webauthnCreate method.`);
257
+
258
+ const authCredential = await this.zsmAPI.webauthnPartialGet(userIdentifier);
259
+ return authCredential?.credential??authCredential;
260
+ }
261
+
262
+ //! PASSKEYS PLUS FIDO2 CLIENT METHODS ==============================================================================================================================
263
+ // [J] These were migrated from PKP Plugin with minimal adjustments to fit into the FIDO2ClientBase class structure and flow
264
+
265
+ /**
266
+ * @name webauthnPasskeyPlusCreate
267
+ * @description Creates a Passkeys+ credential for the user, then creates a ZSM credential
268
+ * @param {string} userIdentifier The identifier for the user
269
+ * @param {string} userVerified Accepts "required", "preferred", "discouraged", to be passed into the Passkey ceremony
270
+ * @returns {Promise<Object>} The results of the call to WebAuthnClient's pkpCreate method or an Error
271
+ * @throws {Error} If the userIdentifier is not provided or is empty
272
+ * @throws {Error} If an error occurs during Passkeys+ creation
273
+ * @memberOf FIDO2ClientBase
274
+ */
275
+ async webauthnPasskeyPlusCreate (userIdentifier, userVerified="preferred") {
276
+ ({ userIdentifier, userVerified } = this.typeChecks('webauthnPasskeyPlusCreate', userIdentifier, userVerified, 're-enroll,reenroll')); // [J] Replaced manual check with typeChecks
277
+
278
+ const pkpCreateResult = await this.zsmAPI.pkpCreate(userIdentifier, userVerified);
279
+
280
+ return pkpCreateResult;
281
+ }
282
+
283
+ /**
284
+ * @name webauthnPasskeyPlusGet
285
+ * @description Authenticates a ZSM credential on the device for the specified user
286
+ * @param {string} userIdentifier The identifier for the user
287
+ * @param {string} userVerified Accepts "required", "preferred", "discouraged", to be passed into the Passkey ceremony
288
+ * @returns {Promise<Object>} The results of the call to WebAuthnClient's pkpAuthenticate method or an Error
289
+ * @throws {Error} If the userIdentifier is not provided or is empty
290
+ * @throws {Error} If an error occurs during Passkeys+ authentication
291
+ * @memberOf FIDO2ClientBase
292
+ */
293
+ async webauthnPasskeyPlusGet (userIdentifier, userVerified="preferred") {
294
+
295
+ let enrollmentDetails = null;
296
+ ({ userIdentifier, userVerified, enrollmentDetails } = this.typeChecks('webauthnPasskeyPlusGet', userIdentifier, userVerified, 're-enroll,reenroll'));
297
+ if(!enrollmentDetails) enrollmentDetails = await this.checkAllEnrollments(userIdentifier, true);
298
+
299
+ const pkpAuthResult = await this.zsmAPI.pkpAuthenticate(enrollmentDetails, userVerified, !enrollmentDetails.zsmCredentialID); // [J] Field name updated to match cleanFinalData
300
+ return pkpAuthResult;
301
+ }
302
+
303
+ /**
304
+ * @name webauthnBindZSMToPasskey
305
+ * @description Adds a ZSM credential for a user who already has Passkeys+ credentials registered on another device.
306
+ * @param {string} userIdentifier The identifier for the user.
307
+ * @returns {Promise<Object>} Completed ZSM + Passkeys+ authentication result.
308
+ * @throws {Error} If no Passkeys+ credentials are found for the user on another device.
309
+ * @memberOf FIDO2ClientBase
310
+ */
311
+ async webauthnBindZSMToPasskey (userIdentifier, userVerified) {
312
+ ({ userIdentifier } = this.typeChecks('webauthnBindZSMToPasskey', userIdentifier));
313
+
314
+ const passkeyPreauth = await this.webauthnPasskeyPlusGet(userIdentifier);
315
+ return passkeyPreauth;
316
+ }
317
+
318
+ /**
319
+ * @name webauthnBindPasskeyToZSM
320
+ * @description Adds a Passkeys+ credential for a user who already has a local ZSM credential.
321
+ * @param {string} userIdentifier The identifier for the user.
322
+ * @param {string} userVerified Accepts "required", "preferred", "discouraged", to be passed into the Passkey ceremony.
323
+ * @returns {Promise<Object>} Completed ZSM + Passkeys+ authentication result.
324
+ * @throws {Error} If no ZSM credentials are found for the user on another device.
325
+ * @memberOf FIDO2ClientBase
326
+ */
327
+ async webauthnBindPasskeyToZSM (userIdentifier, userVerified="preferred") {
328
+ let enrollmentDetails = null;
329
+ ({ userIdentifier, userVerified, enrollmentDetails } = this.typeChecks('webauthnBindPasskeyToZSM', userIdentifier, userVerified, 're-enroll,reenroll')); // [J] Replaced manual check with typeChecks
330
+
331
+ const pkpAddPasskeyResult = await this.zsmAPI.pkpAddPasskeyCredential((enrollmentDetails || userIdentifier), userVerified);
332
+
333
+ return pkpAddPasskeyResult;
115
334
  }
116
335
  }
117
336
 
package/PKPUtils.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ @name decodeAttestationObject
3
+ @description Decodes a CBOR-encoded attestation object buffer into a JavaScript object (CBOR map of 3 entries (0xA3)) with properties 'fmt', 'attStmt', and 'authData'.
4
+ @param {ArrayBuffer} buffer The CBOR-encoded attestation object.
5
+ @returns {Object} The decoded attestation object with properties 'fmt', 'attStmt', and 'authData'.
6
+ @throws {Error} If the first byte does not indicate a CBOR map of 3 entries.
7
+ @throws {Error} If the major type of a value is unsupported.
8
+ @throws {Error} If the input buffer is not a valid CBOR-encoded attestation object.
9
+ */
10
+ function decodeAttestationObject(buffer) {
11
+ try {
12
+ if (!buffer) // IF the buffer argument is missing...
13
+ throw new TypeError("Invalid input"); // ...throw an error
14
+
15
+ if (buffer instanceof Uint8Array) // IF the buffer argument is a Uint8Array...
16
+ buffer = buffer.buffer; // ...convert it to an ArrayBuffer
17
+ if (!(buffer instanceof ArrayBuffer)) // IF the buffer argument is (STILL) not an ArrayBuffer...
18
+ throw new TypeError("Invalid input"); // ...throw an error
19
+
20
+ if (!buffer || !(buffer instanceof ArrayBuffer)) // IF the buffer argument is missing or not an ArrayBuffer...
21
+ throw new TypeError("Invalid input"); // ...throw an error
22
+ const view = new DataView(buffer); // Create a DataView to read the buffer argument (CBOR is a binary format)
23
+ const attObj = {}; // Create an object to hold the decoded attestation object
24
+ let offset = 0; // Create an offset counter to track our position in the buffer
25
+
26
+ // Expect a CBOR map of 3 entries (0xA3)
27
+ const type = view.getUint8(offset++); // Read the first byte to determine the major type
28
+ if (type !== 0xA3) throw new Error("Unexpected format"); // Since we expect a map with 3 key-value pairs, throw an error if not found
29
+
30
+ for (let i = 0; i < 3; i++) { // Iterate over the 3 key-value pairs...
31
+ const keyLen = view.getUint8(offset++) - 0x60; // Read the length of the key (CBOR short string)
32
+
33
+ const key = new TextDecoder() // Isolate out the key by initializing a TextDecoder...
34
+ .decode(new Uint8Array(buffer, offset, keyLen)); // ...and decoding the buffered CBOR short string into it
35
+
36
+ offset += keyLen; // Move offset past the key by the length of the key
37
+
38
+ const majorType = view.getUint8(offset); // Read the major type of the value
39
+
40
+ offset++; // Move past the major type byte
41
+
42
+ if (majorType === 0x58) { // IF the major type's value is a byte string with length in next byte...
43
+ const len = view.getUint8(offset++); // ...read the length of the byte string...
44
+ attObj[key] = new Uint8Array(buffer, offset, len); // ...extract the byte string...
45
+ offset += len; // ...and increment offset by length.
46
+ } else if (majorType >= 0x60 && majorType < 0x78) { // OTHERWISE, if major type's value is a short string (for 'fmt')
47
+ const len = majorType - 0x60; // ...determine length from major type...
48
+ attObj[key] = new TextDecoder() // ...and initialize a TextDecoder...
49
+ .decode(new Uint8Array(buffer, offset, len)); // ...decoding the buffered CBOR short string into it
50
+ offset += len; // ...finally, increment offset by length
51
+ } else if (majorType === 0xA0) { // OTHERWISE, if major type's value is an empty map
52
+ attObj[key] = {}; // ...and assign an empty object
53
+ } else throw new Error("Unsupported type"); // OTHERWISE, throw an error
54
+ }
55
+ return attObj; // return the decoded attestation object
56
+ } catch (e) {
57
+ throw new Error("[Utils] :: decodeAttestationObject :: Failed to decode attestation object: " + e.message);
58
+ }
59
+ }
60
+
61
+ export { decodeAttestationObject };
@@ -0,0 +1,120 @@
1
+ import eventCoordinator from './EventCoordinator.js';
2
+
3
+ class PasskeysPlusClient {
4
+ constructor() {
5
+ eventCoordinator.update('PasskeysPlus');
6
+ this.passkeyAPI = (navigator?.credentials??null) || null;
7
+ eventCoordinator.update('PasskeysPlus', 'READY');
8
+ }
9
+
10
+ /**
11
+ * @name pkpSupported
12
+ * @description Checks if the PasskeysPlus API is supported in the current browser.
13
+ * @returns {boolean} True if supported, false otherwise.
14
+ */
15
+ pkpSupported = () => !!this.passkeyAPI && typeof this.passkeyAPI.create === 'function' && typeof this.passkeyAPI.get === 'function';
16
+
17
+ /**
18
+ * @name blockUnsupportedInvocation
19
+ * @description Throws an error if the PasskeysPlus API is not supported in the current browser.
20
+ * @returns {boolean} True if the invocation is supported, false otherwise.
21
+ * @throws {Error} If the PasskeysPlus API is not supported.
22
+ */
23
+ blockUnsupportedInvocation = () => {
24
+ if(!this.pkpSupported()) throw new Error(`[PK+ PasskeysPlus] :: blockUnsupportedInvocation :: This browser and/or device does not support the PasskeysPlus API!`);
25
+ return true;
26
+ }
27
+
28
+ /**
29
+ * @name b64ToArrayBuffer
30
+ * @description Converts a base64url-encoded string to an ArrayBuffer.
31
+ * @param {string} b64 The base64url-encoded string.
32
+ * @returns {ArrayBuffer} The resulting ArrayBuffer.
33
+ * @memberof PasskeysPlus
34
+ */
35
+ toArrayBuffer (b64) {
36
+ if (typeof b64 !== 'string' || !/^[A-Za-z0-9\-_]+$/.test(b64)) throw new Error(`[PK+ PasskeysPlus] :: toArrayBuffer :: Invalid base64url string provided: ${b64}`);
37
+ try {
38
+ const str = atob(b64.replace(/-/g, '+').replace(/_/g, '/') + ('===='.slice(0, -b64.length % 4)))
39
+ const bytes = new Uint8Array(str.length);
40
+ for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i);
41
+ return bytes.buffer;
42
+ } catch (e) {
43
+ throw new Error(`[PK+ PasskeysPlus] :: toArrayBuffer :: Error converting base64url string to ArrayBuffer: ${e.message || e}`);
44
+ }
45
+ };
46
+
47
+ /**
48
+ * @name pkpCreatePasskeyCredential
49
+ * @description Creates a passkey credential based on the provided public key.
50
+ * @param {Object} publicKey The public key options for the passkey credential.
51
+ * @param {string} userVerification Accepts "required", "preferred", "discouraged", to be passed into the Passkey ceremony.
52
+ * @returns {Promise<Credential>} Resolves with the created passkey credential.
53
+ * @throws {Error} The publicKey parameter was not provided or it was not an object.
54
+ * @throws {Error} The structure of the publicKey parameter is invalid.
55
+ * @throws {Error} The publicKey parameter's user ID was not an ArrayBuffer.
56
+ * @throws {Error} The publicKey parameter's pubKeyCredParams was not an Array, was empty, or contained invalid entries.
57
+ * @throws {Error} The publicKey parameter's authenticatorSelection was not an object (if provided).
58
+ * @throws {Error} The publicKey parameter's authenticatorSelection.authenticatorAttachment was missing or not a valid value.
59
+ * @throws {Error} The publicKey parameter's authenticatorSelection.requireResidentKey was missing or not a boolean.
60
+ * @throws {Error} The publicKey parameter's authenticatorSelection.userVerification was missing or not a valid value.
61
+ * @throws {Error} The publicKey parameter's timeout (if provided) was missing or not a positive number.
62
+ * @throws {Error} The publicKey parameter's excludeCredentials must be an array and cannot be empty (if provided).
63
+ * @throws {Error} The publicKey parameter's excludeCredentials (if provided) contained an entry that was not an ArrayBuffer.
64
+ * @memberof PasskeysPlus
65
+ */
66
+ pkpCreatePasskeyCredential = async (publicKey, userVerification="preferred") => {
67
+ if(!this.blockUnsupportedInvocation()) return false;
68
+ if(publicKey?.authenticatorSelection) publicKey.authenticatorSelection.userVerification = userVerification;
69
+ if(publicKey?.extensions?.credentialProtectionPolicy) delete publicKey.extensions.credentialProtectionPolicy;
70
+ if(!publicKey || typeof publicKey !== 'object') throw new Error(`[PK+ PasskeysPlus] :: pkpCreatePasskeyCredential :: Invalid publicKey parameter provided: ${publicKey}! It must be an object. Received: ${publicKey}`);
71
+ if(!publicKey?.challenge || !publicKey?.rp || !publicKey?.rp?.id || !publicKey?.rp?.name) throw new Error(`[PK+ PasskeysPlus] :: pkpCreatePasskeyCredential :: Invalid publicKey structure! It must contain challenge and rp properties with id and name. Received: ${publicKey}`);
72
+ if(!publicKey?.user || !publicKey?.user?.id || !(publicKey?.user?.id instanceof ArrayBuffer)) throw new Error(`[PK+ PasskeysPlus] :: pkpCreatePasskeyCredential :: Invalid user ID in publicKey! It must be an ArrayBuffer. Received: ${publicKey?.user?.id}`);
73
+ if(!publicKey?.pubKeyCredParams || !Array.isArray(publicKey?.pubKeyCredParams)
74
+ || publicKey?.pubKeyCredParams?.length === 0) throw new Error(`[PK+ PasskeysPlus] :: pkpCreatePasskeyCredential :: pubKeyCredParams must be a non-empty array of public key credential parameters. Received: ${publicKey?.pubKeyCredParams}`);
75
+ if(!publicKey?.authenticatorSelection || typeof publicKey?.authenticatorSelection !== 'object') throw new Error(`[PK+ PasskeysPlus] :: pkpCreatePasskeyCredential :: Invalid authenticatorSelection in publicKey! It must be an object. Received: ${publicKey?.authenticatorSelection} (${typeof publicKey?.authenticatorSelection})`);
76
+ if(publicKey?.authenticatorSelection?.authenticatorAttachment
77
+ && !['platform', 'cross-platform'].includes(publicKey?.authenticatorSelection?.authenticatorAttachment)) throw new Error(`[PK+ PasskeysPlus] :: pkpCreatePasskeyCredential :: Invalid authenticatorAttachment in authenticatorSelection! It must be either "platform" or "cross-platform". Received: ${publicKey?.authenticatorSelection?.authenticatorAttachment}`);
78
+ if(publicKey?.authenticatorSelection?.requireResidentKey
79
+ && typeof publicKey?.authenticatorSelection?.requireResidentKey !== 'boolean') throw new Error(`[PK+ PasskeysPlus] :: pkpCreatePasskeyCredential :: Invalid requireResidentKey in authenticatorSelection! It must be a boolean. Received: ${publicKey?.authenticatorSelection?.requireResidentKey} (${typeof publicKey?.authenticatorSelection?.requireResidentKey})`);
80
+ if(publicKey?.authenticatorSelection?.userVerification
81
+ && !['required', 'preferred', 'discouraged'].includes(publicKey?.authenticatorSelection?.userVerification)) throw new Error(`[PK+ PasskeysPlus] :: pkpCreatePasskeyCredential :: Invalid userVerification in authenticatorSelection! It must be one of "required", "preferred", or "discouraged". Received: ${publicKey?.authenticatorSelection?.userVerification}`);
82
+ if(!publicKey?.timeout || typeof publicKey?.timeout !== 'number' || publicKey?.timeout <= 0) throw new Error(`[PK+ PasskeysPlus] :: pkpCreatePasskeyCredential :: Invalid timeout in publicKey! It must be a positive number. Received: ${publicKey?.timeout} (${typeof publicKey?.timeout})`);
83
+ if(publicKey?.excludeCredentials && (!Array.isArray(publicKey?.excludeCredentials)
84
+ || publicKey?.excludeCredentials?.length > 0 && !publicKey?.excludeCredentials[0].id)) throw new Error(`[PK+ PasskeysPlus] :: pkpCreatePasskeyCredential :: Invalid credential ID in excludeCredentials! It must be an ArrayBuffer. Received: ${publicKey?.excludeCredentials[0]?.id}`);
85
+
86
+ const pkpCredential = await this.passkeyAPI.create({ publicKey: {...publicKey, userVerification} });
87
+
88
+ return pkpCredential;
89
+ }
90
+
91
+ /**
92
+ * @name pkpGetPasskeyCredential
93
+ * @description Retrieves a passkey credential based on the provided public key.
94
+ * @param {Object} publicKey The public key options for the passkey credential.
95
+ * @param {string} userVerification Accepts "required", "preferred", "discouraged", to be passed into the Passkey ceremony.
96
+ * @returns {Promise<Credential>} Resolves with the passkey credential.
97
+ * @throws {Error} The publicKey parameter was not provided or it was not an object.
98
+ * @throws {Error} The structure of the publicKey parameter is invalid.
99
+ * @throws {Error} The publicKey parameter's allowCredentials was not an array or was empty.
100
+ * @throws {Error} The publicKey parameter's allowCredentials did not contain an ArrayBuffer.
101
+ * @throws {Error} The publicKey parameter's user ID was missing or was not an ArrayBuffer.
102
+ * @throws {Error} The publicKey parameter's rp was missing or was missing name and/or id.
103
+ * @memberof PasskeysPlus
104
+ */
105
+ pkpGetPasskeyCredential = async (publicKey, userVerification="preferred") => {
106
+ if(!this.blockUnsupportedInvocation()) return false;
107
+ if(publicKey?.authenticatorSelection) publicKey.authenticatorSelection.userVerification = userVerification;
108
+ if(!publicKey || typeof publicKey !== 'object') throw new Error(`[PK+ PasskeysPlus] :: pkpGetPasskeyCredential :: Invalid publicKey parameter provided!`);
109
+ if(!publicKey?.challenge || !publicKey?.allowCredentials
110
+ || !Array.isArray(publicKey?.allowCredentials)) throw new Error(`[PK+ PasskeysPlus] :: pkpGetPasskeyCredential :: Invalid publicKey structure provided! It must contain challenge and allowCredentials properties. Received: ${publicKey}`);
111
+ if(publicKey?.allowCredentials?.length === 0) throw new Error(`[PK+ PasskeysPlus] :: pkpGetPasskeyCredential :: allowCredentials array must not be empty! It should contain at least one credential ID. Received: ${publicKey?.allowCredentials}`);
112
+ const allIDsValid = publicKey?.allowCredentials.reduce((result, record)=>result && record?.id && (record.id instanceof ArrayBuffer || ArrayBuffer.isView(record.id)), true);
113
+ if(!allIDsValid) throw new Error(`[PK+ PasskeysPlus] :: pkpGetPasskeyCredential :: Invalid credential ID in allowCredentials! It must be an ArrayBuffer. Received: ${publicKey?.allowCredentials[0]?.id}`);
114
+ const pkpCredential = await this.passkeyAPI.get({ publicKey: {...publicKey, userVerification} });
115
+
116
+ return pkpCredential;
117
+ }
118
+ }
119
+
120
+ export default PasskeysPlusClient;
package/PluginManager.js CHANGED
@@ -80,7 +80,7 @@ class PluginManager {
80
80
 
81
81
  const constructedMap = this.seedClassMap(core); // returns Map
82
82
 
83
- // [PASSKEYS+ PLUGIN PACKAGE] If the "PASSKEYS+" plugin is registered, supercede the core classes with the passkeys classes
83
+ // [PASSKEYS+ PLUGIN PACKAGE] If the "PASSKEYS+" plugin is registered, supersede the core classes with the passkeys classes
84
84
  if(this._classCorpus.has('PASSKEYS+')) {
85
85
  const passkeysMap = this._classCorpus.get('PASSKEYS+');
86
86
  if (passkeysMap?.classDefs) for (const [k,v] of Object.entries(passkeysMap.classDefs)) constructedMap.set(k.toUpperCase(), v);