blob: 50c72243f18be0bf8e38a7de499dfa1305d280fb [file] [log] [blame]
// Copyright 2013 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview
* A background script of the auth extension that bridges the communication
* between the main and injected scripts.
*
* Here is an overview of the communication flow when SAML is being used:
* 1. The main script sends the |startAuth| signal to this background script,
* indicating that the authentication flow has started and SAML pages may be
* loaded from now on.
* 2. A script is injected into each SAML page. The injected script sends three
* main types of messages to this background script:
* a) A |pageLoaded| message is sent when the page has been loaded. This is
* forwarded to the main script as |onAuthPageLoaded|.
* b) If the SAML provider supports the credential passing API, the API calls
* are sent to this background script as |apiCall| messages. These
* messages are forwarded unmodified to the main script.
* c) The injected script scrapes passwords. They are sent to this background
* script in |updatePassword| messages. The main script can request a list
* of the scraped passwords by sending the |getScrapedPasswords| message.
*/
/**
* BackgroundBridgeManager maintains an array of BackgroundBridge, indexed by
* the associated tab id.
*/
function BackgroundBridgeManager() {
}
BackgroundBridgeManager.prototype = {
// Maps a tab id to its associated BackgroundBridge.
bridges_: {},
run: function() {
chrome.runtime.onConnect.addListener(this.onConnect_.bind(this));
chrome.webRequest.onBeforeRequest.addListener(
function(details) {
if (this.bridges_[details.tabId])
return this.bridges_[details.tabId].onInsecureRequest(details.url);
}.bind(this),
{urls: ['http://*/*', 'file://*/*', 'ftp://*/*']},
['blocking']);
chrome.webRequest.onBeforeSendHeaders.addListener(
function(details) {
if (this.bridges_[details.tabId])
return this.bridges_[details.tabId].onBeforeSendHeaders(details);
else
return {requestHeaders: details.requestHeaders};
}.bind(this),
{urls: ['*://*/*'], types: ['sub_frame']},
['blocking', 'requestHeaders']);
chrome.webRequest.onHeadersReceived.addListener(
function(details) {
if (this.bridges_[details.tabId])
return this.bridges_[details.tabId].onHeadersReceived(details);
}.bind(this),
{urls: ['*://*/*'], types: ['sub_frame']},
['blocking', 'responseHeaders']);
chrome.webRequest.onCompleted.addListener(
function(details) {
if (this.bridges_[details.tabId])
this.bridges_[details.tabId].onCompleted(details);
}.bind(this),
{urls: ['*://*/*'], types: ['sub_frame']},
['responseHeaders']);
},
onConnect_: function(port) {
var tabId = this.getTabIdFromPort_(port);
if (!this.bridges_[tabId])
this.bridges_[tabId] = new BackgroundBridge(tabId);
if (port.name == 'authMain') {
this.bridges_[tabId].setupForAuthMain(port);
port.onDisconnect.addListener(function() {
delete this.bridges_[tabId];
}.bind(this));
} else if (port.name == 'injected') {
this.bridges_[tabId].setupForInjected(port);
} else {
console.error('Unexpected connection, port.name=' + port.name);
}
},
getTabIdFromPort_: function(port) {
return port.sender.tab ? port.sender.tab.id : -1;
}
};
/**
* BackgroundBridge allows the main script and the injected script to
* collaborate. It forwards credentials API calls to the main script and
* maintains a list of scraped passwords.
* @param {string} tabId The associated tab ID.
*/
function BackgroundBridge(tabId) {
this.tabId_ = tabId;
}
BackgroundBridge.prototype = {
// The associated tab ID. Only used for debugging now.
tabId: null,
isDesktopFlow_: false,
// Continue URL that is set from main auth script.
continueUrl_: null,
// Whether the extension is loaded in a constrained window.
// Set from main auth script.
isConstrainedWindow_: null,
// Email of the newly authenticated user based on the gaia response header
// 'google-accounts-signin'.
email_: null,
// Session index of the newly authenticated user based on the gaia response
// header 'google-accounts-signin'.
sessionIndex_: null,
// Gaia URL base that is set from main auth script.
gaiaUrl_: null,
// Whether to abort the authentication flow and show an error messagen when
// content served over an unencrypted connection is detected.
blockInsecureContent_: false,
// Whether auth flow has started. It is used as a signal of whether the
// injected script should scrape passwords.
authStarted_: false,
passwordStore_: {},
channelMain_: null,
channelInjected_: null,
/**
* Sets up the communication channel with the main script.
*/
setupForAuthMain: function(port) {
this.channelMain_ = new Channel();
this.channelMain_.init(port);
// Registers for desktop related messages.
this.channelMain_.registerMessage(
'initDesktopFlow', this.onInitDesktopFlow_.bind(this));
// Registers for SAML related messages.
this.channelMain_.registerMessage(
'setGaiaUrl', this.onSetGaiaUrl_.bind(this));
this.channelMain_.registerMessage(
'setBlockInsecureContent', this.onSetBlockInsecureContent_.bind(this));
this.channelMain_.registerMessage(
'resetAuth', this.onResetAuth_.bind(this));
this.channelMain_.registerMessage(
'startAuth', this.onAuthStarted_.bind(this));
this.channelMain_.registerMessage(
'getScrapedPasswords',
this.onGetScrapedPasswords_.bind(this));
this.channelMain_.registerMessage(
'apiResponse', this.onAPIResponse_.bind(this));
this.channelMain_.send({
'name': 'channelConnected'
});
},
/**
* Sets up the communication channel with the injected script.
*/
setupForInjected: function(port) {
this.channelInjected_ = new Channel();
this.channelInjected_.init(port);
this.channelInjected_.registerMessage(
'apiCall', this.onAPICall_.bind(this));
this.channelInjected_.registerMessage(
'updatePassword', this.onUpdatePassword_.bind(this));
this.channelInjected_.registerMessage(
'pageLoaded', this.onPageLoaded_.bind(this));
},
/**
* Handler for 'initDesktopFlow' signal sent from the main script.
* Only called in desktop mode.
*/
onInitDesktopFlow_: function(msg) {
this.isDesktopFlow_ = true;
this.gaiaUrl_ = msg.gaiaUrl;
this.continueUrl_ = msg.continueUrl;
this.isConstrainedWindow_ = msg.isConstrainedWindow;
},
/**
* Handler for webRequest.onCompleted. It 1) detects loading of continue URL
* and notifies the main script of signin completion; 2) detects if the
* current page could be loaded in a constrained window and signals the main
* script of switching to full tab if necessary.
*/
onCompleted: function(details) {
// Only monitors requests in the gaia frame whose parent frame ID must be
// positive.
if (!this.isDesktopFlow_ || details.parentFrameId <= 0)
return;
var msg = null;
if (this.continueUrl_ &&
details.url.lastIndexOf(this.continueUrl_, 0) == 0) {
var skipForNow = false;
if (details.url.indexOf('ntp=1') >= 0)
skipForNow = true;
// TOOD(guohui): Show password confirmation UI.
var passwords = this.onGetScrapedPasswords_();
msg = {
'name': 'completeLogin',
'email': this.email_,
'password': passwords[0],
'sessionIndex': this.sessionIndex_,
'skipForNow': skipForNow
};
this.channelMain_.send(msg);
} else if (this.isConstrainedWindow_) {
// The header google-accounts-embedded is only set on gaia domain.
if (this.gaiaUrl_ && details.url.lastIndexOf(this.gaiaUrl_) == 0) {
var headers = details.responseHeaders;
for (var i = 0; headers && i < headers.length; ++i) {
if (headers[i].name.toLowerCase() == 'google-accounts-embedded')
return;
}
}
msg = {
'name': 'switchToFullTab',
'url': details.url
};
this.channelMain_.send(msg);
}
},
/**
* Handler for webRequest.onBeforeRequest, invoked when content served over an
* unencrypted connection is detected. Determines whether the request should
* be blocked and if so, signals that an error message needs to be shown.
* @param {string} url The URL that was blocked.
* @return {!Object} Decision whether to block the request.
*/
onInsecureRequest: function(url) {
if (!this.blockInsecureContent_)
return {};
this.channelMain_.send({name: 'onInsecureContentBlocked', url: url});
return {cancel: true};
},
/**
* Handler or webRequest.onHeadersReceived. It reads the authenticated user
* email from google-accounts-signin-header.
* @return {!Object} Modified request headers.
*/
onHeadersReceived: function(details) {
var headers = details.responseHeaders;
if (this.isDesktopFlow_ &&
this.gaiaUrl_ &&
details.url.lastIndexOf(this.gaiaUrl_) == 0) {
// TODO(xiyuan, guohui): CrOS should reuse the logic below for reading the
// email for SAML users and cut off the /ListAccount call.
for (var i = 0; headers && i < headers.length; ++i) {
if (headers[i].name.toLowerCase() == 'google-accounts-signin') {
var headerValues = headers[i].value.toLowerCase().split(',');
var signinDetails = {};
headerValues.forEach(function(e) {
var pair = e.split('=');
signinDetails[pair[0].trim()] = pair[1].trim();
});
// Remove "" around.
this.email_ = signinDetails['email'].slice(1, -1);
this.sessionIndex_ = signinDetails['sessionindex'];
break;
}
}
}
if (!this.isDesktopFlow_) {
// Check whether GAIA headers indicating the start or end of a SAML
// redirect are present. If so, synthesize cookies to mark these points.
for (var i = 0; headers && i < headers.length; ++i) {
if (headers[i].name.toLowerCase() == 'google-accounts-saml') {
var action = headers[i].value.toLowerCase();
if (action == 'start') {
// GAIA is redirecting to a SAML IdP. Any cookies contained in the
// current |headers| were set by GAIA. Any cookies set in future
// requests will be coming from the IdP. Append a cookie to the
// current |headers| that marks the point at which the redirect
// occurred.
headers.push({name: 'Set-Cookie',
value: 'google-accounts-saml-start=now'});
return {responseHeaders: headers};
} else if (action == 'end') {
// The SAML IdP has redirected back to GAIA. Add a cookie that marks
// the point at which the redirect occurred occurred. It is
// important that this cookie be prepended to the current |headers|
// because any cookies contained in the |headers| were already set
// by GAIA, not the IdP. Due to limitations in the webRequest API,
// it is not trivial to prepend a cookie:
//
// The webRequest API only allows for deleting and appending
// headers. To prepend a cookie (C), three steps are needed:
// 1) Delete any headers that set cookies (e.g., A, B).
// 2) Append a header which sets the cookie (C).
// 3) Append the original headers (A, B).
//
// Due to a further limitation of the webRequest API, it is not
// possible to delete a header in step 1) and append an identical
// header in step 3). To work around this, a trailing semicolon is
// added to each header before appending it. Trailing semicolons are
// ignored by Chrome in cookie headers, causing the modified headers
// to actually set the original cookies.
var otherHeaders = [];
var cookies = [{name: 'Set-Cookie',
value: 'google-accounts-saml-end=now'}];
for (var j = 0; j < headers.length; ++j) {
if (headers[j].name.toLowerCase().indexOf('set-cookie') == 0) {
var header = headers[j];
header.value += ';';
cookies.push(header);
} else {
otherHeaders.push(headers[j]);
}
}
return {responseHeaders: otherHeaders.concat(cookies)};
}
}
}
}
return {};
},
/**
* Handler for webRequest.onBeforeSendHeaders.
* @return {!Object} Modified request headers.
*/
onBeforeSendHeaders: function(details) {
if (!this.isDesktopFlow_ && this.gaiaUrl_ &&
details.url.indexOf(this.gaiaUrl_) == 0) {
details.requestHeaders.push({
name: 'X-Cros-Auth-Ext-Support',
value: 'SAML'
});
}
return {requestHeaders: details.requestHeaders};
},
/**
* Handler for 'setGaiaUrl' signal sent from the main script.
*/
onSetGaiaUrl_: function(msg) {
this.gaiaUrl_ = msg.gaiaUrl;
},
/**
* Handler for 'setBlockInsecureContent' signal sent from the main script.
*/
onSetBlockInsecureContent_: function(msg) {
this.blockInsecureContent_ = msg.blockInsecureContent;
},
/**
* Handler for 'resetAuth' signal sent from the main script.
*/
onResetAuth_: function() {
this.authStarted_ = false;
this.passwordStore_ = {};
},
/**
* Handler for 'authStarted' signal sent from the main script.
*/
onAuthStarted_: function() {
this.authStarted_ = true;
this.passwordStore_ = {};
},
/**
* Handler for 'getScrapedPasswords' request sent from the main script.
* @return {Array.<string>} The array with de-duped scraped passwords.
*/
onGetScrapedPasswords_: function() {
var passwords = {};
for (var property in this.passwordStore_) {
passwords[this.passwordStore_[property]] = true;
}
return Object.keys(passwords);
},
/**
* Handler for 'apiResponse' signal sent from the main script. Passes on the
* |msg| to the injected script.
*/
onAPIResponse_: function(msg) {
this.channelInjected_.send(msg);
},
onAPICall_: function(msg) {
this.channelMain_.send(msg);
},
onUpdatePassword_: function(msg) {
if (!this.authStarted_)
return;
this.passwordStore_[msg.id] = msg.password;
},
onPageLoaded_: function(msg) {
if (this.channelMain_)
this.channelMain_.send({name: 'onAuthPageLoaded', url: msg.url});
}
};
var backgroundBridgeManager = new BackgroundBridgeManager();
backgroundBridgeManager.run();