@ideem/zsm-react-native
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
1,051 lines (937 loc) • 36.1 kB
JavaScript
import RelyingParty from "./relying-party";
import ZSMLogger from "./zsm-logger";
import { version as packageVersion } from "../package.json";
class WebAuthnClientBase {
get version() {
return packageVersion;
}
config = {
application_id: "",
api_key: "",
host_url: "https://zsm-authenticator-demo.useideem.com/",
installation_description:
typeof window !== "undefined" ? window?.navigator?.userAgent : "",
log_level: "Error",
request_timeout_ms: 30000,
retry_count: 0,
// NOTE: consumer_id is intentionally NOT included in default config
// It should only be set when we have a valid UUID (identity_id from server)
};
relyingParty = undefined;
zsmApi = undefined;
identityId = undefined;
useIdentityIdForMpc = false; // Track if this credential was created with identity_id
/**
* Constructs a WebAuthnClient object.
* @param {Object} config - The configuration for ZSM initialization.
*/
constructor(config) {
config = config || {};
// Region-based URL derivation (matching Browser SDK)
if (config.region) {
const region = config.region;
if (!config.authenticator_host_url && !config.authentication_host) {
config.authentication_host = `https://zsm-authenticator-${region}.useideem.com/`;
config.authenticator_host_url = config.authentication_host;
}
if (!config.zsm_host_url && !config.zsm_host && !config.host_url) {
config.zsm_host = `https://zsm-${region}.useideem.com/`;
config.zsm_host_url = config.zsm_host;
config.host_url = config.zsm_host;
}
}
// Handle different naming conventions for authenticator host
if (!config.authenticator_host_url) {
config.authenticator_host_url =
config.authentication_host || config.host_url;
}
if (!config.zsm_host_url) {
if (config.host_url) {
config.zsm_host_url = config.host_url.replace("-authenticator", "");
} else if (config.authentication_host) {
config.zsm_host_url = config.authentication_host.replace(
"-authenticator",
"",
);
}
}
if (config.zsm_host_url) {
config.host_url = config.zsm_host_url;
}
this.config = Object.assign(this.config, config);
// Configure ZSMLogger with log level from config
ZSMLogger.setLogLevel(this.config.log_level);
ZSMLogger.info(
`ZSM JavaScript client v${ZSMLogger.version} initialized successfully`,
);
ZSMLogger.info(
`ZSM JavaScript client configured with log level: ${this.config.log_level}`,
);
if (typeof zsmAppConfig !== "undefined") zsmAppConfig = this.config;
this.relyingParty = new RelyingParty(
this.config.authenticator_host_url,
this.config.api_key,
this.config.application_id,
this.config.application_environment,
);
this.zsmApi;
this.initializationMutex = false;
// Perform automatic health check if enabled
if (this.config.health_check === true) {
ZSMLogger.info(
"Automatic health check enabled, running on initialization...",
);
this.healthCheck()
.then((result) => {
if (result.overall_status !== "HEALTHY") {
ZSMLogger.warn(
`Health check failed during initialization: ${JSON.stringify(
result,
)}`,
);
} else {
ZSMLogger.info("Initialization health check passed");
}
})
.catch((error) => {
ZSMLogger.error(
`Health check error during initialization: ${error.message}`,
);
});
}
}
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 username() {
return this.userIdentifier;
}
set username(v) {
return (this.userIdentifier = v);
}
get userID() {
return this.userIdentifier;
}
get token() {
return this.relyingParty.token;
}
get credentialID() {
return this.relyingParty.credentialID;
}
set credentialID(v) {
return (this.relyingParty.credentialID = v);
}
// Note: identityId is a class field (line 25), no getter needed
// Expose RelyingParty for testing
get _relyingParty() {
return this.relyingParty;
}
get isEnrolled() {
return !!this.credentialID;
}
// Get the appropriate consumer_id for MPC operations
// CRITICAL: consumer_id must be empty or a valid UUID (never a userId!)
// - identity_id is a UUID returned by the server after enrollment
// - consumer_id from config should already be a UUID if provided
// - NEVER use userId as consumer_id
getMpcConsumerId() {
// CRITICAL: The native SDK validates that consumer_id must be empty or a valid UUID.
// The native SDK's EnrollmentMapper handles user-specific storage internally using userId.
// We should NOT pass any consumer_id - let the native layer handle it.
ZSMLogger.trace(
`[MPC-CONSUMER-ID] Returning empty consumer_id (native SDK uses userId internally for storage isolation)`,
);
return "";
}
// Get a config object for MPC operations
// CRITICAL: Don't set consumer_id - native SDK manages user-specific storage via userId/EnrollmentMapper
getMpcConfig() {
const config = { ...this.config };
// Always remove consumer_id to let native SDK handle it
delete config.consumer_id;
// Remove falsy application_environment to prevent JS null from becoming
// the string "null" when crossing the React Native bridge
if (!config.application_environment) delete config.application_environment;
ZSMLogger.trace(
`[MPC-CONFIG] Final config consumer_id: (not set - native handles via userId)`,
);
return config;
}
/**
* Initializes the ZSM module for the specified username.
* @param {string} userIdentifier - The username to initialize ZSM for.
* @returns {Promise<void>} Resolves when ZSM is initialized.
*/
initializeZsm = async (userIdentifier = this.userIdentifier) => {
const traceId = ZSMLogger.generateTraceId();
// Fast path: if the native SDK is already configured for THIS user, do not
// call configure() again. Each configure() call passes a config with
// consumer_id stripped (see getMpcConfig), which clobbers the consumer_id
// the native UMFAClient set to identity_id during the previous operation.
// That desync leaves the FIDO2Client's storage identifier referencing a
// stale MPC key share, so the second post-migration authenticate (the
// dual passkey + MPC flow) produces a signature that no longer verifies
// against the registered public key — the server then classifies it as a
// passkey and returns "passkey token instead of ZSM token". Native iOS
// does not reconfigure on every authenticate, so it does not hit this.
if (
this.zsmApi &&
this.userIdentifier === userIdentifier &&
this.identityId
) {
ZSMLogger.trace(
`initializeZsm: already initialized for user ${userIdentifier} (identityId=${this.identityId}), skipping reconfigure`,
traceId,
);
return Promise.resolve(this.zsmApi);
}
ZSMLogger.trace(
`initializeZsm called with userIdentifier: ${userIdentifier}`,
traceId,
);
ZSMLogger.trace(
`[INIT-BEFORE] userIdentifier: ${this.userIdentifier}`,
traceId,
);
ZSMLogger.trace(
`[INIT-BEFORE] relyingParty.customerDefinedIdentifier: ${this.relyingParty.customerDefinedIdentifier}`,
traceId,
);
// IMPORTANT: Do NOT set consumer_id to userIdentifier!
// consumer_id must be empty or a valid UUID. The native layer (UMFAClient) handles
// the userId → consumerId mapping internally. We only track userIdentifier here.
this.userIdentifier = userIdentifier;
// CRITICAL: When switching users, lookup the correct identity_id for the new user
const storedIdentityId =
await this.relyingParty.lookupIdentityMapping(userIdentifier);
if (storedIdentityId !== userIdentifier) {
// Found a stored identity mapping - use it
this.identityId = storedIdentityId;
ZSMLogger.trace(
`[INIT-USER-SWITCH] Found stored identity_id: ${storedIdentityId} for user: ${userIdentifier}`,
traceId,
);
} else {
// No stored mapping - clear any cached identity_id
this.identityId = undefined;
ZSMLogger.trace(
`[INIT-USER-SWITCH] No stored identity_id for user: ${userIdentifier}, cleared cache`,
traceId,
);
}
ZSMLogger.trace(
`[INIT-AFTER] userIdentifier: ${this.userIdentifier}`,
traceId,
);
ZSMLogger.trace(
`[INIT-AFTER] relyingParty.customerDefinedIdentifier: ${this.relyingParty.customerDefinedIdentifier}`,
traceId,
);
ZSMLogger.trace(
`[INIT-AFTER] MPC consumer_id will be: ${this.getMpcConsumerId() || "(empty)"}`,
traceId,
);
ZSMLogger.trace(`[INIT-AFTER] Identity ID is: ${this.identityId}`, traceId);
if (typeof zsmAppConfig !== "undefined") zsmAppConfig = this.config;
// Just configure if ZSM API already exists, otherwise initialize fully
// Use MPC config for native layer (includes identity_id when available)
if (this.zsmApi) {
await this.zsmApi.configure(this.getMpcConfig());
return Promise.resolve(this.zsmApi);
} else {
this.status = "ZSM_INITIALIZING";
let rawAPI = await this.getZsmApi(this.getMpcConfig());
return Promise.resolve((this.zsmApi = rawAPI));
}
};
//override INHERITED METHODS
getZsmApi = async (config) => {
Promise.reject(new Error("Method not implemented"));
};
/**
* Performs a comprehensive health check on both ZSM and authenticator hosts.
* @returns {Promise<Object>} Resolves with health status for all configured endpoints.
*/
healthCheck = async () => {
const traceId = ZSMLogger.generateTraceId();
try {
ZSMLogger.info("Starting comprehensive health check...", traceId);
const results = {
timestamp: new Date().toISOString(),
version: this.version,
configuration: {
zsm_host_url: this.config.host_url,
authenticator_host_url: this.config.authenticator_host_url,
application_id: this.config.application_id || "NOT_SET",
api_key: this.config.api_key ? "***configured***" : "NOT_SET",
application_environment: this.config.application_environment,
},
health_checks: {},
};
// Health check for authenticator host (via RelyingParty)
ZSMLogger.trace("Checking authenticator host...", traceId);
results.health_checks.authenticator =
await this.relyingParty.healthCheck();
// Health check for main ZSM host
ZSMLogger.trace("Checking main ZSM host...", traceId);
try {
const zsmHealthUrl = `${
this.config.host_url.endsWith("/")
? this.config.host_url.slice(0, -1)
: this.config.host_url
}/api/health`;
const zsmResponse = await fetch(zsmHealthUrl, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
let zsmHealthData;
try {
const responseText = await zsmResponse.text();
if (!responseText || responseText.trim() === "") {
zsmHealthData = {
error: "Empty response from ZSM health endpoint",
};
} else {
zsmHealthData = JSON.parse(responseText);
}
} catch (e) {
ZSMLogger.error(`Failed to parse ZSM health response: ${e.message}`);
zsmHealthData = { error: `Failed to parse response: ${e.message}` };
}
results.health_checks.zsm_main = {
host: this.config.host_url,
healthEndpoint: zsmHealthUrl,
healthStatus: zsmResponse.ok ? "OK" : "FAILED",
healthResponse: zsmHealthData,
httpStatus: zsmResponse.status,
timestamp: new Date().toISOString(),
};
} catch (error) {
results.health_checks.zsm_main = {
host: this.config.host_url,
healthStatus: "ERROR",
error: error.message,
timestamp: new Date().toISOString(),
};
}
// Test API key and app ID validation
ZSMLogger.trace("Testing API credentials...", traceId);
try {
const testResult =
await this.relyingParty.checkServerSideIdentity(false);
results.health_checks.api_credentials = {
status: "OK",
test_endpoint: "api/umfa/check-identity",
response: testResult,
};
} catch (error) {
results.health_checks.api_credentials = {
status: "FAILED",
test_endpoint: "api/umfa/check-identity",
error: error.message,
};
}
// Overall health summary
const allChecks = Object.values(results.health_checks);
const failedChecks = allChecks.filter(
(check) =>
check.healthStatus === "FAILED" ||
check.healthStatus === "ERROR" ||
check.status === "FAILED",
);
results.overall_status =
failedChecks.length === 0 ? "HEALTHY" : "UNHEALTHY";
results.failed_checks = failedChecks.length;
results.total_checks = allChecks.length;
ZSMLogger.info(
`Health check complete: ${results.overall_status}`,
traceId,
);
ZSMLogger.trace(
`Full health check results: ${JSON.stringify(results)}`,
traceId,
);
return results;
} catch (error) {
const errorResult = {
timestamp: new Date().toISOString(),
overall_status: "ERROR",
error: error.message,
configuration: {
zsm_host_url: this.config.host_url,
authenticator_host_url: this.config.authenticator_host_url,
},
};
ZSMLogger.error(`Health check failed: ${error.message}`);
return errorResult;
}
};
toObject = (inp) => {
const traceId = ZSMLogger.generateTraceId();
if (inp instanceof Map) {
let obj = {};
inp.forEach((v, k) => (obj[k] = this.toObject(v)));
return obj;
} else if (Array.isArray(inp)) {
return inp.map(this.toObject.bind(this));
} else if (inp instanceof Object) {
let obj = {};
for (let k in inp) obj[k] = this.toObject(inp[k]);
return obj;
} else {
return inp;
}
};
/**
* Retrieves a user's identity_id and can create a new one if specified
* @param {string} userIdentifier - The userIdentifier.
* @param {boolean} createNewIdentity Create a new identity if one does not already exist
* @returns {Promise<Object>} Resolves with the retrieved identity if it exists.
*/
checkIdentity = async (userIdentifier, createNewIdentity = false) => {
try {
this.status = "CHECKING_IDENTITY_STARTING";
await this.initializeZsm(userIdentifier);
const resultOfCheck =
await this.relyingParty.checkServerSideIdentity(createNewIdentity);
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!");
// Store identity_id for MPC operations while preserving original consumer_id for storage
this.identityId = identityID;
// CRITICAL: Store identity mapping to persistent storage (this was missing!)
await this.relyingParty.handleServerIdentityResponse(
resultOfCheck,
userIdentifier,
);
const traceId = ZSMLogger.generateTraceId();
ZSMLogger.trace(
`[CHECK-IDENTITY] Stored identity_id: ${this.identityId}`,
traceId,
);
ZSMLogger.trace(
`[CHECK-IDENTITY] MPC config will use consumer_id: ${this.getMpcConsumerId()}`,
traceId,
);
ZSMLogger.trace(
`[CHECK-IDENTITY] Storage config preserves consumer_id: ${this.config.consumer_id}`,
traceId,
);
// Reconfigure ZSM API with the new MPC config that includes identity_id
if (this.zsmApi) {
await this.zsmApi.configure(this.getMpcConfig());
ZSMLogger.trace(
`[CHECK-IDENTITY] ZSM API reconfigured with identity_id`,
traceId,
);
}
return identityID;
} catch (err) {
this.status = "CHECKING_IDENTITY_FAILED";
return Promise.reject(err);
}
};
/**
* Creates a new WebAuthn credential.
* @param {string} userIdentifier - The user identifier.
* @returns {Promise<Object>} Resolves with the created credential.
*/
webauthnCreate = async (userIdentifier, traceId = null) => {
this.status = "ENROLLMENT_STARTING";
await this.initializeZsm(userIdentifier);
// Ensure identity exists (like web SDK does) before creating credentials
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.debug(
`Ensuring identity exists for user: ${userIdentifier}`,
actualTraceId,
);
await this.checkIdentity(userIdentifier, true);
const publicKey = await this.relyingParty.registrationStart(actualTraceId);
if (!publicKey)
throw new Error(
"[registration_start] webauthnCreate failed :: Unable to retrieve public key from Relying Party Server!",
);
ZSMLogger.trace("About to call zsmApi.webauthn_create...", actualTraceId);
ZSMLogger.trace(`publicKey: ${JSON.stringify(publicKey)}`, actualTraceId);
ZSMLogger.trace(`traceId: ${actualTraceId}`, actualTraceId);
let createResponse = await this.zsmApi.webauthn_create(
publicKey,
actualTraceId,
);
ZSMLogger.trace("Raw ZSM API response received", actualTraceId);
ZSMLogger.trace(
`Raw response type: ${typeof createResponse}`,
actualTraceId,
);
ZSMLogger.trace(
`Raw response: ${JSON.stringify(createResponse)}`,
actualTraceId,
);
if (!createResponse)
throw new Error(
"[mpc_round] webauthnCreate failed :: Unable to obtain response from Crypto Server!",
);
ZSMLogger.trace(
"About to call toObject with createResponse...",
actualTraceId,
);
createResponse = this.toObject(createResponse);
ZSMLogger.trace(
`toObject result: ${JSON.stringify(createResponse)}`,
actualTraceId,
);
if (!createResponse?.result)
throw new Error(
"[mpc_round] webauthnCreate failed :: Unable to obtain credential from Crypto Server!",
);
if (!createResponse?.result?.response)
throw new Error(
"[mpc_round] webauthnCreate failed :: Unable to acquire attestation object!",
);
const credential = createResponse.result;
const registrationResult = await this.relyingParty.registrationFinish(
credential,
actualTraceId,
);
if (!registrationResult)
throw new Error(
"[registration_finish] webauthnCreate failed :: Unable to verify credential Relying Party Server!",
);
// Register the enrollment in the native credential store so that
// getCredentialState() and listRegisteredUsers() work correctly
try {
const credentialId = credential.rawId || credential.id || "";
const identityId = this.identityId || this.config.consumer_id || "";
ZSMLogger.debug(
`[registerEnrollment] Registering enrollment - userId: ${userIdentifier}, identityId: ${identityId}, credentialId: ${credentialId}`,
actualTraceId,
);
if (this.zsmApi?.registerEnrollment && credentialId) {
await this.zsmApi.registerEnrollment(
userIdentifier,
identityId,
credentialId,
"mpc", // credential type
actualTraceId,
);
ZSMLogger.debug(
`[registerEnrollment] Successfully registered enrollment for ${userIdentifier}`,
actualTraceId,
);
} else {
ZSMLogger.debug(
`[registerEnrollment] Skipped - registerEnrollment not available or no credentialId`,
actualTraceId,
);
}
} catch (regError) {
// Log but don't fail the enrollment - credential is still created
ZSMLogger.warn(
`[registerEnrollment] Failed to register enrollment: ${regError.message}`,
actualTraceId,
);
}
return registrationResult;
};
/**
* Authenticates a user via WebAuthn.
* @param {string} userIdentifier - The user identifier.
* @returns {Promise<Object>} Resolves with the authentication result.
*/
webauthnGet = async (userIdentifier, traceId = null) => {
this.status = "AUTHENTICATION_STARTING";
// Don't reinitialize - just ensure we have the right user set
if (this.userIdentifier !== userIdentifier) {
this.userIdentifier = userIdentifier;
}
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.debug(
`webauthnGet called for user: ${userIdentifier}`,
actualTraceId,
);
// webauthnGet retrieves credential ID before authentication (matches working web SDK pattern)
await this.webauthnRetrieve(userIdentifier, true, actualTraceId);
if (!this.isEnrolled) {
throw new Error(`Credential is missing for ${userIdentifier}`);
}
const authStartResponse = await this.relyingParty.authenticationStart(
this.credentialID,
actualTraceId,
);
if (!authStartResponse)
throw new Error(
"[authentication_start] webauthnGet failed :: Unable to start authentication with Relying Party Server!",
);
const pubKey = authStartResponse.rcr.publicKey;
if (!pubKey || !pubKey.challenge)
throw new Error(
"[authentication_start] webauthnGet failed :: Unable to acquire challenge from public key!",
);
const response = this.toObject(
await this.zsmApi.webauthn_get(pubKey, actualTraceId),
);
if (!response?.result)
throw new Error(
"[mpc_round] webauthnGet failed :: Unable to obtain credential from Crypto Server!",
);
const credential = response.result;
const authFinishResponse = await this.relyingParty.authenticationFinish(
credential,
actualTraceId,
);
if (!authFinishResponse)
throw new Error(
"[authentication_finish] webauthnGet failed :: Unable to verify credential Relying Party Server!",
);
if (!authFinishResponse.user_id || !authFinishResponse.token)
throw new Error(
"[authentication_finish] webauthnGet failed :: 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,
};
};
/**
* Retrieves the credential ID using WebAuthn.
* @param {string} userIdentifier - The user identifier.
* @param {boolean} [silent=false] - Whether to suppress status updates.
* @returns {Promise<string|false>} Resolves with the credential ID or `false` if retrieval fails.
*/
webauthnRetrieve = async (userIdentifier, silent = false, traceId = null) => {
this.credentialID = null;
if (!silent) {
this.status = "CREDENTIAL_RETRIEVING";
}
try {
// Don't reinitialize - just ensure we have the right user set
if (this.userIdentifier !== userIdentifier) {
this.userIdentifier = userIdentifier;
}
// CRITICAL: Implement iOS native pattern - always use original consumerId for storage retrieval
// This matches iOS webauthn_retrieve behavior (lines 181-186 in ZSMNativeModule.m)
const actualTraceId = traceId || ZSMLogger.generateTraceId();
const currentConsumerId = this.config.consumer_id; // Original consumer_id for storage
ZSMLogger.trace(
`[RETRIEVE] Using current consumerId for storage retrieval: ${currentConsumerId}`,
actualTraceId,
);
ZSMLogger.trace(
`[RETRIEVE] userIdentifier parameter: ${userIdentifier}`,
actualTraceId,
);
ZSMLogger.trace(
`[RETRIEVE] Current identity_id (for MPC only): ${this.identityId}`,
actualTraceId,
);
// Native module only takes traceId - it gets consumerId from config internally
const response = this.toObject(
await this.zsmApi.webauthn_retrieve(actualTraceId),
);
if (response.error || !response?.result?.rawId) {
ZSMLogger.debug(
`No credential found for userIdentifier: ${userIdentifier}`,
actualTraceId,
);
return false;
}
this.credentialID = response.result.rawId;
ZSMLogger.debug(
`Found credential with rawId: ${this.credentialID}`,
actualTraceId,
);
if (!silent) {
this.status = "CREDENTIAL_RETRIEVED";
}
return this.credentialID;
} catch (error) {
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.error(
`webauthnRetrieve error: ${error.message}`,
actualTraceId,
);
if (!silent) {
this.status = "CREDENTIAL_RETRIEVAL_FAILED";
}
throw error;
}
};
/**
* Creates a new WebAuthn credential and immediately performs a partial get (optimized UMFA enrollment flow).
* @param {string} userIdentifier - The user identifier.
* @param {*} trace - Optional trace parameter.
* @returns {Promise<Object>} Resolves with the credential and authentication result.
*/
webauthnCreateThenPartialGet = async (userIdentifier, traceId = null) => {
this.status = "ENROLLMENT_STARTING";
await this.initializeZsm(userIdentifier);
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.debug(
`[OPTIMIZED-FLOW] Starting webauthnCreateThenPartialGet for user: ${userIdentifier}`,
actualTraceId,
);
// OPTIMIZED FLOW: Use combined identity creation and registration start
const returnObj =
await this.relyingParty.createIdentityThenRegistrationStart(
actualTraceId,
);
if (!returnObj)
throw new Error(
"[createIdentityThenRegistrationStart] webauthnCreateThenPartialGet failed :: Unable to retrieve public key from Relying Party Server!",
);
const { publicKey, identity_id } = returnObj;
ZSMLogger.debug(
`[OPTIMIZED-FLOW] Received identity_id: ${identity_id}`,
actualTraceId,
);
// Store identity_id for MPC operations while preserving original consumer_id for storage
this.identityId = identity_id;
ZSMLogger.trace(
`[OPTIMIZED-FLOW] Storage consumer_id (preserved): ${this.config.consumer_id}`,
actualTraceId,
);
ZSMLogger.trace(
`[OPTIMIZED-FLOW] MPC consumer_id will be: ${this.getMpcConsumerId()}`,
actualTraceId,
);
// Configure ZSM API with the new consumer_id (identity_id) for MPC operations
if (this.zsmApi) {
await this.zsmApi.configure(this.getMpcConfig());
ZSMLogger.trace(
`[OPTIMIZED-FLOW] ZSM API configured with identity_id: ${this.identityId}`,
actualTraceId,
);
}
ZSMLogger.trace(
"[OPTIMIZED-FLOW] About to call zsmApi.webauthn_create...",
actualTraceId,
);
ZSMLogger.trace(
`[OPTIMIZED-FLOW] publicKey: ${JSON.stringify(publicKey)}`,
actualTraceId,
);
let createResponse = await this.zsmApi.webauthn_create(
publicKey,
actualTraceId,
);
ZSMLogger.trace(
"[OPTIMIZED-FLOW] Raw ZSM API response received",
actualTraceId,
);
if (!createResponse)
throw new Error(
"[mpc_round] webauthnCreateThenPartialGet failed :: Unable to obtain response from Crypto Server!",
);
createResponse = this.toObject(createResponse);
if (!createResponse?.result)
throw new Error(
"[mpc_round] webauthnCreateThenPartialGet failed :: Unable to obtain credential from Crypto Server!",
);
const createCredential = createResponse.result;
// OPTIMIZED FLOW: Use combined registration finish and authentication start
let regFinishAuthStartResponse =
await this.relyingParty.registrationFinishAuthenticationStart(
createCredential,
actualTraceId,
);
if (!regFinishAuthStartResponse)
throw new Error(
"[registrationFinishAuthenticationStart] webauthnCreateThenPartialGet failed :: Unable to retrieve response from Relying Party Server!",
);
regFinishAuthStartResponse = this.toObject(regFinishAuthStartResponse);
if (
!regFinishAuthStartResponse?.rcr ||
!regFinishAuthStartResponse?.rcr?.publicKey
)
throw new Error(
"[registrationFinishAuthenticationStart] webauthnCreateThenPartialGet failed :: Unable to retrieve public key from Relying Party Server!",
);
const getChallenge = regFinishAuthStartResponse.rcr.publicKey;
// Store the credential ID from the created credential
this.credentialID = createCredential.rawId || createCredential.id;
ZSMLogger.trace(
"[OPTIMIZED-FLOW] About to call zsmApi.webauthn_get...",
actualTraceId,
);
let getResponse = await this.zsmApi.webauthn_get(
getChallenge,
actualTraceId,
);
if (!getResponse)
throw new Error(
"[mpc_round] webauthnGet failed :: Unable to obtain response from Crypto Server!",
);
getResponse = this.toObject(getResponse);
if (!getResponse?.result)
throw new Error(
"[mpc_round] webauthnGet failed :: Unable to obtain credential from Crypto Server!",
);
const getCredential = getResponse.result;
// Register the enrollment in the native credential store so that
// getCredentialState() and listRegisteredUsers() work correctly
try {
const credentialId = createCredential.rawId || createCredential.id || "";
const identityIdForReg = identity_id || this.identityId || "";
ZSMLogger.debug(
`[registerEnrollment] Registering optimized-flow enrollment - userId: ${userIdentifier}, identityId: ${identityIdForReg}, credentialId: ${credentialId}`,
actualTraceId,
);
if (this.zsmApi?.registerEnrollment && credentialId) {
await this.zsmApi.registerEnrollment(
userIdentifier,
identityIdForReg,
credentialId,
"mpc", // credential type
actualTraceId,
);
ZSMLogger.debug(
`[registerEnrollment] Successfully registered optimized-flow enrollment for ${userIdentifier}`,
actualTraceId,
);
} else {
ZSMLogger.debug(
`[registerEnrollment] Skipped - registerEnrollment not available or no credentialId`,
actualTraceId,
);
}
} catch (regError) {
// Log but don't fail the enrollment - credential is still created
ZSMLogger.warn(
`[registerEnrollment] Failed to register enrollment: ${regError.message}`,
actualTraceId,
);
}
// OPTIMIZED FLOW: Return partial result (no authenticationFinish call)
return {
registrationResult: createCredential,
credential: {
signature: getCredential.response.signature,
signedChallenge: getCredential.response.clientDataJSON,
user_id: userIdentifier,
challenge: getChallenge.challenge,
credential: getCredential,
},
};
};
/**
* Performs a partial WebAuthn get (optimized UMFA authentication flow).
* @param {string} userIdentifier - The user identifier.
* @param {*} trace - Optional trace parameter.
* @returns {Promise<Object>} Resolves with the authentication result.
*/
webauthnPartialGet = async (userIdentifier, traceId = null) => {
this.status = "AUTHENTICATION_STARTING";
// Don't reinitialize - credential should already be set from enrollment
// Just ensure we have the right user set
if (this.userIdentifier !== userIdentifier) {
this.userIdentifier = userIdentifier;
}
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.debug(
`[OPTIMIZED-AUTH] webauthnPartialGet called for user: ${userIdentifier}`,
actualTraceId,
);
ZSMLogger.trace(
`[OPTIMIZED-AUTH] Current identity_id: ${this.identityId}`,
actualTraceId,
);
ZSMLogger.trace(
`[OPTIMIZED-AUTH] MPC consumer_id will be: ${this.getMpcConsumerId()}`,
actualTraceId,
);
// Ensure ZSM API is configured with correct consumer_id (identity_id if available)
if (this.zsmApi && this.identityId) {
await this.zsmApi.configure(this.getMpcConfig());
ZSMLogger.trace(
`[OPTIMIZED-AUTH] ZSM API configured with identity_id: ${this.identityId}`,
actualTraceId,
);
}
// If not enrolled (no credentialID in memory), try to retrieve from native storage
// This handles the case where the app was restarted and credentials are in native storage
if (!this.isEnrolled) {
ZSMLogger.debug(
`[OPTIMIZED-AUTH] No credential in memory, attempting to retrieve from native storage`,
actualTraceId,
);
try {
const retrieved = await this.webauthnRetrieve(
userIdentifier,
true,
actualTraceId,
);
if (retrieved) {
ZSMLogger.debug(
`[OPTIMIZED-AUTH] Successfully retrieved credential from storage: ${retrieved}`,
actualTraceId,
);
}
} catch (retrieveError) {
ZSMLogger.debug(
`[OPTIMIZED-AUTH] Failed to retrieve credential from storage: ${retrieveError.message}`,
actualTraceId,
);
}
}
// Check again after potential retrieval
if (!this.isEnrolled) {
throw new Error(`Credential is missing for ${userIdentifier}`);
}
const getChallenge = await this.relyingParty.authenticationStart(
this.credentialID,
actualTraceId,
);
if (!getChallenge)
throw new Error(
"[authentication_start] webauthnPartialGet failed :: Unable to start authentication with Relying Party Server!",
);
const pubKey = getChallenge.rcr.publicKey;
if (!pubKey || !pubKey.challenge)
throw new Error(
"[authentication_start] webauthnPartialGet failed :: Unable to retrieve public key from Relying Party Server!",
);
ZSMLogger.trace(
"[OPTIMIZED-AUTH] About to call zsmApi.webauthn_get...",
actualTraceId,
);
const getResponse = this.toObject(
await this.zsmApi.webauthn_get(pubKey, actualTraceId),
);
if (!getResponse?.result)
throw new Error(
"[mpc_round] webauthnPartialGet failed :: Unable to obtain credential from Crypto Server!",
);
const getCredential = getResponse.result;
// webauthnPartialGet does NOT call authenticationFinish (returns partial results)
return {
signature: getCredential.response.signature,
signedChallenge: getCredential.response.clientDataJSON,
user_id: userIdentifier, // Return input userIdentifier, not from server response
challenge: getChallenge.challenge,
credential: getCredential,
};
};
/**
* Resets the WebAuthn credential for the user.
* @param {string} userIdentifier - The userIdentifier.
* @returns {Promise<void>} Resolves when the credential is reset.
*/
webauthnReset = () => this.relyingParty.resetAll();
resetDevice = this.webauthnReset;
}
// Export WebAuthnClient for browser fallback or custom usage
export default WebAuthnClientBase;