// 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. /** * @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` `; /** * 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} * @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: [''], 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} * @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 }; });