Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(97)

Unified Diff: chrome/browser/resources/gaia_auth_host/saml_handler.js

Issue 1004753004: cros: Port SAML support to webview sign-in. (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: handle loadabort to fix test Created 5 years, 9 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
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
+ };
+});

Powered by Google App Engine
This is Rietveld 408576698