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 */ 30function BackgroundBridgeManager() { 31} 32 33BackgroundBridgeManager.prototype = { 34 CONTINUE_URL_BASE: 'chrome-extension://mfffpogegjflfpflabcdkioaeobkgjik' + 35 '/success.html', 36 // Maps a tab id to its associated BackgroundBridge. 37 bridges_: {}, 38 39 run: function() { 40 chrome.runtime.onConnect.addListener(this.onConnect_.bind(this)); 41 42 chrome.webRequest.onBeforeRequest.addListener( 43 function(details) { 44 if (this.bridges_[details.tabId]) 45 return this.bridges_[details.tabId].onInsecureRequest(details.url); 46 }.bind(this), 47 {urls: ['http://*/*', 'file://*/*', 'ftp://*/*']}, 48 ['blocking']); 49 50 chrome.webRequest.onBeforeSendHeaders.addListener( 51 function(details) { 52 if (this.bridges_[details.tabId]) 53 return this.bridges_[details.tabId].onBeforeSendHeaders(details); 54 else 55 return {requestHeaders: details.requestHeaders}; 56 }.bind(this), 57 {urls: ['*://*/*'], types: ['sub_frame']}, 58 ['blocking', 'requestHeaders']); 59 60 chrome.webRequest.onHeadersReceived.addListener( 61 function(details) { 62 if (this.bridges_[details.tabId]) 63 return this.bridges_[details.tabId].onHeadersReceived(details); 64 }.bind(this), 65 {urls: ['*://*/*'], types: ['sub_frame']}, 66 ['blocking', 'responseHeaders']); 67 68 chrome.webRequest.onCompleted.addListener( 69 function(details) { 70 if (this.bridges_[details.tabId]) 71 this.bridges_[details.tabId].onCompleted(details); 72 }.bind(this), 73 {urls: ['*://*/*', this.CONTINUE_URL_BASE + '*'], types: ['sub_frame']}, 74 ['responseHeaders']); 75 }, 76 77 onConnect_: function(port) { 78 var tabId = this.getTabIdFromPort_(port); 79 if (!this.bridges_[tabId]) 80 this.bridges_[tabId] = new BackgroundBridge(tabId); 81 if (port.name == 'authMain') { 82 this.bridges_[tabId].setupForAuthMain(port); 83 port.onDisconnect.addListener(function() { 84 delete this.bridges_[tabId]; 85 }.bind(this)); 86 } else if (port.name == 'injected') { 87 this.bridges_[tabId].setupForInjected(port); 88 } else { 89 console.error('Unexpected connection, port.name=' + port.name); 90 } 91 }, 92 93 getTabIdFromPort_: function(port) { 94 return port.sender.tab ? port.sender.tab.id : -1; 95 } 96}; 97 98/** 99 * BackgroundBridge allows the main script and the injected script to 100 * collaborate. It forwards credentials API calls to the main script and 101 * maintains a list of scraped passwords. 102 * @param {string} tabId The associated tab ID. 103 */ 104function BackgroundBridge(tabId) { 105 this.tabId_ = tabId; 106} 107 108BackgroundBridge.prototype = { 109 // The associated tab ID. Only used for debugging now. 110 tabId: null, 111 112 isDesktopFlow_: false, 113 114 // Whether the extension is loaded in a constrained window. 115 // Set from main auth script. 116 isConstrainedWindow_: null, 117 118 // Email of the newly authenticated user based on the gaia response header 119 // 'google-accounts-signin'. 120 email_: null, 121 122 // Session index of the newly authenticated user based on the gaia response 123 // header 'google-accounts-signin'. 124 sessionIndex_: null, 125 126 // Gaia URL base that is set from main auth script. 127 gaiaUrl_: null, 128 129 // Whether to abort the authentication flow and show an error messagen when 130 // content served over an unencrypted connection is detected. 131 blockInsecureContent_: false, 132 133 // Whether auth flow has started. It is used as a signal of whether the 134 // injected script should scrape passwords. 135 authStarted_: false, 136 137 passwordStore_: {}, 138 139 channelMain_: null, 140 channelInjected_: null, 141 142 /** 143 * Sets up the communication channel with the main script. 144 */ 145 setupForAuthMain: function(port) { 146 this.channelMain_ = new Channel(); 147 this.channelMain_.init(port); 148 149 // Registers for desktop related messages. 150 this.channelMain_.registerMessage( 151 'initDesktopFlow', this.onInitDesktopFlow_.bind(this)); 152 153 // Registers for SAML related messages. 154 this.channelMain_.registerMessage( 155 'setGaiaUrl', this.onSetGaiaUrl_.bind(this)); 156 this.channelMain_.registerMessage( 157 'setBlockInsecureContent', this.onSetBlockInsecureContent_.bind(this)); 158 this.channelMain_.registerMessage( 159 'resetAuth', this.onResetAuth_.bind(this)); 160 this.channelMain_.registerMessage( 161 'startAuth', this.onAuthStarted_.bind(this)); 162 this.channelMain_.registerMessage( 163 'getScrapedPasswords', 164 this.onGetScrapedPasswords_.bind(this)); 165 this.channelMain_.registerMessage( 166 'apiResponse', this.onAPIResponse_.bind(this)); 167 168 this.channelMain_.send({ 169 'name': 'channelConnected' 170 }); 171 }, 172 173 /** 174 * Sets up the communication channel with the injected script. 175 */ 176 setupForInjected: function(port) { 177 this.channelInjected_ = new Channel(); 178 this.channelInjected_.init(port); 179 180 this.channelInjected_.registerMessage( 181 'apiCall', this.onAPICall_.bind(this)); 182 this.channelInjected_.registerMessage( 183 'updatePassword', this.onUpdatePassword_.bind(this)); 184 this.channelInjected_.registerMessage( 185 'pageLoaded', this.onPageLoaded_.bind(this)); 186 }, 187 188 /** 189 * Handler for 'initDesktopFlow' signal sent from the main script. 190 * Only called in desktop mode. 191 */ 192 onInitDesktopFlow_: function(msg) { 193 this.isDesktopFlow_ = true; 194 this.gaiaUrl_ = msg.gaiaUrl; 195 this.isConstrainedWindow_ = msg.isConstrainedWindow; 196 }, 197 198 /** 199 * Handler for webRequest.onCompleted. It 1) detects loading of continue URL 200 * and notifies the main script of signin completion; 2) detects if the 201 * current page could be loaded in a constrained window and signals the main 202 * script of switching to full tab if necessary. 203 */ 204 onCompleted: function(details) { 205 // Only monitors requests in the gaia frame whose parent frame ID must be 206 // positive. 207 if (!this.isDesktopFlow_ || details.parentFrameId <= 0) 208 return; 209 210 if (details.url.lastIndexOf(backgroundBridgeManager.CONTINUE_URL_BASE, 0) == 211 0) { 212 var skipForNow = false; 213 if (details.url.indexOf('ntp=1') >= 0) 214 skipForNow = true; 215 216 // TOOD(guohui): Show password confirmation UI. 217 var passwords = this.onGetScrapedPasswords_(); 218 var msg = { 219 'name': 'completeLogin', 220 'email': this.email_, 221 'password': passwords[0], 222 'sessionIndex': this.sessionIndex_, 223 'skipForNow': skipForNow 224 }; 225 this.channelMain_.send(msg); 226 } else if (this.isConstrainedWindow_) { 227 // The header google-accounts-embedded is only set on gaia domain. 228 if (this.gaiaUrl_ && details.url.lastIndexOf(this.gaiaUrl_) == 0) { 229 var headers = details.responseHeaders; 230 for (var i = 0; headers && i < headers.length; ++i) { 231 if (headers[i].name.toLowerCase() == 'google-accounts-embedded') 232 return; 233 } 234 } 235 var msg = { 236 'name': 'switchToFullTab', 237 'url': details.url 238 }; 239 this.channelMain_.send(msg); 240 } 241 }, 242 243 /** 244 * Handler for webRequest.onBeforeRequest, invoked when content served over an 245 * unencrypted connection is detected. Determines whether the request should 246 * be blocked and if so, signals that an error message needs to be shown. 247 * @param {string} url The URL that was blocked. 248 * @return {!Object} Decision whether to block the request. 249 */ 250 onInsecureRequest: function(url) { 251 if (!this.blockInsecureContent_) 252 return {}; 253 this.channelMain_.send({name: 'onInsecureContentBlocked', url: url}); 254 return {cancel: true}; 255 }, 256 257 /** 258 * Handler or webRequest.onHeadersReceived. It reads the authenticated user 259 * email from google-accounts-signin-header. 260 * @return {!Object} Modified request headers. 261 */ 262 onHeadersReceived: function(details) { 263 var headers = details.responseHeaders; 264 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 for (var i = 0; headers && i < headers.length; ++i) { 271 if (headers[i].name.toLowerCase() == 'google-accounts-signin') { 272 var headerValues = headers[i].value.toLowerCase().split(','); 273 var signinDetails = {}; 274 headerValues.forEach(function(e) { 275 var pair = e.split('='); 276 signinDetails[pair[0].trim()] = pair[1].trim(); 277 }); 278 // Remove "" around. 279 this.email_ = signinDetails['email'].slice(1, -1); 280 this.sessionIndex_ = signinDetails['sessionindex']; 281 break; 282 } 283 } 284 } 285 286 if (!this.isDesktopFlow_) { 287 // Check whether GAIA headers indicating the start or end of a SAML 288 // redirect are present. If so, synthesize cookies to mark these points. 289 for (var i = 0; headers && i < headers.length; ++i) { 290 if (headers[i].name.toLowerCase() == 'google-accounts-saml') { 291 var action = headers[i].value.toLowerCase(); 292 if (action == 'start') { 293 // GAIA is redirecting to a SAML IdP. Any cookies contained in the 294 // current |headers| were set by GAIA. Any cookies set in future 295 // requests will be coming from the IdP. Append a cookie to the 296 // current |headers| that marks the point at which the redirect 297 // occurred. 298 headers.push({name: 'Set-Cookie', 299 value: 'google-accounts-saml-start=now'}); 300 return {responseHeaders: headers}; 301 } else if (action == 'end') { 302 // The SAML IdP has redirected back to GAIA. Add a cookie that marks 303 // the point at which the redirect occurred occurred. It is 304 // important that this cookie be prepended to the current |headers| 305 // because any cookies contained in the |headers| were already set 306 // by GAIA, not the IdP. Due to limitations in the webRequest API, 307 // it is not trivial to prepend a cookie: 308 // 309 // The webRequest API only allows for deleting and appending 310 // headers. To prepend a cookie (C), three steps are needed: 311 // 1) Delete any headers that set cookies (e.g., A, B). 312 // 2) Append a header which sets the cookie (C). 313 // 3) Append the original headers (A, B). 314 // 315 // Due to a further limitation of the webRequest API, it is not 316 // possible to delete a header in step 1) and append an identical 317 // header in step 3). To work around this, a trailing semicolon is 318 // added to each header before appending it. Trailing semicolons are 319 // ignored by Chrome in cookie headers, causing the modified headers 320 // to actually set the original cookies. 321 var otherHeaders = []; 322 var cookies = [{name: 'Set-Cookie', 323 value: 'google-accounts-saml-end=now'}]; 324 for (var j = 0; j < headers.length; ++j) { 325 if (headers[j].name.toLowerCase().indexOf('set-cookie') == 0) { 326 var header = headers[j]; 327 header.value += ';'; 328 cookies.push(header); 329 } else { 330 otherHeaders.push(headers[j]); 331 } 332 } 333 return {responseHeaders: otherHeaders.concat(cookies)}; 334 } 335 } 336 } 337 } 338 339 return {}; 340 }, 341 342 /** 343 * Handler for webRequest.onBeforeSendHeaders. 344 * @return {!Object} Modified request headers. 345 */ 346 onBeforeSendHeaders: function(details) { 347 if (!this.isDesktopFlow_ && this.gaiaUrl_ && 348 details.url.indexOf(this.gaiaUrl_) == 0) { 349 details.requestHeaders.push({ 350 name: 'X-Cros-Auth-Ext-Support', 351 value: 'SAML' 352 }); 353 } 354 return {requestHeaders: details.requestHeaders}; 355 }, 356 357 /** 358 * Handler for 'setGaiaUrl' signal sent from the main script. 359 */ 360 onSetGaiaUrl_: function(msg) { 361 this.gaiaUrl_ = msg.gaiaUrl; 362 }, 363 364 /** 365 * Handler for 'setBlockInsecureContent' signal sent from the main script. 366 */ 367 onSetBlockInsecureContent_: function(msg) { 368 this.blockInsecureContent_ = msg.blockInsecureContent; 369 }, 370 371 /** 372 * Handler for 'resetAuth' signal sent from the main script. 373 */ 374 onResetAuth_: function() { 375 this.authStarted_ = false; 376 this.passwordStore_ = {}; 377 }, 378 379 /** 380 * Handler for 'authStarted' signal sent from the main script. 381 */ 382 onAuthStarted_: function() { 383 this.authStarted_ = true; 384 this.passwordStore_ = {}; 385 }, 386 387 /** 388 * Handler for 'getScrapedPasswords' request sent from the main script. 389 * @return {Array.<string>} The array with de-duped scraped passwords. 390 */ 391 onGetScrapedPasswords_: function() { 392 var passwords = {}; 393 for (var property in this.passwordStore_) { 394 passwords[this.passwordStore_[property]] = true; 395 } 396 return Object.keys(passwords); 397 }, 398 399 /** 400 * Handler for 'apiResponse' signal sent from the main script. Passes on the 401 * |msg| to the injected script. 402 */ 403 onAPIResponse_: function(msg) { 404 this.channelInjected_.send(msg); 405 }, 406 407 onAPICall_: function(msg) { 408 this.channelMain_.send(msg); 409 }, 410 411 onUpdatePassword_: function(msg) { 412 if (!this.authStarted_) 413 return; 414 415 this.passwordStore_[msg.id] = msg.password; 416 }, 417 418 onPageLoaded_: function(msg) { 419 if (this.channelMain_) 420 this.channelMain_.send({name: 'onAuthPageLoaded', url: msg.url}); 421 } 422}; 423 424var backgroundBridgeManager = new BackgroundBridgeManager(); 425backgroundBridgeManager.run(); 426