| Index: chrome/browser/resources/gaia_auth_host/saml_handler.js
|
| diff --git a/chrome/browser/resources/gaia_auth_host/saml_handler.js b/chrome/browser/resources/gaia_auth_host/saml_handler.js
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..4065f79552ed35f5fa5865d9b91d67d028945b70
|
| --- /dev/null
|
| +++ b/chrome/browser/resources/gaia_auth_host/saml_handler.js
|
| @@ -0,0 +1,454 @@
|
| +// Copyright 2015 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.
|
| +
|
| +<include src="post_message_channel.js">
|
| +
|
| +/**
|
| + * @fileoverview Saml support for webview based auth.
|
| + */
|
| +
|
| +cr.define('cr.login', function() {
|
| + 'use strict';
|
| +
|
| + /**
|
| + * The lowest version of the credentials passing API supported.
|
| + * @type {number}
|
| + */
|
| + var MIN_API_VERSION_VERSION = 1;
|
| +
|
| + /**
|
| + * The highest version of the credentials passing API supported.
|
| + * @type {number}
|
| + */
|
| + var MAX_API_VERSION_VERSION = 1;
|
| +
|
| + /**
|
| + * The key types supported by the credentials passing API.
|
| + * @type {Array} Array of strings.
|
| + */
|
| + var API_KEY_TYPES = [
|
| + 'KEY_TYPE_PASSWORD_PLAIN',
|
| + ];
|
| +
|
| + /** @const */
|
| + var SAML_HEADER = 'google-accounts-saml';
|
| +
|
| + /**
|
| + * The script to inject into webview and its sub frames.
|
| + * @type {string}
|
| + */
|
| + var injectedJs = String.raw`
|
| + <include src="webview_saml_injected.js">
|
| + `;
|
| +
|
| + /**
|
| + * Creates a new URL by striping all query parameters.
|
| + * @param {string} url The original URL.
|
| + * @return {string} The new URL with all query parameters stripped.
|
| + */
|
| + function stripParams(url) {
|
| + return url.substring(0, url.indexOf('?')) || url;
|
| + }
|
| +
|
| + /**
|
| + * Extract domain name from an URL.
|
| + * @param {string} url An URL string.
|
| + * @return {string} The host name of the URL.
|
| + */
|
| + function extractDomain(url) {
|
| + var a = document.createElement('a');
|
| + a.href = url;
|
| + return a.hostname;
|
| + }
|
| +
|
| + /**
|
| + * A handler to provide saml support for the given webview that hosts the
|
| + * auth IdP pages.
|
| + * @extends {cr.EventTarget}
|
| + * @param {webview} webview
|
| + * @constructor
|
| + */
|
| + function SamlHandler(webview) {
|
| + /**
|
| + * The webview that serves IdP pages.
|
| + * @type {webview}
|
| + */
|
| + this.webview_ = webview;
|
| +
|
| + /**
|
| + * Whether a Saml IdP page is display in the webview.
|
| + * @type {boolean}
|
| + */
|
| + this.isSamlPage_ = false;
|
| +
|
| + /**
|
| + * Pending Saml IdP page flag that is set when a SAML_HEADER is received
|
| + * and is copied to |isSamlPage_| in loadcommit.
|
| + * @type {boolean}
|
| + */
|
| + this.pendingIsSamlPage_ = false;
|
| +
|
| + /**
|
| + * The last aborted top level url. It is recorded in loadabort event and
|
| + * used to skip injection into Chrome's error page in the following
|
| + * loadcommit event.
|
| + * @type {string}
|
| + */
|
| + this.abortedTopLevelUrl_ = null;
|
| +
|
| + /**
|
| + * The domain of the Saml IdP.
|
| + * @type {string}
|
| + */
|
| + this.authDomain = '';
|
| +
|
| + /**
|
| + * Scraped password stored in an id to password field value map.
|
| + * @type {Object.<string, string>}
|
| + * @private
|
| + */
|
| + this.passwordStore_ = {};
|
| +
|
| + /**
|
| + * Whether Saml API is initialized.
|
| + * @type {boolean}
|
| + */
|
| + this.apiInitialized_ = false;
|
| +
|
| + /**
|
| + * Saml API version to use.
|
| + * @type {number}
|
| + */
|
| + this.apiVersion_ = 0;
|
| +
|
| + /**
|
| + * Saml API token received.
|
| + * @type {string}
|
| + */
|
| + this.apiToken_ = null;
|
| +
|
| + /**
|
| + * Saml API password bytes.
|
| + * @type {string}
|
| + */
|
| + this.apiPasswordBytes_ = null;
|
| +
|
| + /*
|
| + * Whether to abort the authentication flow and show an error messagen when
|
| + * content served over an unencrypted connection is detected.
|
| + * @type {boolean}
|
| + */
|
| + this.blockInsecureContent = false;
|
| +
|
| + this.webview_.addEventListener(
|
| + 'loadabort', this.onLoadAbort_.bind(this));
|
| + this.webview_.addEventListener(
|
| + 'loadcommit', this.onLoadCommit_.bind(this));
|
| +
|
| + this.webview_.request.onBeforeRequest.addListener(
|
| + this.onInsecureRequest.bind(this),
|
| + {urls: ['http://*/*', 'file://*/*', 'ftp://*/*']},
|
| + ['blocking']);
|
| + this.webview_.request.onHeadersReceived.addListener(
|
| + this.onHeadersReceived_.bind(this),
|
| + {urls: ['<all_urls>'], types: ['main_frame', 'xmlhttprequest']},
|
| + ['blocking', 'responseHeaders']);
|
| +
|
| + PostMessageChannel.runAsDaemon(this.onConnected_.bind(this));
|
| + }
|
| +
|
| + SamlHandler.prototype = {
|
| + __proto__: cr.EventTarget.prototype,
|
| +
|
| + /**
|
| + * Whether Saml API is used during auth.
|
| + * @return {boolean}
|
| + */
|
| + get samlApiUsed() {
|
| + return !!this.apiPasswordBytes_;
|
| + },
|
| +
|
| + /**
|
| + * Returns the Saml API password bytes.
|
| + * @return {string}
|
| + */
|
| + get apiPasswordBytes() {
|
| + return this.apiPasswordBytes_;
|
| + },
|
| +
|
| + /**
|
| + * Returns the number of scraped passwords.
|
| + * @return {number}
|
| + */
|
| + get scrapedPasswordCount() {
|
| + return this.getConsolidatedScrapedPasswords_().length;
|
| + },
|
| +
|
| + /**
|
| + * Gets the de-duped scraped passwords.
|
| + * @return {Array.<string>}
|
| + * @private
|
| + */
|
| + getConsolidatedScrapedPasswords_: function() {
|
| + var passwords = {};
|
| + for (var property in this.passwordStore_) {
|
| + passwords[this.passwordStore_[property]] = true;
|
| + }
|
| + return Object.keys(passwords);
|
| + },
|
| +
|
| + /**
|
| + * Resets all auth states
|
| + */
|
| + reset: function() {
|
| + this.isSamlPage_ = false;
|
| + this.pendingIsSamlPage_ = false;
|
| + this.passwordStore_ = {};
|
| +
|
| + this.apiInitialized_ = false;
|
| + this.apiVersion_ = 0;
|
| + this.apiToken_ = null;
|
| + this.apiPasswordBytes_ = null;
|
| + },
|
| +
|
| + /**
|
| + * Check whether the given |password| is in the scraped passwords.
|
| + * @return {boolean} True if the |password| is found.
|
| + */
|
| + verifyConfirmedPassword: function(password) {
|
| + return this.getConsolidatedScrapedPasswords_().indexOf(password) >= 0;
|
| + },
|
| +
|
| + /**
|
| + * Injects JS code to all frames.
|
| + * @private
|
| + */
|
| + injectJs_: function() {
|
| + if (!injectedJs)
|
| + return;
|
| +
|
| + // TODO(xiyuan): Replace this with webview.addContentScript.
|
| + this.webview_.executeScript({
|
| + code: injectedJs,
|
| + allFrames: true,
|
| + runAt: 'document_start'
|
| + }, (function() {
|
| + PostMessageChannel.init(this.webview_.contentWindow);
|
| + }).bind(this));
|
| + },
|
| +
|
| + /**
|
| + * Invoked on the webview's loadabort event.
|
| + * @private
|
| + */
|
| + onLoadAbort_: function(e) {
|
| + if (e.isTopLevel)
|
| + this.abortedTopLevelUrl_ = e.url;
|
| + },
|
| +
|
| + /**
|
| + * Invoked on the webview's loadcommit event for both main and sub frames.
|
| + * @private
|
| + */
|
| + onLoadCommit_: function(e) {
|
| + // Skip this loadcommit if the top level load is just aborted.
|
| + if (e.isTopLevel && e.url === this.abortedTopLevelUrl_) {
|
| + this.abortedTopLevelUrl_ = null;
|
| + return;
|
| + }
|
| +
|
| + this.isSamlPage_ = this.pendingIsSamlPage_;
|
| + this.injectJs_();
|
| + },
|
| +
|
| + /**
|
| + * 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 {Object} details
|
| + * @return {!Object} Decision whether to block the request.
|
| + */
|
| + onInsecureRequest: function(details) {
|
| + if (!this.blockInsecureContent)
|
| + return {};
|
| + var strippedUrl = stripParams(details.url);
|
| + this.dispatchEvent(new CustomEvent('insecureContentBlocked',
|
| + {detail: {url: strippedUrl}}));
|
| + return {cancel: true};
|
| + },
|
| +
|
| + /**
|
| + * Invoked when headers are received for the main frame.
|
| + * @private
|
| + */
|
| + onHeadersReceived_: function(details) {
|
| + var headers = details.responseHeaders;
|
| +
|
| + // 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) {
|
| + var header = headers[i];
|
| + var headerName = header.name.toLowerCase();
|
| +
|
| + if (headerName == SAML_HEADER) {
|
| + var action = header.value.toLowerCase();
|
| + if (action == 'start') {
|
| + this.pendingIsSamlPage_ = true;
|
| +
|
| + // 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') {
|
| + this.pendingIsSamlPage_ = false;
|
| +
|
| + // 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 {};
|
| + },
|
| +
|
| + /**
|
| + * Invoked when the injected JS makes a connection.
|
| + */
|
| + onConnected_: function(port) {
|
| + if (port.targetWindow != this.webview_.contentWindow)
|
| + return;
|
| +
|
| + var channel = Channel.create();
|
| + channel.init(port);
|
| +
|
| + channel.registerMessage(
|
| + 'apiCall', this.onAPICall_.bind(this, channel));
|
| + channel.registerMessage(
|
| + 'updatePassword', this.onUpdatePassword_.bind(this, channel));
|
| + channel.registerMessage(
|
| + 'pageLoaded', this.onPageLoaded_.bind(this, channel));
|
| + channel.registerMessage(
|
| + 'getSAMLFlag', this.onGetSAMLFlag_.bind(this, channel));
|
| + },
|
| +
|
| + sendInitializationSuccess_: function(channel) {
|
| + channel.send({name: 'apiResponse', response: {
|
| + result: 'initialized',
|
| + version: this.apiVersion_,
|
| + keyTypes: API_KEY_TYPES
|
| + }});
|
| + },
|
| +
|
| + sendInitializationFailure_: function(channel) {
|
| + channel.send({
|
| + name: 'apiResponse',
|
| + response: {result: 'initialization_failed'}
|
| + });
|
| + },
|
| +
|
| + /**
|
| + * Handlers for channel messages.
|
| + * @param {Channel} channel A channel to send back response.
|
| + * @param {Object} msg Received message.
|
| + * @private
|
| + */
|
| + onAPICall_: function(channel, msg) {
|
| + var call = msg.call;
|
| + if (call.method == 'initialize') {
|
| + if (!Number.isInteger(call.requestedVersion) ||
|
| + call.requestedVersion < MIN_API_VERSION_VERSION) {
|
| + this.sendInitializationFailure_(channel);
|
| + return;
|
| + }
|
| +
|
| + this.apiVersion_ = Math.min(call.requestedVersion,
|
| + MAX_API_VERSION_VERSION);
|
| + this.apiInitialized_ = true;
|
| + this.sendInitializationSuccess_(channel);
|
| + return;
|
| + }
|
| +
|
| + if (call.method == 'add') {
|
| + if (API_KEY_TYPES.indexOf(call.keyType) == -1) {
|
| + console.error('SamlHandler.onAPICall_: unsupported key type');
|
| + return;
|
| + }
|
| + // Not setting |email_| and |gaiaId_| because this API call will
|
| + // eventually be followed by onCompleteLogin_() which does set it.
|
| + this.apiToken_ = call.token;
|
| + this.apiPasswordBytes_ = call.passwordBytes;
|
| + } else if (call.method == 'confirm') {
|
| + if (call.token != this.apiToken_)
|
| + console.error('SamlHandler.onAPICall_: token mismatch');
|
| + } else {
|
| + console.error('SamlHandler.onAPICall_: unknown message');
|
| + }
|
| + },
|
| +
|
| + onUpdatePassword_: function(channel, msg) {
|
| + if (this.isSamlPage_)
|
| + this.passwordStore_[msg.id] = msg.password;
|
| + },
|
| +
|
| + onPageLoaded_: function(channel, msg) {
|
| + this.authDomain = extractDomain(msg.url);
|
| + this.dispatchEvent(new CustomEvent(
|
| + 'authPageLoaded',
|
| + {detail: {url: url,
|
| + isSAMLPage: this.isSamlPage_,
|
| + domain: this.authDomain}}));
|
| + },
|
| +
|
| + onGetSAMLFlag_: function(channel, msg) {
|
| + return this.isSamlPage_;
|
| + },
|
| + };
|
| +
|
| + /**
|
| + * Sets the saml injected JS code.
|
| + * @param {string} samlInjectedJs JS code to inejct for Saml.
|
| + */
|
| + SamlHandler.setSamlInjectedJs = function(samlInjectedJs) {
|
| + injectedJs = samlInjectedJs;
|
| + };
|
| +
|
| + return {
|
| + SamlHandler: SamlHandler
|
| + };
|
| +});
|
|
|