Merge Chromium + Blink git repositories
[chromium-blink-merge.git] / chrome / browser / resources / gaia_auth / background.js
blob482a4d1df4c8d115a686c8e70e26a620c9852608
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.
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  */
26 /**
27  * BackgroundBridgeManager maintains an array of BackgroundBridge, indexed by
28  * the associated tab id.
29  */
30 function BackgroundBridgeManager() {
31   this.bridges_ = {};
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,
40   run: function() {
41     chrome.runtime.onConnect.addListener(this.onConnect_.bind(this));
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']);
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']);
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']);
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   },
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   },
94   getTabIdFromPort_: function(port) {
95     return port.sender.tab ? port.sender.tab.id : -1;
96   }
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_ = {};
110 BackgroundBridge.prototype = {
111   // The associated tab ID. Only used for debugging now.
112   tabId: null,
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,
118   // On process onCompleted() requests that come from this frame Id.
119   frameId: -1,
121   isDesktopFlow_: false,
123   // Whether the extension is loaded in a constrained window.
124   // Set from main auth script.
125   isConstrainedWindow_: null,
127   // Email of the newly authenticated user based on the gaia response header
128   // 'google-accounts-signin'.
129   email_: null,
131   // Gaia Id of the newly authenticated user based on the gaia response
132   // header 'google-accounts-signin'.
133   gaiaId_: null,
135   // Session index of the newly authenticated user based on the gaia response
136   // header 'google-accounts-signin'.
137   sessionIndex_: null,
139   // Gaia URL base that is set from main auth script.
140   gaiaUrl_: null,
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,
146   // Whether auth flow has started. It is used as a signal of whether the
147   // injected script should scrape passwords.
148   authStarted_: false,
150   // Whether SAML flow is going.
151   isSAML_: false,
153   passwordStore_: null,
155   channelMain_: null,
156   channelInjected_: null,
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);
165     // Registers for desktop related messages.
166     this.channelMain_.registerMessage(
167         'initDesktopFlow', this.onInitDesktopFlow_.bind(this));
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));
184     this.channelMain_.send({
185       'name': 'channelConnected'
186     });
187   },
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);
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   },
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   },
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     }
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;
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   },
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   },
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;
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     }
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().indexOf('set-cookie') == 0) {
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     }
369     return {};
370   },
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.indexOf(this.gaiaUrl_) == 0) {
379       details.requestHeaders.push({
380         name: 'X-Cros-Auth-Ext-Support',
381         value: 'SAML'
382       });
383     }
384     return {requestHeaders: details.requestHeaders};
385   },
387   /**
388    * Handler for 'setGaiaUrl' signal sent from the main script.
389    */
390   onSetGaiaUrl_: function(msg) {
391     this.gaiaUrl_ = msg.gaiaUrl;
392   },
394   /**
395    * Handler for 'setBlockInsecureContent' signal sent from the main script.
396    */
397   onSetBlockInsecureContent_: function(msg) {
398     this.blockInsecureContent_ = msg.blockInsecureContent;
399   },
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   },
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   },
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   },
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   },
439   onAPICall_: function(msg) {
440     this.channelMain_.send(msg);
441   },
443   onUpdatePassword_: function(msg) {
444     if (!this.authStarted_)
445       return;
447     this.passwordStore_[msg.id] = msg.password;
448   },
450   onPageLoaded_: function(msg) {
451     if (this.channelMain_)
452       this.channelMain_.send({name: 'onAuthPageLoaded',
453                               url: msg.url,
454                               isSAMLPage: this.isSAML_});
455   },
457   onGetSAMLFlag_: function(msg) {
458     return this.isSAML_;
459   }
462 var backgroundBridgeManager = new BackgroundBridgeManager();
463 backgroundBridgeManager.run();