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

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

// <if expr="chromeos_ash">
import {NativeEventTarget as EventTarget} from 'chrome://resources/ash/common/event_target.js';

// </if>

import {Channel} from './channel.js';
import {PostMessageChannel} from './post_message_channel.js';
import {SafeXMLUtils} from './safe_xml_utils.js';
import {PasswordAttributes, readPasswordAttributes} from './saml_password_attributes.js';
import {maybeAutofillUsername} from './saml_username_autofill.js';
import {WebviewEventManager} from './webview_event_manager.js';

/**
 * @fileoverview Saml support for webview based auth.
 */

  /**
   * The lowest version of the credentials passing API supported.
   * @type {number}
   */
  const MIN_API_VERSION_VERSION = 1;

  /**
   * The highest version of the credentials passing API supported.
   * @type {number}
   */
  const MAX_API_VERSION_VERSION = 1;

  /**
   * The key types supported by the credentials passing API.
   * @type {Array} Array of strings.
   */
  const API_KEY_TYPES = [
    'KEY_TYPE_PASSWORD_PLAIN',
  ];

  /** @const */
  const SAML_HEADER = 'google-accounts-saml';

  /** @const */
  const SAML_DEVICE_TRUST_HEADER = 'x-device-trust';

  /** @const */
  const SAML_VERIFIED_ACCESS_CHALLENGE_HEADER = 'x-verified-access-challenge';
  /** @const */
  const SAML_VERIFIED_ACCESS_RESPONSE_HEADER =
      'x-verified-access-challenge-response';

  /** @const */
  const injectedScriptName = 'samlInjected';

  /** @const */
  const SAML_API_Error = 'ChromeOS.SAML.APIError';

  /** @const */
  const SAML_INCORRECT_ATTESTATION = 'ChromeOS.SAML.IncorrectAttestation';

  /**
   * The script to inject into webview and its sub frames.
   * @type {string}
   */
  const injectedJs = 'gaia_auth_host/saml_injected.rollup.js';

  /**
   * @typedef {{
   *   method: string,
   *   requestedVersion: number,
   *   keyType: string,
   *   token: string,
   *   passwordBytes: string
   * }}
   */
  let ApiCallMessageCall;

  /**
   * @typedef {{
   *   name: string,
   *   call: ApiCallMessageCall
   * }}
   */
  let ApiCallMessage;

  /**
   * Details about the request.
   * @typedef {{
   *   method: string,
   *   requestBody: Object,
   *   url: string
   * }}
   * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/onBeforeRequest#details
   */
  export let OnBeforeRequestDetails;

  /**
   * Details of the request.
   * @typedef {{
   *   responseHeaders: Array<HttpHeader>,
   *   statusCode: number,
   *   url: string,
   * }}
   * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/onHeadersReceived#details
   */
  export let OnHeadersReceivedDetails;

  /**
   * 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;
  }

  /**
   * A handler to provide saml support for the given webview that hosts the
   * auth IdP pages.
   */
  export class SamlHandler extends EventTarget {
    /**
     * @param {!WebView} webview
     * @param {boolean} startsOnSamlPage - whether initial URL is already SAML
     *                  page
     * */
    constructor(webview, startsOnSamlPage) {
      super();

      /**
       * Device attestation flow stages.
       * @enum {number}
       * @private
       */
      SamlHandler.DeviceAttestationStage = {
        // No device attestation in progress.
        NONE: 1,
        // A Redirect was received with a HTTP header that contained a device
        // attestation challenge.
        CHALLENGE_RECEIVED: 2,
        // The Redirect has been canceled and a device attestation challenge
        // response is being computed.
        ORIGINAL_REDIRECT_CANCELED: 3,
        // The device attestation challenge response is available and the
        // original Redirect is being followed with the response included in a
        // HTTP header.
        NAVIGATING_TO_REDIRECT_PAGE: 4,
        // The attestation flow belongs to Device Trust. It should be ignored by
        // the Verified Access for SAML feature implemented in this file.
        DEVICE_TRUST_FLOW: 5,
      };

      /**
       * This enum is tied directly to a UMA enum defined in
       * //tools/metrics/histograms/metadata/chromeos/enums.xml, and should
       * always reflect it (do not change one without changing the other). These
       * values are persisted to logs. Entries should not be renumbered and
       * numeric values should never be reused.
       * @enum {number}
       */
      SamlHandler.ApiErrorType = {
        // IdP sent unsupported key type.
        UNSUPPORTED_KEY: 0,
        // Gaia wanted to create an account while feature is not supported.
        UNSUPPORTED_MESSAGE: 1,
        // Gaia wanted to create an account for a user that wasn't added.
        CREATE_TOKEN_MISMATCH: 2,
        // IdP confirmed token that wasn't added.
        CONFIRM_TOKEN_MISMATCH: 3,
        // IdP sent a message that isn't supported in SAML API.
        UNKNOWN_MESSAGE: 4,
        // IdP didn't send user's password confirmation.
        PASSWORD_NOT_CONFIRMED: 5,
        // Enum Max value.
        MAX: 6,
      };

      /**
       * This enum is tied directly to a UMA enum defined in
       * //tools/metrics/histograms/metadata/chromeos/enums.xml, and should
       * always reflect it (do not change one without changing the other). These
       * values are persisted to logs. Entries should not be renumbered and
       * numeric values should never be reused.
       * @enum {number}
       */
      SamlHandler.IncorrectAttestationStage = {
        // onBeforeRequest_(details) method.
        ON_BEFORE_REQUEST: 0,
        // onBeforeSendHeaders_(details) method.
        ON_BEFORE_SEND_HEADERS: 1,
        // continueDelayedRedirect_(url, challengeResponse) method.
        CONTINUE_DELAYED_REDIRECT: 2,
        // Enum Max value.
        MAX: 3,
      };

      /**
       * The webview that serves IdP pages.
       * @private {!WebView}
       */
      this.webview_ = webview;

      /**
       * Whether a Saml page is in the webview from the start.
       * @private {boolean}
       */
      this.startsOnSamlPage_ = startsOnSamlPage;

      /**
       * Whether a Saml IdP page is display in the webview.
       * @private {boolean}
       */
      this.isSamlPage_ = this.startsOnSamlPage_;

      /**
       * Pending Saml IdP page flag that is set when a SAML_HEADER is received
       * and is copied to |isSamlPage_| in loadcommit.
       * @private {boolean}
       */
      this.pendingIsSamlPage_ = this.startsOnSamlPage_;

      /**
       * 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.
       * @private {?string}
       */
      this.abortedTopLevelUrl_ = null;

      /**
       * Scraped password stored in an id to password field value map.
       * @private {!Object<string, string>}
       */
      this.passwordStore_ = {};

      /**
       * Whether Saml API is initialized.
       * @private {boolean}
       */
      this.apiInitialized_ = false;

      /**
       * Saml API version to use.
       * @private {number}
       */
      this.apiVersion_ = 0;

      /**
       * Saml API tokens received.
       * @private {!Object}
       */
      this.apiTokenStore_ = {};

      /**
       * Saml API confirmation token. Set by last 'confirm' call.
       * @private {?string}
       */
      this.confirmToken_ = null;

      /**
       * Saml API password bytes set by last 'add' call. Needed to not break
       * existing behavior.
       * @private {?string}
       */
      this.lastApiPasswordBytes_ = null;

      /**
       * Whether to abort the authentication flow and show an error message
       * when content served over an unencrypted connection is detected.
       * @type {boolean}
       */
      this.blockInsecureContent = false;

      /**
       * Whether to attempt to extract password attributes from the SAMLResponse
       * XML. See saml_password_attributes.js
       * @type {boolean}
       */
      this.extractSamlPasswordAttributes = false;

      /**
       * Current stage of device attestation flow.
       * @private {!SamlHandler.DeviceAttestationStage}
       */
      this.deviceAttestationStage_ = SamlHandler.DeviceAttestationStage.NONE;

      /**
       * Challenge from IdP to perform device attestation.
       * @private {?string}
       */
      this.verifiedAccessChallenge_ = null;

      /**
       * Response for a device attestation challenge.
       * @private {?string}
       */
      this.verifiedAccessChallengeResponse_ = null;

      /**
       * If set, this should handle the account creation message.
       * If not set, this will log any account creation message as invalid call.
       * @public {?boolean}
       */
      this.shouldHandleAccountCreationMessage = false;

      /**
       * Certificate that were extracted from the SAMLResponse.
       * @public {?string}
       */
      this.x509certificate = null;

      /**
       * The password-attributes that were extracted from the SAMLResponse, if
       * any. (Doesn't contain the password itself).
       * @private {!PasswordAttributes}
       */
      this.passwordAttributes_ = PasswordAttributes.EMPTY;

      /**
       * User's email.
       * @public {?string}
       */
      this.email = null;

      /**
       * Url parameter name for SAML IdP web page which is used to autofill the
       * username.
       * @public {?string}
       */
      this.urlParameterToAutofillSAMLUsername = null;

      this.webviewEventManager_ = new WebviewEventManager();

      this.webviewEventManager_.addEventListener(
          this.webview_, 'contentload', this.onContentLoad_.bind(this));
      this.webviewEventManager_.addEventListener(
          this.webview_, 'loadabort', this.onLoadAbort_.bind(this));
      this.webviewEventManager_.addEventListener(
          this.webview_, 'permissionrequest',
          this.onPermissionRequest_.bind(this));

      this.webviewEventManager_.addWebRequestEventListener(
          this.webview_.request.onBeforeRequest,
          this.onInsecureRequest.bind(this),
          {urls: ['http://*/*', 'file://*/*', 'ftp://*/*']}, ['blocking']);

      this.webviewEventManager_.addWebRequestEventListener(
          this.webview_.request.onBeforeRequest,
          this.onMainFrameWebRequest.bind(this),
          {urls: ['http://*/*', 'https://*/*'], types: ['main_frame']},
          ['requestBody']);

      this.webviewEventManager_.addWebRequestEventListener(
          this.webview_.request.onBeforeRequest,
          this.onMainFrameHttpsWebRequest_.bind(this),
          {urls: ['https://*/*'], types: ['main_frame']}, ['blocking']);

      if (!this.startsOnSamlPage_) {
        this.webviewEventManager_.addEventListener(
            this.webview_, 'loadcommit', this.onLoadCommit_.bind(this));

        this.webviewEventManager_.addWebRequestEventListener(
            this.webview_.request.onBeforeRequest,
            this.onBeforeRequest_.bind(this),
            {urls: ['<all_urls>'], types: ['main_frame', 'xmlhttprequest']},
            ['blocking']);

        this.webviewEventManager_.addWebRequestEventListener(
            this.webview_.request.onBeforeSendHeaders,
            this.onBeforeSendHeaders_.bind(this),
            {urls: ['<all_urls>'], types: ['main_frame', 'xmlhttprequest']},
            ['blocking', 'requestHeaders']);

        this.webviewEventManager_.addWebRequestEventListener(
            this.webview_.request.onHeadersReceived,
            this.onHeadersReceived_.bind(this),
            {urls: ['<all_urls>'], types: ['main_frame', 'xmlhttprequest']},
            ['blocking', 'responseHeaders']);
      }

      this.webview_.addContentScripts([{
        name: injectedScriptName,
        matches: ['http://*/*', 'https://*/*'],
        js: {files: [injectedJs]},
        all_frames: true,
        run_at: 'document_start',
      }]);

      PostMessageChannel.runAsDaemon(this.onConnected_.bind(this));
    }

    /**
     * Whether Saml API is used during auth.
     * @return {boolean}
     */
    get samlApiUsed() {
      return !!this.lastApiPasswordBytes_;
    }

    /**
     * Returns the Saml API password bytes.
     * @return {?string}
     */
    get apiPasswordBytes() {
      if (this.confirmToken_ != null &&
          typeof (this.apiTokenStore_[this.confirmToken_]) === 'object' &&
          typeof (this.apiTokenStore_[this.confirmToken_]['passwordBytes']) ===
              'string') {
        return this.apiTokenStore_[this.confirmToken_]['passwordBytes'];
      }
      return this.lastApiPasswordBytes_;
    }

    /**
     * Returns the first scraped password if any, or an empty string otherwise.
     * @return {string}
     */
    get firstScrapedPassword() {
      const scraped = this.getConsolidatedScrapedPasswords_();
      return scraped.length ? scraped[0] : '';
    }

    /**
     * Returns the number of scraped passwords.
     * @return {number}
     */
    get scrapedPasswordCount() {
      return this.getConsolidatedScrapedPasswords_().length;
    }

    get scrapedPasswords() {
      return this.getConsolidatedScrapedPasswords_();
    }

    /**
     * Gets the list of passwords which have matching passwordProperty and
     * are scraped exactly |times| times.
     * @return {Array<string>}
     */
    getPasswordsWithPropertyScrapedTimes(times, passwordProperty) {
      const passwords = {};
      for (const property in this.passwordStore_) {
        if (passwordProperty && !property.match(passwordProperty)) {
          continue;
        }
        const key = this.passwordStore_[property];
        passwords[key] = (passwords[key] + 1) || 1;
      }
      return Object.keys(passwords).filter(key => passwords[key] === times);
    }

    /**
     * Gets the de-duped scraped passwords.
     * @return {Array<string>}
     * @private
     */
    getConsolidatedScrapedPasswords_() {
      const passwords = {};
      for (const property in this.passwordStore_) {
        passwords[this.passwordStore_[property]] = true;
      }
      return Object.keys(passwords);
    }

    /**
     * Gets the password attributes extracted from SAML Response.
     * @return {Object}
     */
    get passwordAttributes() {
      return this.passwordAttributes_;
    }

    /**
     * Sets the startsOnSamlPage attribute of the SAML handler.
     * @param {boolean} value
     */
    set startsOnSamlPage(value) {
      this.startsOnSamlPage_ = value;
      this.reset();
    }

    /**
     * Removes the injected content script and unbinds all listeners from the
     * webview passed to the constructor. This SAMLHandler will be unusable
     * after this function returns.
     */
    unbindFromWebview() {
      this.webview_.removeContentScripts([injectedScriptName]);
      this.webviewEventManager_.removeAllListeners();
    }

    /**
     * Resets all auth states
     */
    reset() {
      console.info('SamlHandler.reset: resets all auth states');
      this.isSamlPage_ = this.startsOnSamlPage_;
      this.pendingIsSamlPage_ = this.startsOnSamlPage_;
      this.passwordStore_ = {};

      this.deviceAttestationStage_ = SamlHandler.DeviceAttestationStage.NONE;
      this.verifiedAccessChallenge_ = null;
      this.verifiedAccessChallengeResponse_ = null;

      this.apiInitialized_ = false;
      this.apiVersion_ = 0;
      this.apiTokenStore_ = {};
      this.confirmToken_ = null;
      this.lastApiPasswordBytes_ = null;
      this.passwordAttributes_ = PasswordAttributes.EMPTY;
      this.x509certificate = null;

      this.email = null;
      this.urlParameterToAutofillSAMLUsername = null;
    }

    /**
     * Check whether the given |password| is in the scraped passwords.
     * @return {boolean} True if the |password| is found.
     */
    verifyConfirmedPassword(password) {
      return this.getConsolidatedScrapedPasswords_().indexOf(password) >= 0;
    }

    /**
     * Check that last navigation was aborted intentionally. It will be
     * continued later, so the abort event can be ignored.
     * @return {boolean}
     */
    isIntentionalAbort() {
      return this.deviceAttestationStage_ ===
          SamlHandler.DeviceAttestationStage.ORIGINAL_REDIRECT_CANCELED;
    }

    /**
     * Invoked on the webview's contentload event.
     * @private
     */
    onContentLoad_(e) {
      // |this.webview_.contentWindow| may be null after network error screen
      // is shown. See crbug.com/770999.
      if (this.webview_.contentWindow) {
        PostMessageChannel.init(this.webview_.contentWindow);
      } else {
        console.error('SamlHandler.onContentLoad_: contentWindow is null.');
      }
    }

    /**
     * Invoked on the webview's loadabort event.
     * @private
     */
    onLoadAbort_(e) {
      if (this.isIntentionalAbort()) {
        return;
      }

      if (e.isTopLevel) {
        this.abortedTopLevelUrl_ = e.url;
      }
    }

    /**
     * Invoked on the webview's loadcommit event for both main and sub frames.
     * @private
     */
    onLoadCommit_(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.startsWith('https://') && !e.url.startsWith('http://')) {
        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(details) {
      if (!this.blockInsecureContent) {
        return {};
      }
      const strippedUrl = stripParams(details.url);
      this.dispatchEvent(new CustomEvent(
          'insecureContentBlocked', {detail: {url: strippedUrl}}));
      return {cancel: true};
    }

    /**
     * Set x509certificate in pem-format which is extracted from samlResponse
     * and will be used to record SAML provider
     * @param {string} samlResponse SAML response which is received from SAML
     *     page.
     * @private
     */
    setX509certificate_(samlResponse) {
      const xmlUtils = new SafeXMLUtils(samlResponse);
      this.x509certificate = xmlUtils.getX509Certificate();
    }

    /**
     * Handler for webRequest.onBeforeRequest that looks for the Base64
     * encoded SAMLResponse in the POST-ed formdata sent from the SAML page.
     * Non-blocking.
     * @param {OnBeforeRequestDetails} details The web-request details.
     */
    onMainFrameWebRequest(details) {
      if (!this.extractSamlPasswordAttributes) {
        return;
      }
      if (!this.isSamlPage_ || details.method !== 'POST') {
        return;
      }

      const formData = details.requestBody.formData;
      let samlResponse = (formData && formData.SAMLResponse);
      if (!samlResponse) {
        samlResponse = new URL(details.url).searchParams.get('SAMLResponse');
      }
      if (!samlResponse) {
        return;
      }

      try {
        // atob means asciiToBinary, which actually means base64Decode:
        samlResponse = window.atob(samlResponse);
      } catch (decodingError) {
        console.warn('SAMLResponse is not Base64 encoded');
        return;
      }

      this.setX509certificate_(samlResponse);

      this.passwordAttributes_ = readPasswordAttributes(samlResponse);
    }

    /**
     * Handler for webRequest.onBeforeRequest, used to optionally add a url
     * parameter to the IdP login page in order to autofill the username field.
     * @param {OnBeforeRequestDetails} details The web-request details.
     * @return {BlockingResponse} Allows the event handler to modify network
     *     requests.
     * @private
     */
    onMainFrameHttpsWebRequest_(details) {
      // Ignore GAIA page - we are only interested in 3P IdP page here.
      if (!this.isSamlPage_ && !this.pendingIsSamlPage_) {
        return {};
      }
      const urlToAutofillUsername = maybeAutofillUsername(
          details.url, this.urlParameterToAutofillSAMLUsername, this.email);
      if (urlToAutofillUsername) {
        return {redirectUrl: urlToAutofillUsername};
      }
      return {};
    }

    /**
     * Receives a response for a device attestation challenge and navigates to
     * saved redirect page.
     * @param {string} url Url from canceled redirect.
     * @param {{success: boolean, response: string}} challengeResponse Response
     *     for device attestation challenge. If |success| is true, |response|
     *     contains challenge response. Otherwise |response| contains empty
     *     string.
     * @private
     */
    continueDelayedRedirect_(url, challengeResponse) {
      if (this.deviceAttestationStage_ !==
          SamlHandler.DeviceAttestationStage.ORIGINAL_REDIRECT_CANCELED) {
        console.warn(
            'SamlHandler.continueDelayedRedirect_: incorrect attestation stage');
        this.recordInIncorrectAttestationHistogram_(
            SamlHandler.IncorrectAttestationStage.CONTINUE_DELAYED_REDIRECT);
        return;
      }

      // Save response only if it is successful.
      if (challengeResponse.success) {
        this.verifiedAccessChallengeResponse_ = challengeResponse.response;
      }

      // Navigate to the saved destination from the canceled redirect.
      this.deviceAttestationStage_ =
          SamlHandler.DeviceAttestationStage.NAVIGATING_TO_REDIRECT_PAGE;
      this.webview_.src = url;
    }

    /**
     * Invoked before sending a web request. If a challenge for the remote
     * attestation was found in a previous request, cancel the current one. It
     * will be continued (reinitiated) later when a challenge response is ready.
     * @param {Object} details The web-request details.
     * @return {BlockingResponse} Allows the event handler to modify network
     *     requests.
     * @private
     */
    onBeforeRequest_(details) {
      // Default case without Verified Access.
      if (this.deviceAttestationStage_ ===
          SamlHandler.DeviceAttestationStage.NONE) {
        return {};
      }

      if (this.deviceAttestationStage_ ===
          SamlHandler.DeviceAttestationStage.NAVIGATING_TO_REDIRECT_PAGE) {
        return {};
      }

      if ((this.deviceAttestationStage_ ===
           SamlHandler.DeviceAttestationStage.CHALLENGE_RECEIVED) &&
          (this.verifiedAccessChallenge_ !== null)) {
        // Ask backend to compute response for device attestation challenge.
        this.dispatchEvent(new CustomEvent('challengeMachineKeyRequired', {
          detail: {
            url: details.url,
            challenge: this.verifiedAccessChallenge_,
            callback: this.continueDelayedRedirect_.bind(this, details.url),
          },
        }));

        this.verifiedAccessChallenge_ = null;

        // Cancel redirect by changing destination to javascript:void(0).
        // That will produce 'loadabort' event that should be ignored.
        this.deviceAttestationStage_ =
            SamlHandler.DeviceAttestationStage.ORIGINAL_REDIRECT_CANCELED;
        return {redirectUrl: 'javascript:void(0)'};
      }

      // Reset state in case of unexpected requests during device attestation.
      this.deviceAttestationStage_ = SamlHandler.DeviceAttestationStage.NONE;
      console.warn('SamlHandler.onBeforeRequest_: incorrect attestation stage');
      this.recordInIncorrectAttestationHistogram_(
          SamlHandler.IncorrectAttestationStage.ON_BEFORE_REQUEST);
      return {};
    }

    /**
     * Checks if the attestation flow belongs to Device Trust and if so skip
     * Verified Access. Otherwise attaches challenge response during device
     * attestation flow.
     * @param {Object} details The web-request details.
     * @return {BlockingResponse} Allows the event handler to modify network
     *     requests.
     * @private
     */
    onBeforeSendHeaders_(details) {
      // Default case without Verified Access.
      if (this.deviceAttestationStage_ ===
          SamlHandler.DeviceAttestationStage.NONE) {
        // Check if the attestation flow was initiated by device trust.
        const headersRequest = details.requestHeaders;

        if (!headersRequest) {
          return {};
        }

        // TODO(b/246818937): Remove this for loop.
        for (const headerRequest of headersRequest) {
          const headerRequestName = headerRequest.name.toLowerCase();
          if (headerRequestName === SAML_DEVICE_TRUST_HEADER) {
            this.deviceAttestationStage_ =
                SamlHandler.DeviceAttestationStage.DEVICE_TRUST_FLOW;
            return {};
          }
        }
        return {};
      }

      if (this.deviceAttestationStage_ ===
          SamlHandler.DeviceAttestationStage.NAVIGATING_TO_REDIRECT_PAGE) {
        // Send extra header only if no error was encountered during challenge
        // key procedure.
        if (this.verifiedAccessChallengeResponse_ === null) {
          this.deviceAttestationStage_ =
              SamlHandler.DeviceAttestationStage.NONE;
          return {};
        }

        details.requestHeaders.push({
          'name': SAML_VERIFIED_ACCESS_RESPONSE_HEADER,
          'value': this.verifiedAccessChallengeResponse_,
        });

        this.verifiedAccessChallengeResponse_ = null;
        this.deviceAttestationStage_ = SamlHandler.DeviceAttestationStage.NONE;

        return {requestHeaders: details.requestHeaders};
      }

      // Reset state in case of unexpected navigation during device attestation.
      this.deviceAttestationStage_ = SamlHandler.DeviceAttestationStage.NONE;
      console.warn(
          'SamlHandler.onBeforeSendHeaders_: incorrect attestation stage');
      this.recordInIncorrectAttestationHistogram_(
          SamlHandler.IncorrectAttestationStage.ON_BEFORE_SEND_HEADERS);
      return {};
    }

    /**
     * Invoked when headers are received for the main frame.
     * @private
     */
    onHeadersReceived_(details) {
      if (this.deviceAttestationStage_ ===
          SamlHandler.DeviceAttestationStage.DEVICE_TRUST_FLOW) {
        return {};
      }

      const headers = details.responseHeaders;

      // Check whether GAIA headers indicating the start or end of a SAML
      // redirect are present.
      for (let i = 0; headers && i < headers.length; ++i) {
        const header = headers[i];
        const headerName = header.name.toLowerCase();

        if (headerName === SAML_HEADER) {
          const action = header.value.toLowerCase();
          if (action === 'start') {
            console.info('SamlHandler.onHeadersReceived_: SAML flow start');
            this.pendingIsSamlPage_ = true;
          } else if (action === 'end') {
            console.info('SamlHandler.onHeadersReceived_: SAML flow end');
            this.pendingIsSamlPage_ = false;
          }
        }

        // If true, IdP tries to perform a device attestation.
        // 300 <= .. <= 399 means it is a redirect to a page that will verify
        // device response. HTTP header with
        // |SAML_VERIFIED_ACCESS_CHALLENGE_HEADER| name contains challenge from
        // Verified Access Web API.
        if ((details.statusCode >= 300) && (details.statusCode <= 399) &&
            (headerName === SAML_VERIFIED_ACCESS_CHALLENGE_HEADER)) {
          this.deviceAttestationStage_ =
              SamlHandler.DeviceAttestationStage.CHALLENGE_RECEIVED;
          this.verifiedAccessChallenge_ = header.value;
        }
      }

      return {};
    }

    /**
     * Invoked when the injected JS makes a connection.
     */
    onConnected_(port) {
      if (port.targetWindow !== this.webview_.contentWindow) {
        return;
      }

      const channel = new PostMessageChannel();
      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));
      channel.registerMessage(
          'scrollInfo', this.onScrollInfo_.bind(this, channel));
    }

    sendInitializationSuccess_(channel) {
      channel.send({
        name: 'apiResponse',
        response: {
          result: 'initialized',
          version: this.apiVersion_,
          keyTypes: API_KEY_TYPES,
        },
      });
    }

    sendInitializationFailure_(channel) {
      channel.send(
          {name: 'apiResponse', response: {result: 'initialization_failed'}});
    }

    /**
     * Invoked to record value in ChromeOS.SAML.APIError metric.
     * @private
     */
    recordInAPIErrorHistogram_(value) {
      chrome.send(
          'metricsHandler:recordInHistogram',
          [SAML_API_Error, value, SamlHandler.ApiErrorType.MAX]);
    }

    /**
     * Invoked to record value in ChromeOS.SAML.IncorrectAttestation metric.
     * @private
     */
    recordInIncorrectAttestationHistogram_(value) {
      chrome.send('metricsHandler:recordInHistogram', [
        SAML_INCORRECT_ATTESTATION,
        value,
        SamlHandler.IncorrectAttestationStage.MAX,
      ]);
    }

    /**
     * Invoked to record that password wasn't confirmed in
     * ChromeOS.SAML.APIError metric.
     */
    recordPasswordNotConfirmedError() {
      this.recordInAPIErrorHistogram_(
          SamlHandler.ApiErrorType.PASSWORD_NOT_CONFIRMED);
    }

    /**
     * Handlers for channel messages.
     * @param {Channel} channel A channel to send back response.
     * @param {ApiCallMessage} msg Received message.
     * @private
     */
    onAPICall_(channel, msg) {
      const call = msg.call;
      console.info('SamlHandler.onAPICall_: call.method = ' + call.method);
      if (call.method === 'initialize') {
        if (!Number.isInteger(call.requestedVersion) ||
            call.requestedVersion < MIN_API_VERSION_VERSION) {
          this.sendInitializationFailure_(channel);
          this.recordInAPIErrorHistogram_(
              SamlHandler.ApiErrorType.UNSUPPORTED_KEY);
          return;
        }

        this.apiVersion_ =
            Math.min(call.requestedVersion, MAX_API_VERSION_VERSION);
        this.apiInitialized_ = true;
        console.info('SamlHandler.onAPICall_ is initialized successfully');
        this.sendInitializationSuccess_(channel);
        return;
      }

      if (call.method === 'add') {
        if (API_KEY_TYPES.indexOf(call.keyType) === -1) {
          console.warn('SamlHandler.onAPICall_: unsupported key type');
          this.recordInAPIErrorHistogram_(
              SamlHandler.ApiErrorType.UNSUPPORTED_KEY);
          return;
        }
        // Not setting |email_| and |gaiaId_| because this API call will
        // eventually be followed by onCompleteLogin_() which does set it.
        this.apiTokenStore_[call.token] = call;
        this.lastApiPasswordBytes_ = call.passwordBytes;
        console.info('SamlHandler.onAPICall_: password added');
        this.dispatchEvent(new CustomEvent('apiPasswordAdded'));
      } else if (call.method === 'createaccount') {
        if (!this.shouldHandleAccountCreationMessage) {
          console.warn('SamlHandler.onAPICall_: message not supported');
          this.recordInAPIErrorHistogram_(
              SamlHandler.ApiErrorType.UNSUPPORTED_MESSAGE);
          return;
        }
        if (!(call.token in this.apiTokenStore_)) {
          console.warn('SamlHandler.onAPICall_: token mismatch');
          this.recordInAPIErrorHistogram_(
              SamlHandler.ApiErrorType.CREATE_TOKEN_MISMATCH);
          return;
        }
        console.info('SamlHandler.onAPICall_: new account created');
        this.dispatchEvent(new CustomEvent('apiAccountCreated'));
      } else if (call.method === 'confirm') {
        if (!(call.token in this.apiTokenStore_)) {
          console.warn('SamlHandler.onAPICall_: token mismatch');
          this.recordInAPIErrorHistogram_(
              SamlHandler.ApiErrorType.CONFIRM_TOKEN_MISMATCH);
        } else {
          this.confirmToken_ = call.token;
          console.info('SamlHandler.onAPICall_: password confirmed');
          this.dispatchEvent(new CustomEvent('apiPasswordConfirmed'));
        }
      } else {
        console.warn('SamlHandler.onAPICall_: unknown message');
        this.recordInAPIErrorHistogram_(
            SamlHandler.ApiErrorType.UNKNOWN_MESSAGE);
      }
    }

    onUpdatePassword_(channel, msg) {
      if (this.isSamlPage_) {
        this.passwordStore_[msg.id] = msg.password;
      }
    }

    onPageLoaded_(channel, msg) {
      this.dispatchEvent(new CustomEvent(
          'authPageLoaded', {detail: {isSAMLPage: this.isSamlPage_}}));
    }

    onScrollInfo_(channel, msg) {
      const scrollTop = msg.scrollTop;
      const scrollHeight = msg.scrollHeight;
      const clientHeight = this.webview_.clientHeight;

      if (scrollTop === undefined || scrollHeight === undefined) {
        return;
      }

      this.webview_.classList.toggle('can-scroll', clientHeight < scrollHeight);
      this.webview_.classList.toggle('is-scrolled', scrollTop > 0);
      const scrolledToBottom = (scrollTop > 0) /*is-scrolled*/ &&
          (Math.ceil(scrollTop + clientHeight) >= scrollHeight);
      this.webview_.classList.toggle('scrolled-to-bottom', scrolledToBottom);
    }

    onPermissionRequest_(permissionEvent) {
      if (permissionEvent.permission === 'media') {
        // The actual permission check happens in
        // WebUILoginView::RequestMediaAccessPermission().
        this.dispatchEvent(new CustomEvent('videoEnabled'));
        permissionEvent.request.allow();
      }
    }

    onGetSAMLFlag_(channel, msg) {
      return this.isSamlPage_;
    }
  }