Home | History | Annotate | Download | only in webapp
      1 // Copyright 2013 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 /**
      6  * @fileoverview
      7  * Connect set-up state machine for Me2Me and IT2Me
      8  */
      9 
     10 'use strict';
     11 
     12 /** @suppress {duplicate} */
     13 var remoting = remoting || {};
     14 
     15 /**
     16  * @param {Element} pluginParent The node under which to add the client plugin.
     17  * @param {function(remoting.ClientSession):void} onOk Callback on success.
     18  * @param {function(remoting.Error):void} onError Callback on error.
     19  * @constructor
     20  */
     21 remoting.SessionConnector = function(pluginParent, onOk, onError) {
     22   /**
     23    * @type {Element}
     24    * @private
     25    */
     26   this.pluginParent_ = pluginParent;
     27 
     28   /**
     29    * @type {function(remoting.ClientSession):void}
     30    * @private
     31    */
     32   this.onOk_ = onOk;
     33 
     34   /**
     35    * @type {function(remoting.Error):void}
     36    * @private
     37    */
     38   this.onError_ = onError;
     39 
     40   /**
     41    * @type {string}
     42    * @private
     43    */
     44   this.clientJid_ = '';
     45 
     46   /**
     47    * @type {remoting.ClientSession.Mode}
     48    * @private
     49    */
     50   this.connectionMode_ = remoting.ClientSession.Mode.ME2ME;
     51 
     52   /**
     53    * A timer that polls for an updated access token.
     54    *
     55    * @type {number}
     56    * @private
     57    */
     58   this.wcsAccessTokenRefreshTimer_ = 0;
     59 
     60   // Initialize/declare per-connection state.
     61   this.reset();
     62 
     63   // Pre-load WCS to improve connection time.
     64   remoting.identity.callWithToken(this.loadWcs_.bind(this), this.onError_);
     65 };
     66 
     67 /**
     68  * Reset the per-connection state so that the object can be re-used for a
     69  * second connection. Note the none of the shared WCS state is reset.
     70  */
     71 remoting.SessionConnector.prototype.reset = function() {
     72   /**
     73    * Set to true to indicate that the user requested pairing when entering
     74    * their PIN for a Me2Me connection.
     75    *
     76    * @type {boolean}
     77    */
     78   this.pairingRequested = false;
     79 
     80   /**
     81    * String used to identify the host to which to connect. For IT2Me, this is
     82    * the first 7 digits of the access code; for Me2Me it is the host identifier.
     83    *
     84    * @type {string}
     85    * @private
     86    */
     87   this.hostId_ = '';
     88 
     89   /**
     90    * For paired connections, the client id of this device, issued by the host.
     91    *
     92    * @type {string}
     93    * @private
     94    */
     95   this.clientPairingId_ = '';
     96 
     97   /**
     98    * For paired connections, the paired secret for this device, issued by the
     99    * host.
    100    *
    101    * @type {string}
    102    * @private
    103    */
    104   this.clientPairedSecret_ = '';
    105 
    106   /**
    107    * String used to authenticate to the host on connection. For IT2Me, this is
    108    * the access code; for Me2Me it is the PIN.
    109    *
    110    * @type {string}
    111    * @private
    112    */
    113   this.passPhrase_ = '';
    114 
    115   /**
    116    * @type {string}
    117    * @private
    118    */
    119   this.hostJid_ = '';
    120 
    121   /**
    122    * @type {string}
    123    * @private
    124    */
    125   this.hostPublicKey_ = '';
    126 
    127   /**
    128    * @type {boolean}
    129    * @private
    130    */
    131   this.refreshHostJidIfOffline_ = false;
    132 
    133   /**
    134    * @type {remoting.ClientSession}
    135    * @private
    136    */
    137   this.clientSession_ = null;
    138 
    139   /**
    140    * @type {XMLHttpRequest}
    141    * @private
    142    */
    143   this.pendingXhr_ = null;
    144 
    145   /**
    146    * Function to interactively obtain the PIN from the user.
    147    * @type {function(boolean, function(string):void):void}
    148    * @private
    149    */
    150   this.fetchPin_ = function(onPinFetched) {};
    151 
    152   /**
    153    * @type {function(string, string, string,
    154    *                 function(string, string):void): void}
    155    * @private
    156    */
    157   this.fetchThirdPartyToken_ = function(
    158       tokenUrl, scope, onThirdPartyTokenFetched) {};
    159 
    160   /**
    161    * Host 'name', as displayed in the client tool-bar. For a Me2Me connection,
    162    * this is the name of the host; for an IT2Me connection, it is the email
    163    * address of the person sharing their computer.
    164    *
    165    * @type {string}
    166    * @private
    167    */
    168   this.hostDisplayName_ = '';
    169 };
    170 
    171 /**
    172  * Initiate a Me2Me connection.
    173  *
    174  * @param {remoting.Host} host The Me2Me host to which to connect.
    175  * @param {function(boolean, function(string):void):void} fetchPin Function to
    176  *     interactively obtain the PIN from the user.
    177  * @param {function(string, string, string,
    178  *                  function(string, string): void): void}
    179  *     fetchThirdPartyToken Function to obtain a token from a third party
    180  *     authenticaiton server.
    181  * @param {string} clientPairingId The client id issued by the host when
    182  *     this device was paired, if it is already paired.
    183  * @param {string} clientPairedSecret The shared secret issued by the host when
    184  *     this device was paired, if it is already paired.
    185  * @return {void} Nothing.
    186  */
    187 remoting.SessionConnector.prototype.connectMe2Me =
    188     function(host, fetchPin, fetchThirdPartyToken,
    189              clientPairingId, clientPairedSecret) {
    190   this.connectMe2MeInternal_(
    191       host.hostId, host.jabberId, host.publicKey, host.hostName,
    192       fetchPin, fetchThirdPartyToken,
    193       clientPairingId, clientPairedSecret, true);
    194 };
    195 
    196 /**
    197  * Update the pairing info so that the reconnect function will work correctly.
    198  *
    199  * @param {string} clientId The paired client id.
    200  * @param {string} sharedSecret The shared secret.
    201  */
    202 remoting.SessionConnector.prototype.updatePairingInfo =
    203     function(clientId, sharedSecret) {
    204   this.clientPairingId_ = clientId;
    205   this.clientPairedSecret_ = sharedSecret;
    206 };
    207 
    208 /**
    209  * Initiate a Me2Me connection.
    210  *
    211  * @param {string} hostId ID of the Me2Me host.
    212  * @param {string} hostJid XMPP JID of the host.
    213  * @param {string} hostPublicKey Public Key of the host.
    214  * @param {string} hostDisplayName Display name (friendly name) of the host.
    215  * @param {function(boolean, function(string):void):void} fetchPin Function to
    216  *     interactively obtain the PIN from the user.
    217  * @param {function(string, string, string,
    218  *                  function(string, string): void): void}
    219  *     fetchThirdPartyToken Function to obtain a token from a third party
    220  *     authenticaiton server.
    221  * @param {string} clientPairingId The client id issued by the host when
    222  *     this device was paired, if it is already paired.
    223  * @param {string} clientPairedSecret The shared secret issued by the host when
    224  *     this device was paired, if it is already paired.
    225  * @param {boolean} refreshHostJidIfOffline Whether to refresh the JID and retry
    226  *     the connection if the current JID is offline.
    227  * @return {void} Nothing.
    228  * @private
    229  */
    230 remoting.SessionConnector.prototype.connectMe2MeInternal_ =
    231     function(hostId, hostJid, hostPublicKey, hostDisplayName,
    232              fetchPin, fetchThirdPartyToken,
    233              clientPairingId, clientPairedSecret,
    234              refreshHostJidIfOffline) {
    235   // Cancel any existing connect operation.
    236   this.cancel();
    237 
    238   this.hostId_ = hostId;
    239   this.hostJid_ = hostJid;
    240   this.hostPublicKey_ = hostPublicKey;
    241   this.fetchPin_ = fetchPin;
    242   this.fetchThirdPartyToken_ = fetchThirdPartyToken;
    243   this.hostDisplayName_ = hostDisplayName;
    244   this.connectionMode_ = remoting.ClientSession.Mode.ME2ME;
    245   this.refreshHostJidIfOffline_ = refreshHostJidIfOffline;
    246   this.updatePairingInfo(clientPairingId, clientPairedSecret);
    247   this.createSessionIfReady_();
    248 };
    249 
    250 /**
    251  * Initiate an IT2Me connection.
    252  *
    253  * @param {string} accessCode The access code as entered by the user.
    254  * @return {void} Nothing.
    255  */
    256 remoting.SessionConnector.prototype.connectIT2Me = function(accessCode) {
    257   var kSupportIdLen = 7;
    258   var kHostSecretLen = 5;
    259   var kAccessCodeLen = kSupportIdLen + kHostSecretLen;
    260 
    261   // Cancel any existing connect operation.
    262   this.cancel();
    263 
    264   var normalizedAccessCode = this.normalizeAccessCode_(accessCode);
    265   if (normalizedAccessCode.length != kAccessCodeLen) {
    266     this.onError_(remoting.Error.INVALID_ACCESS_CODE);
    267     return;
    268   }
    269 
    270   this.hostId_ = normalizedAccessCode.substring(0, kSupportIdLen);
    271   this.passPhrase_ = normalizedAccessCode;
    272   this.connectionMode_ = remoting.ClientSession.Mode.IT2ME;
    273   remoting.identity.callWithToken(this.connectIT2MeWithToken_.bind(this),
    274                                   this.onError_);
    275 };
    276 
    277 /**
    278  * Reconnect a closed connection.
    279  *
    280  * @return {void} Nothing.
    281  */
    282 remoting.SessionConnector.prototype.reconnect = function() {
    283   if (this.connectionMode_ == remoting.ClientSession.Mode.IT2ME) {
    284     console.error('reconnect not supported for IT2Me.');
    285     return;
    286   }
    287   this.connectMe2MeInternal_(
    288       this.hostId_, this.hostJid_, this.hostPublicKey_, this.hostDisplayName_,
    289       this.fetchPin_, this.fetchThirdPartyToken_,
    290       this.clientPairingId_, this.clientPairedSecret_, true);
    291 };
    292 
    293 /**
    294  * Cancel a connection-in-progress.
    295  */
    296 remoting.SessionConnector.prototype.cancel = function() {
    297   if (this.clientSession_) {
    298     this.clientSession_.removePlugin();
    299     this.clientSession_ = null;
    300   }
    301   if (this.pendingXhr_) {
    302     this.pendingXhr_.abort();
    303     this.pendingXhr_ = null;
    304   }
    305   this.reset();
    306 };
    307 
    308 /**
    309  * Get the connection mode (Me2Me or IT2Me)
    310  *
    311  * @return {remoting.ClientSession.Mode}
    312  */
    313 remoting.SessionConnector.prototype.getConnectionMode = function() {
    314   return this.connectionMode_;
    315 };
    316 
    317 /**
    318  * Continue an IT2Me connection once an access token has been obtained.
    319  *
    320  * @param {string} token An OAuth2 access token.
    321  * @return {void} Nothing.
    322  * @private
    323  */
    324 remoting.SessionConnector.prototype.connectIT2MeWithToken_ = function(token) {
    325   // Resolve the host id to get the host JID.
    326   this.pendingXhr_ = remoting.xhr.get(
    327       remoting.settings.DIRECTORY_API_BASE_URL + '/support-hosts/' +
    328           encodeURIComponent(this.hostId_),
    329       this.onIT2MeHostInfo_.bind(this),
    330       '',
    331       { 'Authorization': 'OAuth ' + token });
    332 };
    333 
    334 /**
    335  * Continue an IT2Me connection once the host JID has been looked up.
    336  *
    337  * @param {XMLHttpRequest} xhr The server response to the support-hosts query.
    338  * @return {void} Nothing.
    339  * @private
    340  */
    341 remoting.SessionConnector.prototype.onIT2MeHostInfo_ = function(xhr) {
    342   this.pendingXhr_ = null;
    343   if (xhr.status == 200) {
    344     var host = /** @type {{data: {jabberId: string, publicKey: string}}} */
    345         jsonParseSafe(xhr.responseText);
    346     if (host && host.data && host.data.jabberId && host.data.publicKey) {
    347       this.hostJid_ = host.data.jabberId;
    348       this.hostPublicKey_ = host.data.publicKey;
    349       this.hostDisplayName_ = this.hostJid_.split('/')[0];
    350       this.createSessionIfReady_();
    351       return;
    352     } else {
    353       console.error('Invalid "support-hosts" response from server.');
    354     }
    355   } else {
    356     this.onError_(this.translateSupportHostsError(xhr.status));
    357   }
    358 };
    359 
    360 /**
    361  * Load the WCS driver script.
    362  *
    363  * @param {string} token An OAuth2 access token.
    364  * @return {void} Nothing.
    365  * @private
    366  */
    367 remoting.SessionConnector.prototype.loadWcs_ = function(token) {
    368   remoting.wcsSandbox.setOnLocalJid(this.onLocalJid_.bind(this));
    369   remoting.wcsSandbox.setOnError(this.onError_);
    370   remoting.wcsSandbox.setAccessToken(token);
    371   this.startAccessTokenRefreshTimer_();
    372 };
    373 
    374 /**
    375  * Continue an IT2Me or Me2Me connection once WCS has been loaded.
    376  *
    377  * @param {string} clientJid The full JID of the WCS client.
    378  * @return {void} Nothing.
    379  * @private
    380  */
    381 remoting.SessionConnector.prototype.onLocalJid_ = function(clientJid) {
    382   this.clientJid_ = clientJid;
    383   this.createSessionIfReady_();
    384 };
    385 
    386 /**
    387  * If both the client and host JIDs are available, create a session and connect.
    388  *
    389  * @return {void} Nothing.
    390  * @private
    391  */
    392 remoting.SessionConnector.prototype.createSessionIfReady_ = function() {
    393   if (!this.clientJid_ || !this.hostJid_) {
    394     return;
    395   }
    396 
    397   // In some circumstances, the WCS <iframe> can get reloaded, which results
    398   // in a new clientJid and a new callback. In this case, remove the old
    399   // client plugin before instantiating a new one.
    400   if (this.clientSession_) {
    401     this.clientSession_.removePlugin();
    402     this.clientSession_ = null;
    403   }
    404 
    405   var securityTypes = 'third_party,spake2_pair,spake2_hmac,spake2_plain';
    406   this.clientSession_ = new remoting.ClientSession(
    407       this.hostJid_, this.clientJid_, this.hostPublicKey_, this.passPhrase_,
    408       this.fetchPin_, this.fetchThirdPartyToken_, securityTypes, this.hostId_,
    409       this.connectionMode_, this.hostDisplayName_, this.clientPairingId_,
    410       this.clientPairedSecret_);
    411   this.clientSession_.logHostOfflineErrors(!this.refreshHostJidIfOffline_);
    412   this.clientSession_.setOnStateChange(this.onStateChange_.bind(this));
    413   this.clientSession_.createPluginAndConnect(this.pluginParent_);
    414 };
    415 
    416 /**
    417  * Handle a change in the state of the client session prior to successful
    418  * connection (after connection, this class no longer handles state change
    419  * events). Errors that occur while connecting either trigger a reconnect
    420  * or notify the onError handler.
    421  *
    422  * @param {number} oldState The previous state of the plugin.
    423  * @param {number} newState The current state of the plugin.
    424  * @return {void} Nothing.
    425  * @private
    426  */
    427 remoting.SessionConnector.prototype.onStateChange_ =
    428     function(oldState, newState) {
    429   switch (newState) {
    430     case remoting.ClientSession.State.CONNECTED:
    431       // When the connection succeeds, deregister for state-change callbacks
    432       // and pass the session to the onOk callback. It is expected that it
    433       // will register a new state-change callback to handle disconnect
    434       // or error conditions.
    435       this.clientSession_.setOnStateChange(null);
    436       this.onOk_(this.clientSession_);
    437       break;
    438 
    439     case remoting.ClientSession.State.CREATED:
    440       console.log('Created plugin');
    441       break;
    442 
    443     case remoting.ClientSession.State.BAD_PLUGIN_VERSION:
    444       this.onError_(remoting.Error.BAD_PLUGIN_VERSION);
    445       break;
    446 
    447     case remoting.ClientSession.State.CONNECTING:
    448       console.log('Connecting as ' + remoting.identity.getCachedEmail());
    449       break;
    450 
    451     case remoting.ClientSession.State.INITIALIZING:
    452       console.log('Initializing connection');
    453       break;
    454 
    455     case remoting.ClientSession.State.CLOSED:
    456       // This class deregisters for state-change callbacks when the CONNECTED
    457       // state is reached, so it only sees the CLOSED state in exceptional
    458       // circumstances. For example, a CONNECTING -> CLOSED transition happens
    459       // if the host closes the connection without an error message instead of
    460       // accepting it. Since there's no way of knowing exactly what went wrong,
    461       // we rely on server-side logs in this case and report a generic error
    462       // message.
    463       this.onError_(remoting.Error.UNEXPECTED);
    464       break;
    465 
    466     case remoting.ClientSession.State.FAILED:
    467       var error = this.clientSession_.getError();
    468       console.error('Client plugin reported connection failed: ' + error);
    469       if (error == null) {
    470         error = remoting.Error.UNEXPECTED;
    471       }
    472       if (error == remoting.Error.HOST_IS_OFFLINE &&
    473           this.refreshHostJidIfOffline_) {
    474         remoting.hostList.refresh(this.onHostListRefresh_.bind(this));
    475       } else {
    476         this.onError_(error);
    477       }
    478       break;
    479 
    480     default:
    481       console.error('Unexpected client plugin state: ' + newState);
    482       // This should only happen if the web-app and client plugin get out of
    483       // sync, and even then the version check should ensure compatibility.
    484       this.onError_(remoting.Error.MISSING_PLUGIN);
    485   }
    486 };
    487 
    488 /**
    489  * @param {boolean} success True if the host list was successfully refreshed;
    490  *     false if an error occurred.
    491  * @private
    492  */
    493 remoting.SessionConnector.prototype.onHostListRefresh_ = function(success) {
    494   if (success) {
    495     var host = remoting.hostList.getHostForId(this.hostId_);
    496     if (host) {
    497       this.connectMe2MeInternal_(
    498           host.hostId, host.jabberId, host.publicKey, host.hostName,
    499           this.fetchPin_, this.fetchThirdPartyToken_,
    500           this.clientPairingId_, this.clientPairedSecret_, false);
    501       return;
    502     }
    503   }
    504   this.onError_(remoting.Error.HOST_IS_OFFLINE);
    505 };
    506 
    507 /**
    508  * Start a timer to periodically refresh the access token used by WCS. Access
    509  * tokens have a limited lifespan, and since the WCS driver runs in a sandbox,
    510  * it can't obtain a new one directly.
    511  *
    512  * @return {void} Nothing.
    513  * @private
    514  */
    515 remoting.SessionConnector.prototype.startAccessTokenRefreshTimer_ = function() {
    516   if (this.wcsAccessTokenRefreshTimer_ != 0) {
    517     return;
    518   }
    519 
    520   /** @type {remoting.SessionConnector} */
    521   var that = this;
    522   var refreshAccessToken = function() {
    523     remoting.identity.callWithToken(
    524         remoting.wcsSandbox.setAccessToken.bind(remoting.wcsSandbox),
    525         that.onError_);
    526   };
    527   /**
    528    * A timer that polls for an updated access token.
    529    * @type {number}
    530    * @private
    531    */
    532   this.wcsAccessTokenRefreshTimer_ = setInterval(refreshAccessToken,
    533                                                  60 * 1000);
    534 }
    535 
    536 /**
    537  * @param {number} error An HTTP error code returned by the support-hosts
    538  *     endpoint.
    539  * @return {remoting.Error} The equivalent remoting.Error code.
    540  * @private
    541  */
    542 remoting.SessionConnector.prototype.translateSupportHostsError =
    543     function(error) {
    544   switch (error) {
    545     case 0: return remoting.Error.NETWORK_FAILURE;
    546     case 404: return remoting.Error.INVALID_ACCESS_CODE;
    547     case 502: // No break
    548     case 503: return remoting.Error.SERVICE_UNAVAILABLE;
    549     default: return remoting.Error.UNEXPECTED;
    550   }
    551 };
    552 
    553 /**
    554  * Normalize the access code entered by the user.
    555  *
    556  * @param {string} accessCode The access code, as entered by the user.
    557  * @return {string} The normalized form of the code (whitespace removed).
    558  */
    559 remoting.SessionConnector.prototype.normalizeAccessCode_ =
    560     function(accessCode) {
    561   // Trim whitespace.
    562   return accessCode.replace(/\s/g, '');
    563 };
    564