// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview * Connect set-up state machine for Me2Me and IT2Me */ 'use strict'; /** @suppress {duplicate} */ var remoting = remoting || {}; /** * @param {Element} pluginParent The node under which to add the client plugin. * @param {function(remoting.ClientSession):void} onOk Callback on success. * @param {function(remoting.Error):void} onError Callback on error. * @constructor */ remoting.SessionConnector = function(pluginParent, onOk, onError) { /** * @type {Element} * @private */ this.pluginParent_ = pluginParent; /** * @type {function(remoting.ClientSession):void} * @private */ this.onOk_ = onOk; /** * @type {function(remoting.Error):void} * @private */ this.onError_ = onError; /** * @type {string} * @private */ this.clientJid_ = ''; /** * @type {remoting.ClientSession.Mode} * @private */ this.connectionMode_ = remoting.ClientSession.Mode.ME2ME; // Initialize/declare per-connection state. this.reset(); }; /** * Reset the per-connection state so that the object can be re-used for a * second connection. Note the none of the shared WCS state is reset. */ remoting.SessionConnector.prototype.reset = function() { /** * Set to true to indicate that the user requested pairing when entering * their PIN for a Me2Me connection. * * @type {boolean} */ this.pairingRequested = false; /** * String used to identify the host to which to connect. For IT2Me, this is * the first 7 digits of the access code; for Me2Me it is the host identifier. * * @type {string} * @private */ this.hostId_ = ''; /** * For paired connections, the client id of this device, issued by the host. * * @type {string} * @private */ this.clientPairingId_ = ''; /** * For paired connections, the paired secret for this device, issued by the * host. * * @type {string} * @private */ this.clientPairedSecret_ = ''; /** * String used to authenticate to the host on connection. For IT2Me, this is * the access code; for Me2Me it is the PIN. * * @type {string} * @private */ this.passPhrase_ = ''; /** * @type {string} * @private */ this.hostJid_ = ''; /** * @type {string} * @private */ this.hostPublicKey_ = ''; /** * @type {boolean} * @private */ this.refreshHostJidIfOffline_ = false; /** * @type {remoting.ClientSession} * @private */ this.clientSession_ = null; /** * @type {XMLHttpRequest} * @private */ this.pendingXhr_ = null; /** * Function to interactively obtain the PIN from the user. * @type {function(boolean, function(string):void):void} * @private */ this.fetchPin_ = function(onPinFetched) {}; /** * @type {function(string, string, string, * function(string, string):void): void} * @private */ this.fetchThirdPartyToken_ = function( tokenUrl, scope, onThirdPartyTokenFetched) {}; /** * Host 'name', as displayed in the client tool-bar. For a Me2Me connection, * this is the name of the host; for an IT2Me connection, it is the email * address of the person sharing their computer. * * @type {string} * @private */ this.hostDisplayName_ = ''; }; /** * Initiate a Me2Me connection. * * @param {remoting.Host} host The Me2Me host to which to connect. * @param {function(boolean, function(string):void):void} fetchPin Function to * interactively obtain the PIN from the user. * @param {function(string, string, string, * function(string, string): void): void} * fetchThirdPartyToken Function to obtain a token from a third party * authenticaiton server. * @param {string} clientPairingId The client id issued by the host when * this device was paired, if it is already paired. * @param {string} clientPairedSecret The shared secret issued by the host when * this device was paired, if it is already paired. * @return {void} Nothing. */ remoting.SessionConnector.prototype.connectMe2Me = function(host, fetchPin, fetchThirdPartyToken, clientPairingId, clientPairedSecret) { this.connectMe2MeInternal_( host.hostId, host.jabberId, host.publicKey, host.hostName, fetchPin, fetchThirdPartyToken, clientPairingId, clientPairedSecret, true); }; /** * Update the pairing info so that the reconnect function will work correctly. * * @param {string} clientId The paired client id. * @param {string} sharedSecret The shared secret. */ remoting.SessionConnector.prototype.updatePairingInfo = function(clientId, sharedSecret) { this.clientPairingId_ = clientId; this.clientPairedSecret_ = sharedSecret; }; /** * Initiate a Me2Me connection. * * @param {string} hostId ID of the Me2Me host. * @param {string} hostJid XMPP JID of the host. * @param {string} hostPublicKey Public Key of the host. * @param {string} hostDisplayName Display name (friendly name) of the host. * @param {function(boolean, function(string):void):void} fetchPin Function to * interactively obtain the PIN from the user. * @param {function(string, string, string, * function(string, string): void): void} * fetchThirdPartyToken Function to obtain a token from a third party * authenticaiton server. * @param {string} clientPairingId The client id issued by the host when * this device was paired, if it is already paired. * @param {string} clientPairedSecret The shared secret issued by the host when * this device was paired, if it is already paired. * @param {boolean} refreshHostJidIfOffline Whether to refresh the JID and retry * the connection if the current JID is offline. * @return {void} Nothing. * @private */ remoting.SessionConnector.prototype.connectMe2MeInternal_ = function(hostId, hostJid, hostPublicKey, hostDisplayName, fetchPin, fetchThirdPartyToken, clientPairingId, clientPairedSecret, refreshHostJidIfOffline) { // Cancel any existing connect operation. this.cancel(); this.hostId_ = hostId; this.hostJid_ = hostJid; this.hostPublicKey_ = hostPublicKey; this.fetchPin_ = fetchPin; this.fetchThirdPartyToken_ = fetchThirdPartyToken; this.hostDisplayName_ = hostDisplayName; this.connectionMode_ = remoting.ClientSession.Mode.ME2ME; this.refreshHostJidIfOffline_ = refreshHostJidIfOffline; this.updatePairingInfo(clientPairingId, clientPairedSecret); this.createSession_(); }; /** * Initiate an IT2Me connection. * * @param {string} accessCode The access code as entered by the user. * @return {void} Nothing. */ remoting.SessionConnector.prototype.connectIT2Me = function(accessCode) { var kSupportIdLen = 7; var kHostSecretLen = 5; var kAccessCodeLen = kSupportIdLen + kHostSecretLen; // Cancel any existing connect operation. this.cancel(); var normalizedAccessCode = this.normalizeAccessCode_(accessCode); if (normalizedAccessCode.length != kAccessCodeLen) { this.onError_(remoting.Error.INVALID_ACCESS_CODE); return; } this.hostId_ = normalizedAccessCode.substring(0, kSupportIdLen); this.passPhrase_ = normalizedAccessCode; this.connectionMode_ = remoting.ClientSession.Mode.IT2ME; remoting.identity.callWithToken(this.connectIT2MeWithToken_.bind(this), this.onError_); }; /** * Reconnect a closed connection. * * @return {void} Nothing. */ remoting.SessionConnector.prototype.reconnect = function() { if (this.connectionMode_ == remoting.ClientSession.Mode.IT2ME) { console.error('reconnect not supported for IT2Me.'); return; } this.connectMe2MeInternal_( this.hostId_, this.hostJid_, this.hostPublicKey_, this.hostDisplayName_, this.fetchPin_, this.fetchThirdPartyToken_, this.clientPairingId_, this.clientPairedSecret_, true); }; /** * Cancel a connection-in-progress. */ remoting.SessionConnector.prototype.cancel = function() { if (this.clientSession_) { this.clientSession_.removePlugin(); this.clientSession_ = null; } if (this.pendingXhr_) { this.pendingXhr_.abort(); this.pendingXhr_ = null; } this.reset(); }; /** * Get the connection mode (Me2Me or IT2Me) * * @return {remoting.ClientSession.Mode} */ remoting.SessionConnector.prototype.getConnectionMode = function() { return this.connectionMode_; }; /** * Get host ID. * * @return {string} */ remoting.SessionConnector.prototype.getHostId = function() { return this.hostId_; }; /** * Get host display name. * * @return {string} */ remoting.SessionConnector.prototype.getHostDisplayName = function() { return this.hostDisplayName_; }; /** * Continue an IT2Me connection once an access token has been obtained. * * @param {string} token An OAuth2 access token. * @return {void} Nothing. * @private */ remoting.SessionConnector.prototype.connectIT2MeWithToken_ = function(token) { // Resolve the host id to get the host JID. this.pendingXhr_ = remoting.xhr.get( remoting.settings.DIRECTORY_API_BASE_URL + '/support-hosts/' + encodeURIComponent(this.hostId_), this.onIT2MeHostInfo_.bind(this), '', { 'Authorization': 'OAuth ' + token }); }; /** * Continue an IT2Me connection once the host JID has been looked up. * * @param {XMLHttpRequest} xhr The server response to the support-hosts query. * @return {void} Nothing. * @private */ remoting.SessionConnector.prototype.onIT2MeHostInfo_ = function(xhr) { this.pendingXhr_ = null; if (xhr.status == 200) { var host = /** @type {{data: {jabberId: string, publicKey: string}}} */ jsonParseSafe(xhr.responseText); if (host && host.data && host.data.jabberId && host.data.publicKey) { this.hostJid_ = host.data.jabberId; this.hostPublicKey_ = host.data.publicKey; this.hostDisplayName_ = this.hostJid_.split('/')[0]; this.createSession_(); return; } else { console.error('Invalid "support-hosts" response from server.'); } } else { this.onError_(this.translateSupportHostsError(xhr.status)); } }; /** * Creates ClientSession object. */ remoting.SessionConnector.prototype.createSession_ = function() { // In some circumstances, the WCS <iframe> can get reloaded, which results // in a new clientJid and a new callback. In this case, remove the old // client plugin before instantiating a new one. if (this.clientSession_) { this.clientSession_.removePlugin(); this.clientSession_ = null; } var authenticationMethods = 'third_party,spake2_pair,spake2_hmac,spake2_plain'; this.clientSession_ = new remoting.ClientSession( this.passPhrase_, this.fetchPin_, this.fetchThirdPartyToken_, authenticationMethods, this.hostId_, this.hostJid_, this.hostPublicKey_, this.connectionMode_, this.clientPairingId_, this.clientPairedSecret_); this.clientSession_.logHostOfflineErrors(!this.refreshHostJidIfOffline_); this.clientSession_.setOnStateChange(this.onStateChange_.bind(this)); this.clientSession_.createPluginAndConnect(this.pluginParent_); }; /** * Handle a change in the state of the client session prior to successful * connection (after connection, this class no longer handles state change * events). Errors that occur while connecting either trigger a reconnect * or notify the onError handler. * * @param {number} oldState The previous state of the plugin. * @param {number} newState The current state of the plugin. * @return {void} Nothing. * @private */ remoting.SessionConnector.prototype.onStateChange_ = function(oldState, newState) { switch (newState) { case remoting.ClientSession.State.CONNECTED: // When the connection succeeds, deregister for state-change callbacks // and pass the session to the onOk callback. It is expected that it // will register a new state-change callback to handle disconnect // or error conditions. this.clientSession_.setOnStateChange(null); this.onOk_(this.clientSession_); break; case remoting.ClientSession.State.CREATED: console.log('Created plugin'); break; case remoting.ClientSession.State.CONNECTING: console.log('Connecting as ' + remoting.identity.getCachedEmail()); break; case remoting.ClientSession.State.INITIALIZING: console.log('Initializing connection'); break; case remoting.ClientSession.State.CLOSED: // This class deregisters for state-change callbacks when the CONNECTED // state is reached, so it only sees the CLOSED state in exceptional // circumstances. For example, a CONNECTING -> CLOSED transition happens // if the host closes the connection without an error message instead of // accepting it. Since there's no way of knowing exactly what went wrong, // we rely on server-side logs in this case and report a generic error // message. this.onError_(remoting.Error.UNEXPECTED); break; case remoting.ClientSession.State.FAILED: var error = this.clientSession_.getError(); console.error('Client plugin reported connection failed: ' + error); if (error == null) { error = remoting.Error.UNEXPECTED; } if (error == remoting.Error.HOST_IS_OFFLINE && this.refreshHostJidIfOffline_) { remoting.hostList.refresh(this.onHostListRefresh_.bind(this)); } else { this.onError_(error); } break; default: console.error('Unexpected client plugin state: ' + newState); // This should only happen if the web-app and client plugin get out of // sync, and even then the version check should ensure compatibility. this.onError_(remoting.Error.MISSING_PLUGIN); } }; /** * @param {boolean} success True if the host list was successfully refreshed; * false if an error occurred. * @private */ remoting.SessionConnector.prototype.onHostListRefresh_ = function(success) { if (success) { var host = remoting.hostList.getHostForId(this.hostId_); if (host) { this.connectMe2MeInternal_( host.hostId, host.jabberId, host.publicKey, host.hostName, this.fetchPin_, this.fetchThirdPartyToken_, this.clientPairingId_, this.clientPairedSecret_, false); return; } } this.onError_(remoting.Error.HOST_IS_OFFLINE); }; /** * @param {number} error An HTTP error code returned by the support-hosts * endpoint. * @return {remoting.Error} The equivalent remoting.Error code. * @private */ remoting.SessionConnector.prototype.translateSupportHostsError = function(error) { switch (error) { case 0: return remoting.Error.NETWORK_FAILURE; case 404: return remoting.Error.INVALID_ACCESS_CODE; case 502: // No break case 503: return remoting.Error.SERVICE_UNAVAILABLE; default: return remoting.Error.UNEXPECTED; } }; /** * Normalize the access code entered by the user. * * @param {string} accessCode The access code, as entered by the user. * @return {string} The normalized form of the code (whitespace removed). */ remoting.SessionConnector.prototype.normalizeAccessCode_ = function(accessCode) { // Trim whitespace. return accessCode.replace(/\s/g, ''); };