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