chromium/chrome/browser/resources/gaia_auth_host/saml_injected.js

// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {Channel} from './channel.js';
import {PostMessageChannel} from './post_message_channel.js';
import {WebviewScrollShadowsHelper, WebviewScrollShadowsHelperConstructor} from './scroll_helper_injected.js';

/**
 * @fileoverview
 * Script to be injected into SAML provider pages, serving three main purposes:
 * 1. Signal hosting extension that an external page is loaded so that the
 *    UI around it should be changed accordingly;
 * 2. Provide an API via which the SAML provider can pass user credentials to
 *    Chrome OS, allowing the password to be used for encrypting user data and
 *    offline login.
 * 3. Scrape password fields, making the password available to Chrome OS even if
 *    the SAML provider does not support the credential passing API.
 */

(function() {
function APICallForwarder() {}

/**
 * The credential passing API is used by sending messages to the SAML page's
 * |window| object. This class forwards API calls from the SAML page to a
 * background script and API responses from the background script to the SAML
 * page. Communication with the background script occurs via a |Channel|.
 */
APICallForwarder.prototype = {
  // Channel to which API calls are forwarded.
  channel_: null,

  /**
   * Initialize the API call forwarder.
   * @param {!Channel} channel Channel to which API calls should be forwarded.
   */
  init(channel) {
    this.channel_ = channel;
    this.channel_.registerMessage(
        'apiResponse', this.onAPIResponse_.bind(this));

    window.addEventListener('message', this.onMessage_.bind(this));
  },

  onMessage_(event) {
    if (event.source !== window || typeof event.data !== 'object' ||
        !event.data.hasOwnProperty('type') ||
        event.data.type !== 'gaia_saml_api') {
      return;
    }
    // Forward API calls to the background script.
    this.channel_.send({name: 'apiCall', call: event.data.call});
  },

  onAPIResponse_(msg) {
    // Forward API responses to the SAML page.
    window.postMessage(
        {type: 'gaia_saml_api_reply', response: msg.response}, '/');
  },
};

/**
 * A class to scrape password from type=password input elements under a given
 * docRoot and send them back via a Channel.
 */
function PasswordInputScraper() {}

PasswordInputScraper.prototype = {
  // URL of the page.
  pageURL_: null,

  // Channel to send back changed password.
  channel_: null,

  // An array to hold password fields.
  passwordFields_: null,

  // An array to hold cached password values.
  passwordValues_: null,

  // A MutationObserver to watch for dynamic password field creation.
  passwordFieldsObserver: null,

  /**
   * Initialize the scraper with given channel and docRoot. Note that the
   * scanning for password fields happens inside the function and does not
   * handle DOM tree changes after the call returns.
   * @param {!Object} channel The channel to send back password.
   * @param {!string} pageURL URL of the page.
   * @param {!HTMLElement} docRoot The root element of the DOM tree that
   *     contains the password fields of interest.
   */
  init(channel, pageURL, docRoot) {
    this.pageURL_ = pageURL;
    this.channel_ = channel;

    this.passwordFields_ = [];
    this.passwordValues_ = [];

    this.findAndTrackChildren(docRoot);

    this.passwordFieldsObserver = new MutationObserver(function(mutations) {
      mutations.forEach(function(mutation) {
        Array.prototype.forEach.call(mutation.addedNodes, function(addedNode) {
          if (addedNode.nodeType !== Node.ELEMENT_NODE) {
            return;
          }

          if (addedNode.matches('input[type=password]')) {
            this.trackPasswordField(addedNode);
          } else {
            this.findAndTrackChildren(addedNode);
          }
        }.bind(this));
      }.bind(this));
    }.bind(this));
    this.passwordFieldsObserver.observe(
        docRoot, {subtree: true, childList: true});
  },

  /**
   * Find and track password fields that are descendants of the given element.
   * @param {!HTMLElement} element The parent element to search from.
   */
  findAndTrackChildren(element) {
    Array.prototype.forEach.call(
        element.querySelectorAll('input[type=password]'), function(field) {
          this.trackPasswordField(field);
        }.bind(this));
  },

  /**
   * Start tracking value changes of the given password field if it is
   * not being tracked yet.
   * @param {!HTMLInputElement} passworField The password field to track.
   */
  trackPasswordField(passwordField) {
    const existing = this.passwordFields_.filter(function(element) {
      return element === passwordField;
    });
    if (existing.length !== 0) {
      return;
    }

    const index = this.passwordFields_.length;
    const fieldId = passwordField.id || passwordField.name || '';
    passwordField.addEventListener(
        'input', this.onPasswordChanged_.bind(this, index, fieldId));
    this.passwordFields_.push(passwordField);
    this.passwordValues_.push(passwordField.value);
  },

  /**
   * Check if the password field at |index| has changed. If so, sends back
   * the updated value.
   */
  maybeSendUpdatedPassword(index, fieldId) {
    const newValue = this.passwordFields_[index].value;
    if (newValue === this.passwordValues_[index]) {
      return;
    }

    this.passwordValues_[index] = newValue;

    // Use an invalid char for URL as delimiter to concatenate page url,
    // password field index and id to construct a unique ID for the password
    // field.
    const passwordId =
        this.pageURL_.split('#')[0].split('?')[0] + '|' + index + '|' + fieldId;
    this.channel_.send(
        {name: 'updatePassword', id: passwordId, password: newValue});
  },

  /**
   * Handles 'change' event in the scraped password fields.
   * @param {number} index The index of the password fields in
   *     |passwordFields_|.
   * @param {string} fieldId The id or name of the password field or blank.
   */
  onPasswordChanged_(index, fieldId) {
    this.maybeSendUpdatedPassword(index, fieldId);
  },
};

function onGetSAMLFlag(channel, isSAMLPage) {
  if (!isSAMLPage) {
    return;
  }
  const pageURL = window.location.href;

  channel.send({name: 'pageLoaded', url: pageURL});

  const initPasswordScraper = function() {
    const passwordScraper = new PasswordInputScraper();
    passwordScraper.init(channel, pageURL, document.documentElement);
  };

  if (document.readyState === 'loading') {
    window.addEventListener('readystatechange', function listener(event) {
      if (document.readyState === 'loading') {
        return;
      }
      initPasswordScraper();
      window.removeEventListener(event.type, listener, true);
    }, true);
  } else {
    initPasswordScraper();
  }
}

const channel = new PostMessageChannel();
channel.connect('injected');
channel.sendWithCallback(
    {name: 'getSAMLFlag'}, onGetSAMLFlag.bind(undefined, channel));

const apiCallForwarder = new APICallForwarder();
apiCallForwarder.init(channel);

// Send scroll information from the topmost frame.
if (window.top === window.self) {
  const scrollHelper = WebviewScrollShadowsHelperConstructor();
  scrollHelper.init(channel);
}

})();