UNPKG

@webex/webex-core

Version:

Plugin handling for Cisco Webex

1,011 lines (882 loc) • 31.9 kB
import sha256 from 'crypto-js/sha256'; import {union, unionBy} from 'lodash'; import WebexPlugin from '../webex-plugin'; import METRICS from '../metrics'; import ServiceCatalog from './service-catalog'; import fedRampServices from './service-fed-ramp'; import {COMMERCIAL_ALLOWED_DOMAINS} from '../constants'; import { ActiveServices, IServiceCatalog, QueryOptions, Service, ServiceHostmap, ServiceGroup, } from './types'; const trailingSlashes = /(?:^\/)|(?:\/$)/; // The default cluster when one is not provided (usually as 'US' from hydra) export const DEFAULT_CLUSTER = 'urn:TEAM:us-east-2_a'; // The default service name for convo (currently identityLookup due to some weird CSB issue) export const DEFAULT_CLUSTER_SERVICE = 'identityLookup'; const CLUSTER_SERVICE = process.env.WEBEX_CONVERSATION_CLUSTER_SERVICE || DEFAULT_CLUSTER_SERVICE; const DEFAULT_CLUSTER_IDENTIFIER = process.env.WEBEX_CONVERSATION_DEFAULT_CLUSTER || `${DEFAULT_CLUSTER}:${CLUSTER_SERVICE}`; /* eslint-disable no-underscore-dangle */ /** * @class */ const Services = WebexPlugin.extend({ namespace: 'Services', props: { validateDomains: ['boolean', false, true], initFailed: ['boolean', false, false], }, _catalogs: new WeakMap(), _activeServices: {}, _services: [], /** * @private * Get the current catalog based on the assocaited * webex instance. * @returns {IServiceCatalog} */ _getCatalog(): IServiceCatalog { return this._catalogs.get(this.webex); }, /** * Get a service url from the current services list by name * from the associated instance catalog. * @param {string} name * @param {ServiceGroup} [serviceGroup] * @returns {string|undefined} */ get(name: string, serviceGroup?: ServiceGroup): string | undefined { const catalog = this._getCatalog(); const clusterId = this._activeServices[name]; const urlById = catalog.get(clusterId, serviceGroup); const urlByName = catalog.get(name, serviceGroup); // if both are undefined, then we cannot find the service if (!urlById && !urlByName) { return undefined; } return urlById || urlByName; }, /** * Determine if a whilelist exists in the service catalog. * * @returns {boolean} - True if a allowed domains list exists. */ hasAllowedDomains(): boolean { const catalog = this._getCatalog(); return catalog.getAllowedDomains().length > 0; }, /** * Mark a priority host service url as failed. * This will mark the service url associated with the * `ServiceDetail` to be removed from the its * respective service url array, and then return the next * viable service url from the `ServiceDetail` service url array. * @param {string} url * @returns {string} */ markFailedUrl(url: string): string | undefined { const catalog = this._getCatalog(); return catalog.markFailedServiceUrl(url); }, /** * saves all the services from the pre and post catalog service * @param {ActiveServices} activeServices * @returns {void} */ _updateActiveServices(activeServices: ActiveServices): void { this._activeServices = {...this._activeServices, ...activeServices}; }, /** * saves the hostCatalog object * @param {Array<Service>} services * @returns {void} */ _updateServices(services: Array<Service>): void { this._services = unionBy(services, this._services, 'id'); }, /** * Update a list of `serviceUrls` to the most current * catalog via the defined `discoveryUrl` then returns the current * list of services. * @param {object} [param] * @param {string} [param.from] - This accepts `limited` or `signin` * @param {object} [param.query] - This accepts `email`, `orgId` or `userId` key values * @param {string} [param.query.email] - must be a standard-format email * @param {string} [param.query.orgId] - must be an organization id * @param {string} [param.query.userId] - must be a user id * @param {string} [param.token] - used for signin catalog * @returns {Promise<object>} */ updateServices( {from, query, token, forceRefresh} = {} as { from: string; query: QueryOptions; token: string; forceRefresh: boolean; } ): Promise<object> { const catalog = this._getCatalog(); let formattedQuery; let serviceGroup; // map catalog name to service group name. switch (from) { case 'limited': serviceGroup = 'preauth'; break; case 'signin': serviceGroup = 'signin'; break; default: serviceGroup = 'postauth'; break; } // confirm catalog update for group is not in progress. if (catalog.status[serviceGroup].collecting) { return this.waitForCatalog(serviceGroup); } catalog.status[serviceGroup].collecting = true; if (serviceGroup === 'preauth') { const queryKey = query && Object.keys(query)[0]; if (!['email', 'emailhash', 'userId', 'orgId', 'mode'].includes(queryKey)) { return Promise.reject( new Error('a query param of email, emailhash, userId, orgId, or mode is required') ); } } // encode email when query key is email if (serviceGroup === 'preauth' || serviceGroup === 'signin') { const queryKey = Object.keys(query)[0]; formattedQuery = {}; if (queryKey === 'email' && query.email) { formattedQuery.emailhash = sha256(query.email.toLowerCase()).toString(); } else { formattedQuery[queryKey] = query[queryKey]; } } return this._fetchNewServiceHostmap({ from, token, query: formattedQuery, forceRefresh, }) .then((serviceHostMap: ServiceHostmap) => { catalog.updateServiceGroups(serviceGroup, serviceHostMap); this.updateCredentialsConfig(); catalog.status[serviceGroup].collecting = false; }) .catch((error) => { catalog.status[serviceGroup].collecting = false; return Promise.reject(error); }); }, /** * User validation parameter transfer object for {@link validateUser}. * @param {object} ValidateUserPTO * @property {string} ValidateUserPTO.email - The email of the user. * @property {string} [ValidateUserPTO.reqId] - The activation requester. * @property {object} [ValidateUserPTO.activationOptions] - Extra options to pass when sending the activation * @property {object} [ValidateUserPTO.preloginUserId] - The prelogin user id to set when sending the activation. */ /** * User validation return transfer object for {@link validateUser}. * @param {object} ValidateUserRTO * @property {boolean} ValidateUserRTO.activated - If the user is activated. * @property {boolean} ValidateUserRTO.exists - If the user exists. * @property {string} ValidateUserRTO.details - A descriptive status message. * @property {object} ValidateUserRTO.user - **License** service user object. */ /** * Validate if a user is activated and update the service catalogs as needed * based on the user's activation status. * * @param {ValidateUserPTO} - The parameter transfer object. * @returns {ValidateUserRTO} - The return transfer object. */ validateUser({ email, reqId = 'WEBCLIENT', forceRefresh = false, activationOptions = {}, preloginUserId, }) { this.logger.info('services: validating a user'); // Validate that an email parameter key was provided. if (!email) { return Promise.reject(new Error('`email` is required')); } // Destructure the credentials object. const {canAuthorize} = this.webex.credentials; // Validate that the user is already authorized. if (canAuthorize) { return this.updateServices({forceRefresh}) .then(() => this.webex.credentials.getUserToken()) .then((token) => this.sendUserActivation({ email, reqId, token: token.toString(), activationOptions, preloginUserId, }) ) .then((userObj) => ({ activated: true, exists: true, details: 'user is authorized via a user token', user: userObj, })); } // Destructure the client authorization details. /* eslint-disable camelcase */ const {client_id, client_secret} = this.webex.credentials.config; // Validate that client authentication details exist. if (!client_id || !client_secret) { return Promise.reject(new Error('client authentication details are not available')); } /* eslint-enable camelcase */ // Declare a class-memeber-scoped token for usage within the promise chain. let token; // Begin client authentication user validation. return ( this.collectPreauthCatalog({email}) .then(() => { // Retrieve the service url from the updated catalog. This is required // since `WebexCore` is usually not fully initialized at the time this // request completes. const idbrokerService = this.get('idbroker'); // Collect the client auth token. return this.webex.credentials.getClientToken({ uri: `${idbrokerService}idb/oauth2/v1/access_token`, scope: 'webexsquare:admin webexsquare:get_conversation Identity:SCIM', }); }) .then((tokenObj) => { // Generate the token string. token = tokenObj.toString(); // Collect the signin catalog using the client auth information. return this.collectSigninCatalog({email, token, forceRefresh}); }) // Validate if collecting the signin catalog failed and populate the RTO // with the appropriate content. .catch((error) => ({ exists: error.name !== 'NotFound', activated: false, details: error.name !== 'NotFound' ? 'user exists but is not activated' : 'user does not exist and is not activated', })) // Validate if the previous promise resolved with an RTO and populate the // new RTO accordingly. .then((rto) => Promise.all([ rto || { activated: true, exists: true, details: 'user exists and is activated', }, this.sendUserActivation({ email, reqId, token, activationOptions, preloginUserId, }), ]) ) .then(([rto, user]) => ({...rto, user})) .catch((error) => { const response = { statusCode: error.statusCode, responseText: error.body && error.body.message, body: error.body, }; return Promise.reject(response); }) ); }, /** * Get user meeting preferences (preferred webex site). * * @returns {object} - User Information including user preferrences . */ getMeetingPreferences() { return this.request({ method: 'GET', service: 'hydra', resource: 'meetingPreferences', }) .then((res) => { this.logger.info('services: received user region info'); return res.body; }) .catch((err) => { this.logger.info('services: was not able to fetch user login information', err); // resolve successfully even if request failed }); }, /** * Fetches client region info such as countryCode and timezone. * * @returns {object} - The region info object. */ fetchClientRegionInfo() { const {services} = this.webex.config; return this.request({ uri: services.discovery.sqdiscovery, addAuthHeader: false, headers: { 'spark-user-agent': null, }, timeout: 5000, }) .then((res) => { this.logger.info('services: received user region info'); return res.body; }) .catch((err) => { this.logger.info('services: was not able to get user region info', err); // resolve successfully even if request failed }); }, /** * User activation parameter transfer object for {@link sendUserActivation}. * @typedef {object} SendUserActivationPTO * @property {string} SendUserActivationPTO.email - The email of the user. * @property {string} SendUserActivationPTO.reqId - The activation requester. * @property {string} SendUserActivationPTO.token - The client auth token. * @property {object} SendUserActivationPTO.activationOptions - Extra options to pass when sending the activation. * @property {object} SendUserActivationPTO.preloginUserId - The prelogin user id to set when sending the activation. */ /** * Send a request to activate a user using a client token. * * @param {SendUserActivationPTO} - The Parameter transfer object. * @returns {LicenseDTO} - The DTO returned from the **License** service. */ sendUserActivation({email, reqId, token, activationOptions, preloginUserId}) { this.logger.info('services: sending user activation request'); let countryCode; let timezone; // try to fetch client region info first return ( this.fetchClientRegionInfo() .then((clientRegionInfo) => { if (clientRegionInfo) { ({countryCode, timezone} = clientRegionInfo); } // Send the user activation request to the **License** service. return this.request({ service: 'license', resource: 'users/activations', method: 'POST', headers: { accept: 'application/json', authorization: token, 'x-prelogin-userid': preloginUserId, }, body: { email, reqId, countryCode, timeZone: timezone, ...activationOptions, }, shouldRefreshAccessToken: false, }); }) // On success, return the **License** user object. .then(({body}) => body) // On failure, reject with error from **License**. .catch((error) => Promise.reject(error)) ); }, /** * Updates a given service group i.e. preauth, signin, postauth with a new hostmap. * @param {ServiceGroup} serviceGroup - preauth, signin, postauth * @param {ServiceHostmap} hostMap - The new hostmap to update the service group with. * @returns {Promise<void>} */ updateCatalog(serviceGroup: ServiceGroup, hostMap: ServiceHostmap): Promise<void> { const catalog = this._getCatalog(); const serviceHostMap = this._formatReceivedHostmap(hostMap); return catalog.updateServiceGroups(serviceGroup, serviceHostMap); }, /** * simplified method to update the preauth catalog via email * * @param {object} query * @param {string} query.email - A standard format email. * @param {string} query.orgId - The user's OrgId. * @param {boolean} forceRefresh - Boolean to bypass u2c cache control header * @returns {Promise<void>} */ collectPreauthCatalog(query: QueryOptions, forceRefresh = false) { if (!query) { return this.updateServices({ from: 'limited', query: {mode: 'DEFAULT_BY_PROXIMITY'}, forceRefresh, }); } return this.updateServices({from: 'limited', query, forceRefresh}); }, /** * simplified method to update the signin catalog via email and token * @param {object} param * @param {string} param.email - must be a standard-format email * @param {string} param.token - must be a client token * @returns {Promise<void>} */ collectSigninCatalog( {email, token, forceRefresh} = {} as {email: string; token: string; forceRefresh: boolean} ): Promise<void> { if (!email) { return Promise.reject(new Error('`email` is required')); } if (!token) { return Promise.reject(new Error('`token` is required')); } return this.updateServices({ from: 'signin', query: {email}, token, forceRefresh, }); }, /** * Updates credentials config to utilize u2c catalog * urls. * @returns {void} */ updateCredentialsConfig(): void { const idbrokerUrl = this.get('idbroker'); const identityUrl = this.get('identity'); if (idbrokerUrl && identityUrl) { const {authorizationString, authorizeUrl} = this.webex.config.credentials; // This must be set outside of the setConfig method used to assign the // idbroker and identity url values. this.webex.config.credentials.authorizeUrl = authorizationString ? authorizeUrl : `${idbrokerUrl.replace(trailingSlashes, '')}/idb/oauth2/v1/authorize`; this.webex.setConfig({ credentials: { idbroker: { url: idbrokerUrl.replace(trailingSlashes, ''), // remove trailing slash }, identity: { url: identityUrl.replace(trailingSlashes, ''), // remove trailing slash }, }, }); } }, /** * Wait until the service catalog is available, * or reject afte ra timeout of 60 seconds. * @param {ServiceGroup} serviceGroup * @param {number} [timeout] - in seconds * @returns {Promise<void>} */ waitForCatalog(serviceGroup: ServiceGroup, timeout: number): Promise<void> { const catalog = this._getCatalog(); const {supertoken} = this.webex.credentials; if ( serviceGroup === 'postauth' && supertoken && supertoken.access_token && !catalog.status.postauth.collecting && !catalog.status.postauth.ready ) { if (!catalog.status.preauth.ready) { return this.initServiceCatalogs(); } return this.updateServices(); } return catalog.waitForCatalog(serviceGroup, timeout); }, /** * Service waiting parameter transfer object for {@link waitForService}. * * @typedef {object} WaitForServicePTO * @property {string} [WaitForServicePTO.name] - The service name. * @property {string} [WaitForServicePTO.url] - The service url. * @property {string} [WaitForServicePTO.timeout] - wait duration in seconds. */ /** * Wait until the service has been ammended to any service catalog. This * method prioritizes the service name over the service url when searching. * * @param {WaitForServicePTO} - The parameter transfer object. * @returns {Promise<string>} - Resolves to the priority host of a service. */ waitForService({ name, timeout = 5, url, }: { name: string; timeout: number; url: string; }): Promise<string> { const {services} = this.webex.config; // Save memory by grabbing the catalog after there isn't a priortyURL const catalog = this._getCatalog(); const fetchFromServiceUrl = services.servicesNotNeedValidation.find( (service) => service === name ); if (fetchFromServiceUrl) { const clusterId = this._activeServices[name]; return Promise.resolve(this.get(clusterId)); } const priorityUrl = this.get(name); const priorityUrlObj = this.getServiceFromUrl(url); if (priorityUrl || priorityUrlObj) { return Promise.resolve(priorityUrl || priorityUrlObj.priorityUrl); } if (catalog.isReady) { if (url) { return Promise.resolve(url); } this.webex.internal.metrics.submitClientMetrics(METRICS.JS_SDK_SERVICE_NOT_FOUND, { fields: {service_name: name}, }); return Promise.reject( new Error(`services: service '${name}' was not found in any of the catalogs`) ); } return new Promise((resolve, reject) => { const groupsToCheck = ['preauth', 'signin', 'postauth']; const checkCatalog = (catalogGroup) => catalog .waitForCatalog(catalogGroup, timeout) .then(() => { const scopedPriorityUrl = this.get(name); const scopedPrioriryUrlObj = this.getServiceFromUrl(url); if (scopedPriorityUrl || scopedPrioriryUrlObj) { resolve(scopedPriorityUrl || scopedPrioriryUrlObj.priorityUrl); } }) .catch(() => undefined); Promise.all(groupsToCheck.map((group) => checkCatalog(group))).then(() => { this.webex.internal.metrics.submitClientMetrics(METRICS.JS_SDK_SERVICE_NOT_FOUND, { fields: {service_name: name}, }); reject(new Error(`services: service '${name}' was not found after waiting`)); }); }); }, /** * Looks up the hostname in the host catalog * and replaces it with the first host if it finds it * @param {string} uri * @returns {string} uri with the host replaced */ replaceHostFromHostmap(uri: string): string { try { return this.convertUrlToPriorityHostUrl(uri); } catch { return uri; } }, /** * Formats a host map entry for use in service catalog. * * @param {Object} entry - The host map entry to format. * @param {string} entry.serviceName - i.e. conversation, identity, etc. * @param {string} entry.id - The unique identifier for the service, usually clusterId. * @param {Array<IServiceDetail>} entry.serviceUrls - The group to which the service belongs. * @returns {Object} - The formatted host map entry. */ _formatHostMapEntry({id, serviceName, serviceUrls}) { const formattedServiceUrls = serviceUrls.map((serviceUrl) => ({ host: new URL(serviceUrl.baseUrl).host, ...serviceUrl, })); return { id, serviceName, serviceUrls: formattedServiceUrls, }; }, /** * @private * Organize a received hostmap from a service * @param {ServiceHostmap} serviceHostmap * catalog endpoint. * @returns {Array<Service>} */ _formatReceivedHostmap({services, activeServices}) { const formattedHostmap = services.map((service) => this._formatHostMapEntry(service)); this._updateActiveServices(activeServices); this._updateServices(services); return formattedHostmap; }, /** * Get the clusterId associated with a URL string. * @param {string} url * @returns {string | undefined} - Cluster ID of url provided */ getClusterId(url: string): string | undefined { const catalog = this._getCatalog(); return catalog.findClusterId(url); }, /** * Get a service value from a provided clusterId. This method will * return an object containing both the name and url of a found service. * @param {object} params * @param {string} params.clusterId - clusterId of found service * @param {ServiceGroup} [params.serviceGroup] - specify service group * @returns {object} service * @returns {string} service.name * @returns {string} service.url */ getServiceFromClusterId(params: { clusterId: string; serviceGroup?: ServiceGroup; }): {name: string; url: string} | undefined { const catalog = this._getCatalog(); return catalog.findServiceFromClusterId(params); }, /** * @param {String} cluster the cluster containing the id * @param {UUID} [id] the id of the conversation. * If empty, just return the base URL. * @returns {String} url of the service */ getServiceUrlFromClusterId({cluster = 'us'}: {cluster?: string} = {}): string { let clusterId = cluster === 'us' ? DEFAULT_CLUSTER_IDENTIFIER : cluster; // Determine if cluster has service name (non-US clusters from hydra do not) if (clusterId.split(':').length < 4) { // Add Service to cluster identifier clusterId = `${cluster}:${CLUSTER_SERVICE}`; } const {url} = this.getServiceFromClusterId({clusterId}) || {}; if (!url) { throw Error(`Could not find service for cluster [${cluster}]`); } return url; }, /** * Get a service object from a service url if the service url exists in the * catalog. * * @param {string} url - The url to be validated. * @returns {object} - Service object. * @returns {object.name} - The name of the service found. * @returns {object.priorityUrl} - The default url of the found service. * @returns {object.defaultUrl} - The default url of the found service. */ getServiceFromUrl(url = ''): {name: string; priorityUrl: string; defaultUrl: string} | undefined { const service = this._getCatalog().findServiceDetailFromUrl(url); if (!service) { return undefined; } const priorityUrl = service.get(); const defaultUrl = new URL( service.serviceUrls.find((serviceUrl) => url.startsWith(serviceUrl.baseUrl)).baseUrl ).href; return { name: service.serviceName, priorityUrl, defaultUrl, }; }, /** * Determine if a provided url is in the catalog's allowed domains. * * @param {string} url - The url to match allowed domains against. * @returns {boolean} - True if the url provided is allowed. */ isAllowedDomainUrl(url: string): boolean { const catalog = this._getCatalog(); return !!catalog.findAllowedDomain(url); }, /** * Converts the host portion of the url from default host * to a priority host * * @param {string} url a service url that contains a default host * @returns {string} a service url that contains the top priority host. * @throws if url isn't a service url */ convertUrlToPriorityHostUrl(url = '' as string): string { const data = this.getServiceFromUrl(url); if (!data) { throw Error(`No service associated with url: [${url}]`); } return url.replace(data.defaultUrl, data.priorityUrl); }, /** * @private * Simplified method wrapper for sending a request to get * an updated service hostmap. * @param {object} [param] * @param {string} [param.from] - This accepts `limited` or `signin` * @param {object} [param.query] - This accepts `email`, `orgId` or `userId` key values * @param {string} [param.query.email] - must be a standard-format email * @param {string} [param.query.orgId] - must be an organization id * @param {string} [param.query.userId] - must be a user id * @param {string} [param.token] - used for signin catalog * @returns {Promise<object>} */ _fetchNewServiceHostmap( {from, query, token, forceRefresh} = {} as { from: string; query: QueryOptions; token: string; forceRefresh: boolean; } ): Promise<object> { const service = 'u2c'; const resource = from ? `/${from}/catalog` : '/catalog'; const qs = {...(query || {}), format: 'U2CV2'}; if (forceRefresh) { qs.timestamp = new Date().getTime(); } const requestObject = { method: 'GET', service, resource, qs, headers: {}, }; if (token) { requestObject.headers = {authorization: token}; } return this.webex.internal.newMetrics.callDiagnosticLatencies .measureLatency(() => this.request(requestObject), 'internal.get.u2c.time') .then(({body}) => this._formatReceivedHostmap(body)); }, /** * Initialize the discovery services and the whitelisted services. * * @returns {void} */ initConfig(): void { // Get the catalog and destructure the services config. const catalog = this._getCatalog(); const {services, fedramp} = this.webex.config; // Validate that the services configuration exists. if (services) { if (fedramp) { services.discovery = fedRampServices; } // Check for discovery services. if (services.discovery) { // Format the discovery configuration into an injectable array. const formattedDiscoveryServices = Object.keys(services.discovery).map((key) => this._formatHostMapEntry({ id: key, serviceName: key, serviceUrls: [{baseUrl: services.discovery[key], priority: 1}], }) ); // Inject formatted discovery services into services catalog. catalog.updateServiceGroups('discovery', formattedDiscoveryServices); } if (services.override) { // Format the override configuration into an injectable array. const formattedOverrideServices = Object.keys(services.override).map((key) => this._formatHostMapEntry({ id: key, serviceName: key, serviceUrls: [{baseUrl: services.override[key], priority: 1}], }) ); // Inject formatted override services into services catalog. catalog.updateServiceGroups('override', formattedOverrideServices); } // if not fedramp, append on the commercialAllowedDomains if (!fedramp) { services.allowedDomains = union(services.allowedDomains, COMMERCIAL_ALLOWED_DOMAINS); } // Check for allowed host domains. if (services.allowedDomains) { // Store the allowed domains as a property of the catalog. catalog.setAllowedDomains(services.allowedDomains); } // Set `validateDomains` property to match configuration this.validateDomains = services.validateDomains; } }, /** * Make the initial requests to collect the root catalogs. * * @returns {Promise<void, Error>} - Errors if the token is unavailable. */ initServiceCatalogs(): Promise<void> { this.logger.info('services: initializing initial service catalogs'); // Destructure the credentials plugin. const {credentials} = this.webex; // Init a promise chain. Must be done as a Promise.resolve() to allow // credentials#getOrgId() to properly throw. return ( Promise.resolve() // Get the user's OrgId. .then(() => credentials.getOrgId()) // Begin collecting the preauth/limited catalog. .then((orgId) => this.collectPreauthCatalog({orgId})) .then(() => { // Validate if the token is authorized. if (credentials.canAuthorize) { // Attempt to collect the postauth catalog. return this.updateServices().catch(() => { this.initFailed = true; this.logger.warn('services: cannot retrieve postauth catalog'); }); } // Return a resolved promise for consistent return value. return Promise.resolve(); }) ); }, /** * Initializer * * @instance * @memberof Services * @returns {Services} */ initialize(): typeof Services { const catalog = new ServiceCatalog(); this._catalogs.set(this.webex, catalog); // Listen for configuration changes once. this.listenToOnce(this.webex, 'change:config', () => { this.initConfig(); }); // wait for webex instance to be ready before attempting // to update the service catalogs this.listenToOnce(this.webex, 'ready', () => { const {supertoken} = this.webex.credentials; // Validate if the supertoken exists. if (supertoken && supertoken.access_token) { this.initServiceCatalogs() .then(() => { catalog.isReady = true; }) .catch((error) => { this.initFailed = true; this.logger.error( `services: failed to init initial services when credentials available, ${error?.message}` ); }); } else { const {email} = this.webex.config; this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => { this.initFailed = true; this.logger.error( `services: failed to init initial services when no credentials available, ${error?.message}` ); }); } }); }, }); /* eslint-enable no-underscore-dangle */ export default Services;