@ideem/zsm-client-sdk
Version:
ZSM makes 2FA easy and invisible for everyone, all the time, using advanced cryptography like MPC to establish cryptographic proof of the origin of any transaction or login attempt, while eliminating opportunities for social engineering. ZSM has no relian
430 lines (345 loc) • 25.6 kB
JavaScript
import eventCoordinator from './EventCoordinator.js';
import GLOBAL from './GlobalScoping.js';
import { uid2iid, iid4uid } from './IdentityIndexing.js';
import { toObject } from './Utils.js';
import {zsmPluginManager} from './PluginManager.js';
eventCoordinator.update('WebAuthnClientBase');
const tracePrimer = (obj=false) => {
const traceBase = [[new Date().toISOString(), "TRACEPRIMER"]]
return (obj) ? {"trace": traceBase} : traceBase;
}
class WebAuthnClientBase {
/**
* Constructs a WebAuthnClientBase object.
* @param {Object} config - The configuration for ZSM initialization.
*/
constructor(config) {
// console.log('config :', Object.assign({}, config));
// Helper function to standardize URL format (ensuring it's parsable and ends with a '/')
const cleanURL = (url) => URL.canParse(url) ? new URL(url).origin + '/' : false;
const baseConfig = {
"application_id" : "",
"api_key" : "",
"host_url" : "https://zsm-authenticator-demo.useideem.com/",
"installation_description" : (typeof navigator !== 'undefined' ? navigator?.userAgent : 'No Agent Found'),
"log_level" : 'Debug',
"request_timeout_ms" : 300000,
"retry_count" : 0,
"consumer_id" : ""
};
this.zsmApi = undefined; // The ZSM API instance (read: WASM-driven Crypto Module)
this.relyingParty = undefined; // The Relying Party instance (read: ZSM Authenticator API)
this.status = undefined; // The status of the WebAuthnClientBase
this.config = undefined; // The configuration object passed into the constructor
// If the inputted host_url is not a valid URL, null it for future checks' sake.
config.host_url = cleanURL(config.host_url) || null;
// If the inputted host_url is not a valid URL, but a 'host' is provided instead, construct the host_url.
if(config.host_url == null && config.host != null){
config.host_url = cleanURL(config.host) || `https://zsm-authenticator-${config.host}.useideem.com/`;
}
// If, after all that, the host_url is STILL not a valid URL, destroy it, and let the baseConfig address it.
if(!cleanURL(config.host_url)) delete config.host_url;
config = Object.assign(baseConfig, config);
if(!config?.authenticator_host_url) config.authenticator_host = config.authenticator_host_url = config.host_url;
if(!config?.zsm_host_url) config.zsm_host_url = config.zsm_host = config.host_url.replace("zsm-authenticator", "zsm");
config.host_url = config.zsm_host_url;
this.config = GLOBAL.zsmAppConfig = config;
iid4uid(this.config.consumer_id)
.then(v=>{
GLOBAL.mpcConfig = Object.assign({}, config, {consumer_id: v});
if((GLOBAL.mpcConfig?.use_passkeys??undefined)) {
GLOBAL.mpcConfig.use_origin = true;
delete GLOBAL.mpcConfig.use_passkeys;
}
this.MPCConfig = Object.assign({}, GLOBAL.mpcConfig);
this.zsmApi = this.primeZsmApi();
const relyingParty = zsmPluginManager.classes('RELYINGPARTY');
this.relyingParty = new relyingParty(this.config.authenticator_host_url, this.config.api_key, this.config.application_id, this.config.application_environment, (this.config?.use_passkeys??undefined));
this.userIdentifier = config.consumer_id;
})
.then(()=> GLOBAL.dispatchEvent(new CustomEvent("WebAuthnClientReady", {bubbles: true, cancelable: false, detail: true })))
.catch(error => console.error('Error during initialization:', error))
.finally(() => eventCoordinator.update('WebAuthnClientBase', 'READY'));
}
get userIdentifier() { return this.relyingParty.customerDefinedIdentifier; }
set userIdentifier(v) { return this.relyingParty.customerDefinedIdentifier = v; }
get customerDefinedIdentifier() { return this.relyingParty.customerDefinedIdentifier; }
set customerDefinedIdentifier(v) { return this.relyingParty.customerDefinedIdentifier = v; }
get token() { return this.relyingParty.token; }
get credentialID() { return this.relyingParty.credentialID; }
set credentialID(v) { return this.relyingParty.credentialID = v; }
get isEnrolled() { return !!this.credentialID }
// get version() { return "ZSM WebAuthn client"; }
/**
* @name getZsmApi
* @description Retrieves the ZSM API instance. Note that this function is overridden in the WebAuthnClient class as it differs from mobile to react-native to browser.
* @param {Object} config The configuration object for ZSM initialization.
* @returns {Promise<ZSMAPI>} Resolves with the initialized ZSM API.
*/
async getZsmApi (config) {}
/**
* @name primeZsmApi
* @description Initializes the ZSM API with the provided configuration. Used specifically on the load of the worker
* @ to ensure the ZSM API is already primed and initialized (and thus only needs to be reconfigured) when the
* @ by the initializeZsm thereafter. Fixes several potential race conditions and improves performance.
* @returns {Promise<ZSMAPI>} Resolves with the initialized ZSM API.
*/
primeZsmApi = async () => (!this.zsmApi) ? (this.zsmApi = await this.getZsmApi(Object.assign({}, GLOBAL.mpcConfig, {consumer_id: await iid4uid(GLOBAL.mpcConfig.consumer_id)}))) : this.zsmApi;
/**
* @name initializeZsm
* @description Reconfigures the ZSM API, should the consumer_id change (as after an identity_id is obtained)
* @returns {Promise<ZSMAPI>} Resolves when the ZSM API is reconfigured.
*/
async initializeZsm () {
if(GLOBAL.mpcConfig.consumer_id !== this.MPCConfig.consumer_id) {
// console.log('GLOBAL.mpcConfig :', Object.assign({},GLOBAL.mpcConfig));
await this.zsmApi.configure(GLOBAL.mpcConfig);
this.MPCConfig = await Object.assign({}, GLOBAL.mpcConfig);
await uid2iid(this.config.consumer_id, this.MPCConfig.consumer_id);
}
return await this.zsmApi;
}
/**
* @name checkIdentity
* @description Checks if a user has an identity_id and creates a new one if specified.
* @param {string} userIdentifier The userIdentifier.
* @param {boolean} primeEnroll Indicates the operation is a precursor to enrollment.
* @returns {Promise<String>} Resolves with the retrieved identity_id, if it exists.
* @throws {Error} Throws an error if unable to communicate with the identity server.
* @throws {Error} Throws an error if unable to acquire identity_id.
*/
async checkIdentity (userIdentifier, primeEnroll=false) {
try {
this.status='CHECKING_IDENTITY_STARTING';
const resultOfCheck = await this.relyingParty.checkServerSideIdentity(primeEnroll);
if(!resultOfCheck) throw new Error('Unable to communicate with identity server!');
const identityID = resultOfCheck.identity_id;
if(!identityID) throw new Error('Unable to acquire identity_id!');
uid2iid(userIdentifier, identityID);
GLOBAL.mpcConfig.consumer_id = identityID;
this.status = 'CHECKING_IDENTITY_FINISHED';
return Promise.resolve(identityID);
} catch(err) {
this.status = 'CHECKING_IDENTITY_FAILED';
return Promise.reject(err);
}
}
/**
* @name webauthnCreate
* @description Creates a new WebAuthn credential for the specified userIdentifier.
* @param {string} userIdentifier The user identifier.
* @returns {Promise<Object>} Resolves with the created credential.
* @throws {Error} Throws an error if unable to start enrollment with the Relying Party Server.
* @throws {Error} Throws an error if unable to retrieve the public key/IdentityID from the Relying Party Server.
* @throws {Error} Throws an error if unable to obtain the createResponse/credential from the Crypto Server.
* @throws {Error} Throws an error if unable to verify the credential with the Relying Party Server.
* @throws {Error} Throws an error if unable to acquire the identity ID from the Relying Party Server.
* @throws {Error} Throws an error if the user ID or token is missing from the response body.
*/
async webauthnCreate (userIdentifier) {
this.status = 'ENROLLMENT_STARTING';
await this.initializeZsm('webauthnCreate');
const publicKey = await this.relyingParty.registrationStart();
if(!publicKey) throw new Error('[WebAuthnClient] :: webauthnCreate :: Unable to retrieve public key from Relying Party Server!');
let createResponse = await this.zsmApi.webauthn_create(publicKey, tracePrimer());
if(!createResponse) throw new Error('[WebAuthnClient - mpc_round] :: webauthnCreate :: Unable to obtain response from Crypto Server!');
createResponse = toObject(createResponse);
GLOBAL.dispatchEvent(new CustomEvent("MPCResponse", {bubbles: true, cancelable: false, detail: createResponse }));
if(!createResponse?.result) throw new Error('[WebAuthnClient - mpc_round] :: webauthnCreate :: Unable to obtain credential from Crypto Server!');
if(!createResponse?.result?.response) throw new Error('[WebAuthnClient - mpc_round] :: webauthnCreate :: Unable to acquire attestation object!');
const credential = createResponse.result;
const registrationResult = await this.relyingParty.registrationFinish(credential);
if (!registrationResult) throw new Error('[WebAuthnClient] :: webauthnCreate :: Unable to verify credential Relying Party Server!');
return registrationResult;
};
/**
* @name webauthnGet
* @description Retrieves a WebAuthn credential for the specified userIdentifier.
* @param {string} userIdentifier The user identifier.
* @returns {Promise<ZSMAPI>} Resolves with the retrieved credential.
* @throws {Error} Throws an error if no credential ID is found.
* @throws {Error} Throws an error if unable to start authentication with the Relying Party Server.
* @throws {Error} Throws an error if unable to retrieve the public key/challenge from the Relying Party Server.
* @throws {Error} Throws an error if unable to obtain the getResponse/credential from the Crypto Server.
* @throws {Error} Throws an error if unable to verify the credential with the Relying Party Server.
* @throws {Error} Throws an error if the user ID or token is missing from the response body.
*/
async webauthnGet (userIdentifier) {
this.status = 'AUTHENTICATION_STARTING';
await this.initializeZsm('webauthnGet');
await this.webauthnRetrieve(userIdentifier, true)
if (!this.isEnrolled) throw new Error(`[WebAuthnClient] :: webauthnGet :: Credential is missing for ${userIdentifier}`);
let authStartResponse = await this.relyingParty.authenticationStart(this.credentialID);
if(!authStartResponse) throw new Error('[WebAuthnClient] :: webauthnGet :: Unable to start authentication with Relying Party Server!');
authStartResponse = toObject(authStartResponse);
if(!authStartResponse?.rcr || !authStartResponse?.rcr?.publicKey) throw new Error('[WebAuthnClient] :: webauthnGet :: Unable to retrieve public key from Relying Party Server!');
const pubKey = authStartResponse?.rcr?.publicKey;
if(!pubKey || !pubKey?.challenge) throw new Error('[WebAuthnClient] :: webauthnGet :: Unable to acquire challenge from public key!');
let getResponse = await this.zsmApi.webauthn_get(pubKey, tracePrimer());
if(!getResponse) throw new Error('[WebAuthnClient - mpc_round] :: webauthnGet :: Unable to obtain getResponse from Crypto Server!');
getResponse = toObject(getResponse);
if(!getResponse?.result) throw new Error('[WebAuthnClient - mpc_round] :: webauthnGet :: Unable to obtain credential from Crypto Server!');
GLOBAL.dispatchEvent(new CustomEvent("MPCResponse", {bubbles: true, cancelable: false, detail: getResponse }));
const credential = getResponse.result;
let authFinishResponse = await this.relyingParty.authenticationFinish(credential);
if (!authFinishResponse) throw new Error('[WebAuthnClient] :: webauthnGet :: Unable to verify credential Relying Party Server!');
authFinishResponse = toObject(authFinishResponse)
if (!authFinishResponse.user_id || !authFinishResponse.token) throw new Error('[WebAuthnClient] :: webauthnGet :: User ID or token is missing from response body!');
return {
signature : credential.response.signature,
signedChallenge : credential.response.clientDataJSON,
user_id : authFinishResponse.user_id,
token : authFinishResponse.token,
challenge : pubKey.challenge,
credential
};
};
/**
* @name webauthnRetrieve
* @description Looks up the Identity ID for the specified userIdentifier and retrieves its credential ID (if it exists).
* @ This is used to make a client-only check to see if a given userIdentifier already has an enrollment on the device.
* @param {string} userIdentifier The user identifier.
* @param {boolean} silent Whether to suppress status updates. [default: false]
* @returns {Promise<Boolean>} Resolves with the credential ID or `false` if retrieval fails.
* @returns {Promise<String>} Resolves with the retrieved rawID for the specified user.
*/
async webauthnRetrieve (userIdentifier, silent=false) {
this.credentialID = null;
if (!silent) {
this.status = 'CREDENTIAL_RETRIEVING';
}
try {
await this.initializeZsm('webauthnRetrieve');
let credentialIdentifier = await iid4uid(userIdentifier);
GLOBAL.mpcConfig.consumer_id = credentialIdentifier;
let response = await this.zsmApi.webauthn_retrieve(credentialIdentifier);
response = toObject(response);
if (response.error || !response?.result?.rawId) return false;
this.credentialID = response.result.rawId;
if (!silent) this.status = 'CREDENTIAL_RETRIEVED';
return this.credentialID;
} catch (error) {
if(!silent){
this.status = 'CREDENTIAL_RETRIEVAL_FAILED';
throw error;
}
}
};
/**
* @name webauthnCreateThenPartialGet
* @description UMFA-OPTIMIZED FLOW: Creates a new WebAuthn credential and then performs a partial get operation.
* @description This is used to create a new credential and then immediately retrieve it for authentication.
* @param {string} userIdentifier The user identifier.
* @returns {Promise<Object>} Resolves with the created credential.
* @throws {Error} Throws an error if unable to retieve public key/regFinishAuthStart from the Relying Party Server.
* @throws {Error} Throws an error if unable to obtain the createResponse/credential from the Crypto Server.
*/
async webauthnCreateThenPartialGet (userIdentifier) {
this.status = 'ENROLLMENT_STARTING';
const returnObj = await this.relyingParty.createIdentityThenRegistrationStart();
if (!returnObj) throw new Error('[WebAuthnClient] :: webauthnCreateThenPartialGet :: Unable to retrieve public key from Relying Party Server!');
const {publicKey, identity_id} = returnObj;
await this.initializeZsm('webauthnCreateThenPartialGet');
let createResponse = await this.zsmApi.webauthn_create(publicKey, tracePrimer());
if (!createResponse) throw new Error('[WebAuthnClient - mpc_round] :: webauthnCreateThenPartialGet :: Unable to obtain response from Crypto Server!');
createResponse = toObject(createResponse);
GLOBAL.dispatchEvent(new CustomEvent("MPCResponse", {bubbles: true, cancelable: false, detail: createResponse }));
if(!createResponse?.result) throw new Error('[WebAuthnClient - mpc_round] :: webauthnCreateThenPartialGet :: Unable to obtain credential from Crypto Server!');
const createCredential = createResponse.result;
let regFinishAuthStartResponse = await this.relyingParty.registrationFinishAuthenticationStart(createCredential);
if (!regFinishAuthStartResponse) throw new Error('[WebAuthnClient] :: webauthnCreateThenPartialGet :: Unable to retrieve response from Relying Party Server!');
regFinishAuthStartResponse = toObject(regFinishAuthStartResponse);
if(!regFinishAuthStartResponse?.rcr || !regFinishAuthStartResponse?.rcr?.publicKey) throw new Error('[WebAuthnClient] :: webauthnCreateThenPartialGet :: Unable to retrieve public key from Relying Party Server!');
const getChallenge = regFinishAuthStartResponse.rcr.publicKey;
let getResponse = await this.zsmApi.webauthn_get(getChallenge, tracePrimer());
if(!getResponse) throw new Error('[WebAuthnClient - mpc_round] :: webauthnGet :: Unable to obtain response from Crypto Server!');
getResponse = toObject(getResponse);
if(!getResponse?.result) throw new Error('[WebAuthnClient - mpc_round] :: webauthnGet :: Unable to obtain credential from Crypto Server!');
GLOBAL.dispatchEvent(new CustomEvent("MPCResponse", {bubbles: true, cancelable: false, detail: getResponse }));
const getCredential = getResponse.result;
let op = {
user_id : userIdentifier,
credential : getCredential,
signature : getCredential.response.signature,
signedChallenge : getCredential.response.clientDataJSON,
challenge : getChallenge.challenge,
};
return op;
};
/**
* @name webauthnPartialGet
* @description UMFA-OPTIMIZED FLOW: Performs a partial get operation for WebAuthn authentication.
* @param {string} userIdentifier The user identifier.
* @returns {Promise<Object>} Resolves with the created credential.
* @throws {Error} Throws an error if the credential is missing for the userIdentifier provided.
* @throws {Error} Throws an error if unable to start authentication with the Relying Party Server.
* @throws {Error} Throws an error if unable to retrieve the public key from the Relying Party Server.
* @throws {Error} Throws an error if unable to obtain the getResponse/credential from the Crypto Server.
* @emits {CustomEvent} Emits a "MPCResponse" event with the getResponse data.
*/
async webauthnPartialGet (userIdentifier) {
this.status = 'AUTHENTICATION_STARTING';
await this.initializeZsm('webauthnPartialGet');
if (!this.isEnrolled) throw new Error(`[WebAuthnClient] :: webauthnPartialGet :: Credential is missing for ${userIdentifier}`);
let getChallenge = await this.relyingParty.authenticationStart(this.credentialID);
if (!getChallenge) throw new Error('[WebAuthnClient] :: webauthnPartialGet :: Unable to start authentication with Relying Party Server!');
getChallenge = toObject(getChallenge);
if(!getChallenge?.rcr || !getChallenge.rcr.publicKey) throw new Error('[WebAuthnClient] :: webauthnPartialGet :: Unable to retrieve public key from Relying Party Server!');
const pubKey = getChallenge.rcr.publicKey;
let getResponse = await this.zsmApi.webauthn_get(pubKey, tracePrimer());
if(!getResponse) throw new Error('[WebAuthnClient - mpc_round] :: webauthnGet :: Unable to obtain getResponse from Crypto Server!');
getResponse = toObject(getResponse);
if(!getResponse?.result) throw new Error('[WebAuthnClient - mpc_round] :: webauthnGet :: Unable to obtain credential from Crypto Server!');
GLOBAL.dispatchEvent(new CustomEvent("MPCResponse", {bubbles: true, cancelable: false, detail: getResponse }));
const getCredential = getResponse.result;
return {
signature: getCredential.response.signature,
signedChallenge: getCredential.response.clientDataJSON,
user_id: userIdentifier,
challenge: getChallenge.challenge,
credential: getCredential,
};
};
/**
* @name unbindFromDevice
* @description Removes the device binding and stored account data for userIdentifier
* @param {String} userIdentifier The Customer-Defined Identifier of the user whose account is to be removed
* @returns {Promise<Boolean>} Returns true if the user's account was present (and subsequently removed from the device)
* @returns {Promise<Boolean>} Returns false if there is no credential matching userIdentifier stored on the device
* @throws {Error<CredentialError>} Throws an CredentialError if the credential is missing for the userIdentifier provided
* @throws {Error<UnenrollError>} Throws an UnenrollError if a credential matching userIdentifier IS stored on the device, but was unable to be removed.
*/
async unbindFromDevice (userIdentifier) {
this.status = 'UNENROLL_STARTING';
if(userIdentifier !== this.userIdentifier) return false;
await this.initializeZsm('unbindFromDevice');
const rawID = await this.webauthnRetrieve(userIdentifier);
if (!rawID) return false;
const request = indexedDB.open("ideem", 1);
request.onsuccess = function(event) {
const store = event.target.result.transaction("zsm", "readwrite").objectStore("zsm");
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = function(event) {
const cursor = event.target.result;
if (cursor) {
const { key, value } = cursor;
if (value instanceof Map && value.has("rawId") && value.get("rawId") === rawID) return (store.delete(key), true);
cursor.continue();
}
};
cursorRequest.onerror = (err) => { throw new Error(`[WebAuthnClient] :: unbindFromDevice :: Unable to unbind the device for ${userIdentifier}\n${err}`); };
};
request.onerror = (err) => { throw new Error(`[WebAuthnClient] :: unbindFromDevice :: Unable to open indexedDB for ${userIdentifier}\n${err}`); };
this.webauthnRetrieve(userIdentifier);
this.status = 'UNENROLL_COMPLETE';
return true;
}
/**
* Resets the WebAuthn credential for the user.
* @param {string} userIdentifier - The userIdentifier.
* @returns {Promise<ZSMAPI>} Resolves when the credential is reset.
*/
webauthnReset = () => this.relyingParty.resetAll();
resetDevice = this.webauthnReset
}
export default WebAuthnClientBase;