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 * A background script of the auth extension that bridges the communication 8 * between the main and injected scripts. 9 * 10 * Here is an overview of the communication flow when SAML is being used: 11 * 1. The main script sends the |startAuth| signal to this background script, 12 * indicating that the authentication flow has started and SAML pages may be 13 * loaded from now on. 14 * 2. A script is injected into each SAML page. The injected script sends three 15 * main types of messages to this background script: 16 * a) A |pageLoaded| message is sent when the page has been loaded. This is 17 * forwarded to the main script as |onAuthPageLoaded|. 18 * b) If the SAML provider supports the credential passing API, the API calls 19 * are sent to this background script as |apiCall| messages. These 20 * messages are forwarded unmodified to the main script. 21 * c) The injected script scrapes passwords. They are sent to this background 22 * script in |updatePassword| messages. The main script can request a list 23 * of the scraped passwords by sending the |getScrapedPasswords| message. 24 */ 25 26 /** 27 * BackgroundBridgeManager maintains an array of BackgroundBridge, indexed by 28 * the associated tab id. 29 */ 30 function BackgroundBridgeManager() { 31 } 32 33 BackgroundBridgeManager.prototype = { 34 // Maps a tab id to its associated BackgroundBridge. 35 bridges_: {}, 36 37 run: function() { 38 chrome.runtime.onConnect.addListener(this.onConnect_.bind(this)); 39 40 chrome.webRequest.onBeforeRequest.addListener( 41 function(details) { 42 if (this.bridges_[details.tabId]) 43 return this.bridges_[details.tabId].onInsecureRequest(details.url); 44 }.bind(this), 45 {urls: ['http://*/*', 'file://*/*', 'ftp://*/*']}, 46 ['blocking']); 47 48 chrome.webRequest.onBeforeSendHeaders.addListener( 49 function(details) { 50 if (this.bridges_[details.tabId]) 51 return this.bridges_[details.tabId].onBeforeSendHeaders(details); 52 else 53 return {requestHeaders: details.requestHeaders}; 54 }.bind(this), 55 {urls: ['*://*/*'], types: ['sub_frame']}, 56 ['blocking', 'requestHeaders']); 57 58 chrome.webRequest.onHeadersReceived.addListener( 59 function(details) { 60 if (this.bridges_[details.tabId]) 61 this.bridges_[details.tabId].onHeadersReceived(details); 62 }.bind(this), 63 {urls: ['*://*/*'], types: ['sub_frame']}, 64 ['responseHeaders']); 65 66 chrome.webRequest.onCompleted.addListener( 67 function(details) { 68 if (this.bridges_[details.tabId]) 69 this.bridges_[details.tabId].onCompleted(details); 70 }.bind(this), 71 {urls: ['*://*/*'], types: ['sub_frame']}, 72 ['responseHeaders']); 73 }, 74 75 onConnect_: function(port) { 76 var tabId = this.getTabIdFromPort_(port); 77 if (!this.bridges_[tabId]) 78 this.bridges_[tabId] = new BackgroundBridge(tabId); 79 if (port.name == 'authMain') { 80 this.bridges_[tabId].setupForAuthMain(port); 81 port.onDisconnect.addListener(function() { 82 delete this.bridges_[tabId]; 83 }.bind(this)); 84 } else if (port.name == 'injected') { 85 this.bridges_[tabId].setupForInjected(port); 86 } else { 87 console.error('Unexpected connection, port.name=' + port.name); 88 } 89 }, 90 91 getTabIdFromPort_: function(port) { 92 return port.sender.tab ? port.sender.tab.id : -1; 93 } 94 }; 95 96 /** 97 * BackgroundBridge allows the main script and the injected script to 98 * collaborate. It forwards credentials API calls to the main script and 99 * maintains a list of scraped passwords. 100 * @param {string} tabId The associated tab ID. 101 */ 102 function BackgroundBridge(tabId) { 103 this.tabId_ = tabId; 104 } 105 106 BackgroundBridge.prototype = { 107 // The associated tab ID. Only used for debugging now. 108 tabId: null, 109 110 isDesktopFlow_: false, 111 112 // Continue URL that is set from main auth script. 113 continueUrl_: null, 114 115 // Whether the extension is loaded in a constrained window. 116 // Set from main auth script. 117 isConstrainedWindow_: null, 118 119 // Email of the newly authenticated user based on the gaia response header 120 // 'google-accounts-signin'. 121 email_: null, 122 123 // Session index of the newly authenticated user based on the gaia response 124 // header 'google-accounts-signin'. 125 sessionIndex_: null, 126 127 // Gaia URL base that is set from main auth script. 128 gaiaUrl_: null, 129 130 // Whether to abort the authentication flow and show an error messagen when 131 // content served over an unencrypted connection is detected. 132 blockInsecureContent_: false, 133 134 // Whether auth flow has started. It is used as a signal of whether the 135 // injected script should scrape passwords. 136 authStarted_: false, 137 138 passwordStore_: {}, 139 140 channelMain_: null, 141 channelInjected_: null, 142 143 /** 144 * Sets up the communication channel with the main script. 145 */ 146 setupForAuthMain: function(port) { 147 this.channelMain_ = new Channel(); 148 this.channelMain_.init(port); 149 150 // Registers for desktop related messages. 151 this.channelMain_.registerMessage( 152 'initDesktopFlow', this.onInitDesktopFlow_.bind(this)); 153 154 // Registers for SAML related messages. 155 this.channelMain_.registerMessage( 156 'setGaiaUrl', this.onSetGaiaUrl_.bind(this)); 157 this.channelMain_.registerMessage( 158 'setBlockInsecureContent', this.onSetBlockInsecureContent_.bind(this)); 159 this.channelMain_.registerMessage( 160 'resetAuth', this.onResetAuth_.bind(this)); 161 this.channelMain_.registerMessage( 162 'startAuth', this.onAuthStarted_.bind(this)); 163 this.channelMain_.registerMessage( 164 'getScrapedPasswords', 165 this.onGetScrapedPasswords_.bind(this)); 166 this.channelMain_.registerMessage( 167 'apiResponse', this.onAPIResponse_.bind(this)); 168 169 this.channelMain_.send({ 170 'name': 'channelConnected' 171 }); 172 }, 173 174 /** 175 * Sets up the communication channel with the injected script. 176 */ 177 setupForInjected: function(port) { 178 this.channelInjected_ = new Channel(); 179 this.channelInjected_.init(port); 180 181 this.channelInjected_.registerMessage( 182 'apiCall', this.onAPICall_.bind(this)); 183 this.channelInjected_.registerMessage( 184 'updatePassword', this.onUpdatePassword_.bind(this)); 185 this.channelInjected_.registerMessage( 186 'pageLoaded', this.onPageLoaded_.bind(this)); 187 }, 188 189 /** 190 * Handler for 'initDesktopFlow' signal sent from the main script. 191 * Only called in desktop mode. 192 */ 193 onInitDesktopFlow_: function(msg) { 194 this.isDesktopFlow_ = true; 195 this.gaiaUrl_ = msg.gaiaUrl; 196 this.continueUrl_ = msg.continueUrl; 197 this.isConstrainedWindow_ = msg.isConstrainedWindow; 198 }, 199 200 /** 201 * Handler for webRequest.onCompleted. It 1) detects loading of continue URL 202 * and notifies the main script of signin completion; 2) detects if the 203 * current page could be loaded in a constrained window and signals the main 204 * script of switching to full tab if necessary. 205 */ 206 onCompleted: function(details) { 207 // Only monitors requests in the gaia frame whose parent frame ID must be 208 // positive. 209 if (!this.isDesktopFlow_ || details.parentFrameId <= 0) 210 return; 211 212 var msg = null; 213 if (this.continueUrl_ && 214 details.url.lastIndexOf(this.continueUrl_, 0) == 0) { 215 var skipForNow = false; 216 if (details.url.indexOf('ntp=1') >= 0) 217 skipForNow = true; 218 219 // TOOD(guohui): Show password confirmation UI. 220 var passwords = this.onGetScrapedPasswords_(); 221 msg = { 222 'name': 'completeLogin', 223 'email': this.email_, 224 'password': passwords[0], 225 'sessionIndex': this.sessionIndex_, 226 'skipForNow': skipForNow 227 }; 228 this.channelMain_.send(msg); 229 } else if (this.isConstrainedWindow_) { 230 // The header google-accounts-embedded is only set on gaia domain. 231 if (this.gaiaUrl_ && details.url.lastIndexOf(this.gaiaUrl_) == 0) { 232 var headers = details.responseHeaders; 233 for (var i = 0; headers && i < headers.length; ++i) { 234 if (headers[i].name.toLowerCase() == 'google-accounts-embedded') 235 return; 236 } 237 } 238 msg = { 239 'name': 'switchToFullTab', 240 'url': details.url 241 }; 242 this.channelMain_.send(msg); 243 } 244 }, 245 246 /** 247 * Handler for webRequest.onBeforeRequest, invoked when content served over an 248 * unencrypted connection is detected. Determines whether the request should 249 * be blocked and if so, signals that an error message needs to be shown. 250 * @param {string} url The URL that was blocked. 251 * @return {!Object} Decision whether to block the request. 252 */ 253 onInsecureRequest: function(url) { 254 if (!this.blockInsecureContent_) 255 return {}; 256 this.channelMain_.send({name: 'onInsecureContentBlocked', url: url}); 257 return {cancel: true}; 258 }, 259 260 /** 261 * Handler or webRequest.onHeadersReceived. It reads the authenticated user 262 * email from google-accounts-signin-header. 263 */ 264 onHeadersReceived: function(details) { 265 if (!this.isDesktopFlow_ || 266 !this.gaiaUrl_ || 267 details.url.lastIndexOf(this.gaiaUrl_) != 0) { 268 // TODO(xiyuan, guohui): CrOS should reuse the logic below for reading the 269 // email for SAML users and cut off the /ListAccount call. 270 return; 271 } 272 273 var headers = details.responseHeaders; 274 for (var i = 0; headers && i < headers.length; ++i) { 275 if (headers[i].name.toLowerCase() == 'google-accounts-signin') { 276 var headerValues = headers[i].value.toLowerCase().split(','); 277 var signinDetails = {}; 278 headerValues.forEach(function(e) { 279 var pair = e.split('='); 280 signinDetails[pair[0].trim()] = pair[1].trim(); 281 }); 282 // Remove "" around. 283 this.email_ = signinDetails['email'].slice(1, -1); 284 this.sessionIndex_ = signinDetails['sessionindex']; 285 return; 286 } 287 } 288 }, 289 290 /** 291 * Handler for webRequest.onBeforeSendHeaders. 292 * @return {!Object} Modified request headers. 293 */ 294 onBeforeSendHeaders: function(details) { 295 if (!this.isDesktopFlow_ && this.gaiaUrl_ && 296 details.url.indexOf(this.gaiaUrl_) == 0) { 297 details.requestHeaders.push({ 298 name: 'X-Cros-Auth-Ext-Support', 299 value: 'SAML' 300 }); 301 } 302 return {requestHeaders: details.requestHeaders}; 303 }, 304 305 /** 306 * Handler for 'setGaiaUrl' signal sent from the main script. 307 */ 308 onSetGaiaUrl_: function(msg) { 309 this.gaiaUrl_ = msg.gaiaUrl; 310 }, 311 312 /** 313 * Handler for 'setBlockInsecureContent' signal sent from the main script. 314 */ 315 onSetBlockInsecureContent_: function(msg) { 316 this.blockInsecureContent_ = msg.blockInsecureContent; 317 }, 318 319 /** 320 * Handler for 'resetAuth' signal sent from the main script. 321 */ 322 onResetAuth_: function() { 323 this.authStarted_ = false; 324 this.passwordStore_ = {}; 325 }, 326 327 /** 328 * Handler for 'authStarted' signal sent from the main script. 329 */ 330 onAuthStarted_: function() { 331 this.authStarted_ = true; 332 this.passwordStore_ = {}; 333 }, 334 335 /** 336 * Handler for 'getScrapedPasswords' request sent from the main script. 337 * @return {Array.<string>} The array with de-duped scraped passwords. 338 */ 339 onGetScrapedPasswords_: function() { 340 var passwords = {}; 341 for (var property in this.passwordStore_) { 342 passwords[this.passwordStore_[property]] = true; 343 } 344 return Object.keys(passwords); 345 }, 346 347 /** 348 * Handler for 'apiResponse' signal sent from the main script. Passes on the 349 * |msg| to the injected script. 350 */ 351 onAPIResponse_: function(msg) { 352 this.channelInjected_.send(msg); 353 }, 354 355 onAPICall_: function(msg) { 356 this.channelMain_.send(msg); 357 }, 358 359 onUpdatePassword_: function(msg) { 360 if (!this.authStarted_) 361 return; 362 363 this.passwordStore_[msg.id] = msg.password; 364 }, 365 366 onPageLoaded_: function(msg) { 367 if (this.channelMain_) 368 this.channelMain_.send({name: 'onAuthPageLoaded', url: msg.url}); 369 } 370 }; 371 372 var backgroundBridgeManager = new BackgroundBridgeManager(); 373 backgroundBridgeManager.run(); 374