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