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