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