| // 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( |
| 'contentload', this.onContentLoad_.bind(this)); |
| 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']); |
| |
| this.webview_.addContentScripts([{ |
| name: 'samlInjected', |
| matches: ['http://*/*', 'https://*/*'], |
| js: { |
| code: injectedJs |
| }, |
| all_frames: true, |
| run_at: 'document_start' |
| }]); |
| |
| 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; |
| }, |
| |
| /** |
| * Invoked on the webview's contentload event. |
| * @private |
| */ |
| onContentLoad_: function(e) { |
| PostMessageChannel.init(this.webview_.contentWindow); |
| }, |
| |
| /** |
| * 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; |
| } |
| |
| // Skip for none http/https url. |
| if (e.url.indexOf('https://') != 0 && |
| e.url.indexOf('http://') != 0) { |
| return; |
| } |
| |
| this.isSamlPage_ = this.pendingIsSamlPage_; |
| }, |
| |
| /** |
| * 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_; |
| }, |
| }; |
| |
| return { |
| SamlHandler: SamlHandler |
| }; |
| }); |