diff --git a/packages/@webex/internal-plugin-mobius-socket/src/mobius-socket.js b/packages/@webex/internal-plugin-mobius-socket/src/mobius-socket.js index bc10125d1bf..5a3d24acbef 100644 --- a/packages/@webex/internal-plugin-mobius-socket/src/mobius-socket.js +++ b/packages/@webex/internal-plugin-mobius-socket/src/mobius-socket.js @@ -19,6 +19,7 @@ import { const normalReconnectReasons = ['idle', 'done (forced)']; const DEFAULT_MOBIUS_WEBSOCKET_SESSION = 'mobius-websocket-session'; +const TOKEN_REFRESH_INTERVAL_MS = 1 * 60 * 60 * 1000; // 1 hour function normalizeMobiusAuthToken(token) { if (typeof token !== 'string') { @@ -75,6 +76,9 @@ const MobiusSocket = WebexPlugin.extend({ }, initialize() { + this._tokenRefreshTimer = undefined; + this._tokenRefreshInFlight = undefined; + /* When one of these legacy feature gets updated, this event would be triggered * group-message-notifications @@ -307,13 +311,21 @@ const MobiusSocket = WebexPlugin.extend({ /** * Check if a socket is connected - * @param {string} [sessionId=this.defaultSessionId] - The session identifier + * @param {string} [sessionId] - Optional session identifier * @returns {boolean|undefined} True if the socket is connected */ - hasConnectedSockets(sessionId = this.defaultSessionId) { - const socket = this.sockets.get(sessionId || this.defaultSessionId); + hasConnectedSockets(sessionId) { + if (sessionId) { + return this.sockets.get(sessionId)?.connected; + } + + for (const socket of this.sockets.values()) { + if (socket?.connected) { + return true; + } + } - return socket?.connected; + return false; }, /** @@ -437,6 +449,9 @@ const MobiusSocket = WebexPlugin.extend({ // Update overall connected status this.connected = this.hasConnectedSockets(); + if (!this.hasConnectedSockets()) { + this._stopTokenRefreshTimer(); + } }); }, @@ -456,6 +471,7 @@ const MobiusSocket = WebexPlugin.extend({ this.connected = false; this.sockets.clear(); this.backoffCalls.clear(); + this._stopTokenRefreshTimer(); // Clear connection promises to prevent stale promises if (this._connectPromises) { this._connectPromises.clear(); @@ -662,6 +678,7 @@ const MobiusSocket = WebexPlugin.extend({ forceCloseDelay: this.config.forceCloseDelay, wssResponseTimeout: this.config.wssResponseTimeout, token: normalizeMobiusAuthToken(token.toString()), + refreshToken: () => this._refreshToken(), trackingId: `${this.webex.sessionId}_${Date.now()}`, logger: this.logger, }; @@ -727,6 +744,7 @@ const MobiusSocket = WebexPlugin.extend({ this.connecting = this.hasConnectingSockets(); this.connected = this.hasConnectedSockets(); this.hasEverConnected = true; + this._startTokenRefreshTimer(); this._emit(sid, 'online'); } @@ -865,6 +883,71 @@ const MobiusSocket = WebexPlugin.extend({ return handlers; }, + _startTokenRefreshTimer() { + if (this._tokenRefreshTimer || !this.hasConnectedSockets()) { + return; + } + + this._tokenRefreshTimer = setInterval(() => { + this._refreshToken().catch((error) => { + this.logger.error(`${this.namespace}: periodic token refresh failed`, error); + }); + }, TOKEN_REFRESH_INTERVAL_MS); + }, + + _stopTokenRefreshTimer() { + if (!this._tokenRefreshTimer) { + return; + } + + clearInterval(this._tokenRefreshTimer); + this._tokenRefreshTimer = undefined; + }, + + _refreshToken() { + if (this._tokenRefreshInFlight) { + return this._tokenRefreshInFlight; + } + + if (!this.hasConnectedSockets()) { + this._stopTokenRefreshTimer(); + + return Promise.resolve(); + } + + const tokenPromise = this.webex.credentials.canRefresh + ? this.webex.credentials + .refresh({force: true}) + .then(() => this.webex.credentials.getUserToken()) + : this.webex.credentials.getUserToken(); + + this._tokenRefreshInFlight = tokenPromise + .then((token) => { + if (!token) { + throw new Error('Mobius token refresh did not return a token'); + } + const refreshedToken = normalizeMobiusAuthToken(token.toString()); + const authPayloadPromises = []; + + for (const socket of this.sockets.values()) { + if (socket?.connected) { + authPayloadPromises.push(socket.refresh(refreshedToken)); + } + } + + return Promise.all(authPayloadPromises); + }) + .catch((error) => { + this.logger.error(`${this.namespace}: failed to refresh/re-auth Mobius sockets`, error); + throw error; + }) + .finally(() => { + this._tokenRefreshInFlight = undefined; + }); + + return this._tokenRefreshInFlight; + }, + _onclose(sessionId, event, sourceSocket) { // I don't see any way to avoid the complexity or statement count in here. /* eslint complexity: [0] */ @@ -891,6 +974,9 @@ const MobiusSocket = WebexPlugin.extend({ // Update overall connected status this.connecting = this.hasConnectingSockets(); this.connected = this.hasConnectedSockets(); + if (!this.hasConnectedSockets()) { + this._stopTokenRefreshTimer(); + } } else { // Old socket closed; do not flip connection state this.logger.info( diff --git a/packages/@webex/internal-plugin-mobius-socket/src/socket/socket-base.js b/packages/@webex/internal-plugin-mobius-socket/src/socket/socket-base.js index 466811934d0..a2e8f399fcf 100644 --- a/packages/@webex/internal-plugin-mobius-socket/src/socket/socket-base.js +++ b/packages/@webex/internal-plugin-mobius-socket/src/socket/socket-base.js @@ -267,7 +267,7 @@ export default class Socket extends EventEmitter { socket.onopen = () => { this.logger.info(`socket,${this._domain}: connected`); - this._authorize() + this._authorize(this.token) .then(() => { this.logger.info(`socket,${this._domain}: authorized`); socket.onclose = this.onclose; @@ -459,19 +459,29 @@ export default class Socket extends EventEmitter { }); } + refresh(token) { + if (!token) { + return Promise.reject(new Error('`token` is required for Socket#refresh()')); + } + + const refreshedToken = token && typeof token.toString === 'function' ? token.toString() : token; + + return this._authorize(refreshedToken); + } + /** - * Sends an auth message up the socket - * @private + * Sends an auth message up the socket with a refreshed token. + * @param {string} token * @returns {Promise} */ - _authorize() { + _authorize(token) { this.logger.info(`socket,${this._domain}: authorizing`); return this.sendRequest( { type: MESSAGE_TYPES.AUTH, data: { - token: this.token, + token, }, }, { @@ -559,6 +569,18 @@ export default class Socket extends EventEmitter { const statusCode = pendingResponse.getStatusCode(response); const statusMessage = pendingResponse.getStatusMessage(response); + if (statusCode === 440 && response?.subtype !== MESSAGE_TYPES.AUTH) { + if (typeof this.refreshToken === 'function') { + Promise.resolve(this.refreshToken(response)).catch((error) => { + this.logger.warn(`socket,${this._domain}: failed token-expiry re-auth`, error); + }); + } else { + this.logger.warn( + `socket,${this._domain}: refreshToken callback is unavailable for statusCode 440` + ); + } + } + if (statusCode === undefined) { pendingResponse.reject( pendingResponse.createError(