@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
360 lines (309 loc) • 19.5 kB
JavaScript
import ZSMLogger from './zsm-logger';
class RelyingPartyBase {
// Persistent private properties (set by the constructor from the user-defined configuration)
#host; // Set in the extended config (authenticator_host_url)
#apiKey; // Set in the user-defined configuration
#applicationId; // Set in the user-defined configuration
// Transient private properties (set by the class methods)
#customerDefinedIdentifier; // Value passed in by the user representing the user's identity
#publicKey; // Set by registrationStart (data.ccr.publicKey)
#credentialID; // Set by registrationFinish (credential.rawId)
/**
* Constructs a RelyingParty object.
* @param {string} host - The URL of the relying party server.
*/
constructor(host, apiKey, applicationID) {
this.#host = host;
this.#apiKey = apiKey;
this.#applicationId = applicationID;
this.xhrHeaders = { 'Content-Type' : 'application/json' };
}
get host() { return this.#host; }
get apiKey() { return this.#apiKey; }
get applicationId() { return this.#applicationId; }
get userIdentifier() { return this.#customerDefinedIdentifier; }
set userIdentifier(v) { return this.#customerDefinedIdentifier = v; }
get customerDefinedIdentifier() { return this.#customerDefinedIdentifier; }
set customerDefinedIdentifier(v) { return this.#customerDefinedIdentifier = v; }
get publicKey() { return this.#publicKey; }
get credentialID() { return this.#credentialID; }
set credentialID(v) { return this.#credentialID = v; }
/**
* Abstract methods for identity mapping - must be implemented by platform-specific code
* These methods should be overridden by platform implementations (React Native, Web, etc.)
*/
async storeIdentityMapping(userId, identityId) {
// Default implementation - no-op (web has its own implementation)
ZSMLogger.log(`[IDENTITY-MAPPING] Base implementation: would store ${userId} -> ${identityId}`);
}
async lookupIdentityMapping(userId) {
// Default implementation - return original userId
ZSMLogger.log(`[IDENTITY-MAPPING] Base implementation: returning original userId ${userId}`);
return userId;
}
/**
* Get the correct consumer_id for MPC operations
* This is the centralized method that ensures proper identity mapping
* @param {string} userId - The user identifier
* @returns {Promise<string>} The identity ID to use as consumer_id or original userId
*/
async getConsumerIdForMPC(userId = this.customerDefinedIdentifier) {
const mapConsumerId = await this.lookupIdentityMapping(userId);
ZSMLogger.log(`[IDENTITY-MAPPING] MPC consumer_id: '${mapConsumerId}' for user: '${userId}'`);
return mapConsumerId;
}
/**
* Store identity mapping when receiving identity_id from server responses
* This should be called whenever the server returns an identity_id
* @param {Object} serverResponse - Response containing identity_id
* @param {string} userId - The user identifier to map
*/
async handleServerIdentityResponse(serverResponse, userId = this.customerDefinedIdentifier) {
if (serverResponse?.identity_id && userId) {
await this.storeIdentityMapping(userId, serverResponse.identity_id);
ZSMLogger.debug(`[IDENTITY-MAPPING] Processed server identity response for user: ${userId}`);
}
}
#makePostRequest = (url, body={}, headers={}, method='POST', traceId = null) => {
url = `${this.host.endsWith('/') ? this.host.slice(0,-1) : this.host}/${url.startsWith('/') ? url.slice(1) : url}`;
body = ((typeof body === 'object') ? body : {});
body = Object.assign({customer_defined_identifier: this.customerDefinedIdentifier, application_id: this.applicationId}, body);
// Note: trace_id is NOT added to request body to match web implementation
let fetchObj = {
method,
headers : Object.assign(this.xhrHeaders, ((!headers?.Authorization) ? {'Authorization': `Bearer ${this.#apiKey}`} : {}), headers),
body : JSON.stringify(body)
};
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.trace(`${method} to url: ${url}`, actualTraceId);
ZSMLogger.trace(`Request headers: ${JSON.stringify(fetchObj.headers)}`, actualTraceId);
ZSMLogger.trace(`Request body: ${fetchObj.body}`, actualTraceId);
return fetch(url, fetchObj)
.then(response => {
ZSMLogger.trace(`Response status: ${response.status}`, actualTraceId);
ZSMLogger.trace(`Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`, actualTraceId);
ZSMLogger.trace(`Response ok: ${response.ok}`, actualTraceId);
if (response.ok) {
return response.text().then(text => {
ZSMLogger.trace(`SUCCESS RESPONSE BODY: ${text}`, actualTraceId);
try {
// Check for empty response
if (!text || text.trim() === '') {
ZSMLogger.error('Empty response received', actualTraceId);
throw new Error('Server returned empty response');
}
const parsed = JSON.parse(text);
ZSMLogger.trace(`PARSED JSON SUCCESS: ${JSON.stringify(parsed)}`, actualTraceId);
// Log if server echoed back trace ID for debugging
if (parsed.trace_id) {
ZSMLogger.trace(`Server echoed trace_id: ${parsed.trace_id}`, actualTraceId);
}
return parsed;
} catch (e) {
ZSMLogger.error(`JSON PARSE ERROR: ${e.message}`, actualTraceId);
throw new Error(`JSON Parse error: Unexpected end of input - Response: "${text}" - Error: ${e.message}`);
}
});
} else {
return response.text().then(text => {
ZSMLogger.trace(`ERROR RESPONSE BODY: ${text}`, actualTraceId);
let errData;
try {
// Check for empty error response
if (!text || text.trim() === '') {
errData = { message: 'Server returned empty error response' };
} else {
errData = JSON.parse(text);
}
} catch (e) {
ZSMLogger.error(`ERROR JSON PARSE FAILED: ${e.message}`, actualTraceId);
errData = { message: `Invalid JSON in error response: "${text}"` };
}
throw new Error(`Request failed: ${response.statusText}, ${JSON.stringify(errData)}`);
});
}
})
.catch(error => {
ZSMLogger.error(`Fetch error: ${error.message}`, actualTraceId);
throw error;
});
}
clearEnrollmentCredentials = () => (this.#publicKey = this.#credentialID = null);
clearLoginCredentials = async () => null;
resetAll = async () => (this.clearLoginCredentials(), this.clearEnrollmentCredentials());
/**
* Performs a health check on the relying party server.
* @returns {Promise<Object>} Resolves with health status and configuration info.
*/
healthCheck = async () => {
try {
const healthUrl = `${this.host.endsWith('/') ? this.host.slice(0,-1) : this.host}/api/health`;
const traceId = ZSMLogger.generateTraceId();
ZSMLogger.trace(`Checking health at: ${healthUrl}`, traceId);
const response = await fetch(healthUrl, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
let healthData;
try {
const responseText = await response.text();
if (!responseText || responseText.trim() === '') {
healthData = { error: 'Empty response from health endpoint' };
} else {
healthData = JSON.parse(responseText);
}
} catch (e) {
ZSMLogger.error(`Failed to parse health response: ${e.message}`);
healthData = { error: `Failed to parse response: ${e.message}` };
}
const result = {
host: this.host,
apiKey: this.#apiKey ? '***configured***' : 'NOT_SET',
applicationId: this.#applicationId || 'NOT_SET',
healthEndpoint: healthUrl,
healthStatus: response.ok ? 'OK' : 'FAILED',
healthResponse: healthData,
httpStatus: response.status,
timestamp: new Date().toISOString()
};
ZSMLogger.info(`Health check result: ${result.healthStatus}`, traceId);
ZSMLogger.trace(`Full health check result: ${JSON.stringify(result)}`, traceId);
return result;
} catch (error) {
const errorResult = {
host: this.host,
healthStatus: 'ERROR',
error: error.message,
timestamp: new Date().toISOString()
};
ZSMLogger.error(`Health check failed: ${error.message}`);
return errorResult;
}
}
/**
* Starts WebAuthn registration by sending a request to the server.
* @param {string} [traceId] - Optional trace ID for end-to-end tracing.
* @returns {Promise<Object>} Resolves with the registration challenge.
*/
registrationStart = (traceId = null) => {
this.status='REGISTRATION_START';
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.debug('Starting WebAuthn registration', actualTraceId);
return this.#makePostRequest("api/webauthn/registration/start", {}, {}, 'POST', actualTraceId)
.then(data => (this.#publicKey = data.ccr.publicKey))
.then(result => (this.status='REGISTRATION_STARTED', result))
.catch(error => (this.status='REGISTRATION_FAILED', Promise.reject((error instanceof Error) ? error : new Error(error))))
}
/**
* Completes WebAuthn registration by sending the credential to the server.
* @param {Object} credential - The credential information to finalize registration.
* @param {string} [traceId] - Optional trace ID for end-to-end tracing.
* @returns {Promise<Object>} Resolves with the registered credential.
*/
registrationFinish = (credential, traceId = null) => {
this.status='REGISTRATION_FINISH';
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.debug('Finishing WebAuthn registration', actualTraceId);
return this.#makePostRequest("api/webauthn/registration/finish", { credential }, {}, 'POST', actualTraceId)
.then(response => (this.#credentialID = credential.rawId, response))
.then(credential => (this.status='REGISTRATION_FINISHED', credential))
.catch(error => (this.status='REGISTRATION_FAILED', this.clearEnrollmentCredentials(), Promise.reject((error instanceof Error) ? error : new Error(error))))
}
/**
* Starts WebAuthn authentication by sending a request to the server.
* @param {string} [credential_id] - The credential ID to authenticate with.
* @param {string} [traceId] - Optional trace ID for end-to-end tracing.
* @returns {Promise<Object>} Resolves with the authentication challenge.
*/
authenticationStart = (credential_id=this.credentialID, traceId = null) => {
this.status='AUTHENTICATION_STARTING';
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.debug('Starting WebAuthn authentication', actualTraceId);
return (this.credentialID != null)
? this.#makePostRequest( "api/webauthn/authentication/start", { credential_id }, {}, 'POST', actualTraceId)
.then(result => (this.status='AUTHENTICATION_STARTED', result))
: (this.status='AUTHENTICATION_FAILED', Promise.reject(new Error("No user ID or credential ID provided")))
}
/**
* Completes WebAuthn authentication by sending the credential to the server.
* @param {Object} credential - The credential information to finalize authentication.
* @param {string} [traceId] - Optional trace ID for end-to-end tracing.
* @returns {Promise<Object>} Resolves with the authentication result.
*/
authenticationFinish = (credential, traceId = null) => {
this.status='AUTHENTICATION_FINISHING';
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.debug('Finishing WebAuthn authentication', actualTraceId);
return this.#makePostRequest("api/webauthn/authentication/finish", { credential }, {}, 'POST', actualTraceId)
.then(result => (this.status='AUTHENTICATION_FINISHED', result))
.catch(error => (this.status='AUTHENTICATION_FAILED', Promise.reject((error instanceof Error) ? error : new Error(error))));
}
/**
* Completes Identity check/creation by sending the request to the server.
* @param {boolean} createNewIdentity Create a new identity if one does not already exist
* @returns {Promise<Object>} Resolves with the authentication result.
*/
checkServerSideIdentity = createNewIdentity => {
// DEBUGGING: Log the incoming parameter
const traceId = ZSMLogger.generateTraceId();
ZSMLogger.trace(`[CHECK-SERVER-IDENTITY] called with createNewIdentity: ${createNewIdentity}`, traceId);
ZSMLogger.trace(`[CHECK-SERVER-IDENTITY] customerDefinedIdentifier: ${this.customerDefinedIdentifier}`, traceId);
ZSMLogger.trace(`[CHECK-SERVER-IDENTITY] applicationId: ${this.applicationId}`, traceId);
// If the createNewIdentity parameter is set, set it to an object with the value of "true", otherwise set it to an empty object.
createNewIdentity = createNewIdentity ? { create_new_identity: "true" } : {};
ZSMLogger.trace(`[CHECK-SERVER-IDENTITY] Final object to send: ${JSON.stringify(createNewIdentity)}`, traceId);
this.status='CHECKING_IDENTITY_STARTING';
return this.#makePostRequest("api/umfa/check-identity", createNewIdentity)
.then(result => (this.status='CHECKING_IDENTITY_FINISHED', result))
.catch(error => (this.status='CHECKING_IDENTITY_FAILED', Promise.reject(error)))
}
/**
* Creates identity and starts WebAuthn registration in a single optimized call (UMFA optimized flow).
* This replaces the two-step process of checkServerSideIdentity + registrationStart.
* @param {string} [traceId] - Optional trace ID for end-to-end tracing.
* @returns {Promise<Object>} Resolves with identity creation result and registration challenge.
*/
createIdentityThenRegistrationStart = (traceId = null) => {
this.status='OPTIMIZED_REGISTRATION_START';
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.debug('[OPTIMIZED-FLOW] Starting createIdentityThenRegistrationStart', actualTraceId);
return this.#makePostRequest("api/webauthn/registration/create-identity-then-start", {}, {}, 'POST', actualTraceId)
.then(async data => {
// Store both identity_id from response and publicKey for registration
this.#publicKey = data.ccr.publicKey;
ZSMLogger.debug(`[OPTIMIZED-FLOW] Received identity_id: ${data.identity_id}`, actualTraceId);
ZSMLogger.trace(`[OPTIMIZED-FLOW] Received publicKey for registration`, actualTraceId);
// Handle server identity response for mapping storage
await this.handleServerIdentityResponse(data, this.customerDefinedIdentifier);
return {
identity_id: data.identity_id,
publicKey: data.ccr.publicKey,
ccr: data.ccr
};
})
.then(result => (this.status='OPTIMIZED_REGISTRATION_STARTED', result))
.catch(error => (this.status='OPTIMIZED_REGISTRATION_FAILED', Promise.reject((error instanceof Error) ? error : new Error(error))))
}
/**
* Completes WebAuthn registration and starts authentication in a single call (UMFA optimized flow).
* This replaces the two-step process of registrationFinish + authenticationStart.
* @param {Object} credential - The credential information to finalize registration.
* @param {string} [traceId] - Optional trace ID for end-to-end tracing.
* @returns {Promise<Object>} Resolves with authentication challenge.
*/
registrationFinishAuthenticationStart = (credential, traceId = null) => {
this.status='OPTIMIZED_REGISTRATION_FINISH_AUTH_START';
const actualTraceId = traceId || ZSMLogger.generateTraceId();
ZSMLogger.debug('[OPTIMIZED-FLOW] Starting registrationFinishAuthenticationStart', actualTraceId);
return this.#makePostRequest("api/webauthn/registration/finish-then-authentication-start", { credential }, {}, 'POST', actualTraceId)
.then(result => {
// Store credential ID from the finished registration
this.#credentialID = credential.rawId;
ZSMLogger.debug(`[OPTIMIZED-FLOW] Registration finished and authentication started`, actualTraceId);
return result;
})
.then(result => (this.status='OPTIMIZED_REGISTRATION_FINISH_AUTH_STARTED', result))
.catch(error => (this.status='OPTIMIZED_REGISTRATION_FINISH_AUTH_FAILED', this.clearEnrollmentCredentials(), Promise.reject((error instanceof Error) ? error : new Error(error))))
}
}
export default RelyingPartyBase;