Home | History | Annotate | Download | only in gaia_auth
      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   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  */
    104 function BackgroundBridge(tabId) {
    105   this.tabId_ = tabId;
    106 }
    107 
    108 BackgroundBridge.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 
    424 var backgroundBridgeManager = new BackgroundBridgeManager();
    425 backgroundBridgeManager.run();
    426