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

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

/**
 * @fileoverview Support password change on with SAML provider.
 */

// clang-format off
// <if expr="chromeos_ash">
import {NativeEventTarget as EventTarget} from 'chrome://resources/ash/common/event_target.js';
import {$, appendParam} from 'chrome://resources/ash/common/util.js';
import {assert} from 'chrome://resources/ash/common/assert.js';
// </if>
// <if expr="not chromeos_ash">
import {assert} from 'chrome://resources/js/assert.js';
import {$, appendParam} from 'chrome://resources/js/util.js';

// </if>

import {SamlHandler} from './saml_handler.js';
import {WebviewEventManager} from './webview_event_manager.js';
// clang-format on

/** @const */
export const oktaInjectedScriptName = 'oktaInjected';

/**
 * "SAML password change extension" which helps detect password change
 *  @type {string}
 */
export const extensionId = 'mkmjngkgbjeljoblnahkagdlcdeiiped';

/**
 * The script to inject into Okta user settings page.
 * @type {string}
 */
const oktaInjectedJsFile = 'gaia_auth_host/okta_detect_success_injected.js';

const BLANK_PAGE_URL = 'about:blank';

/**
 * @typedef {{
 *   old_passwords: Array<string>,
 *   new_passwords: Array<string>,
 * }}
 */
export let PasswordChangeEventData;

/**
 * @param {string} extensionId The ID of the extension to send the message to.
 * @param {Object} message The message to send. This message should be a
 *     JSON-ifiable object.
 * @param {function(?)} callback the response callback function
 * @private
 * @see: https://developer.chrome.com/extensions/runtime#method-sendMessage
 */
function sendMessage_(extensionId, message, callback) {
  // Sending message to extension and callback will be used to receive
  // response from extension. This way is used to send one time request :
  // https://developer.chrome.com/extensions/messaging#simple
  chrome.runtime.sendMessage(extensionId, message, callback);
}

/**
 * The different providers of password-change pages that we support, or are
 * working on supporting.
 * Should match the enum in SAML password change extension
 * @enum {number}
 */
export const PasswordChangePageProvider = {
  UNKNOWN: 0,
  ADFS: 1,
  AZURE: 2,
  OKTA: 3,
  PING: 4,
};

/**
 * @param {URL?} url The url of the webpage that is being interacted with.
 * @return {PasswordChangePageProvider?} The provider of the password change
 *         page, as detected based on the URL.
 */
function detectProvider_(url) {
  if (!url) {
    return null;
  }
  if (url.pathname.match(/\/updatepassword\/?$/)) {
    return PasswordChangePageProvider.ADFS;
  }
  if (url.pathname.endsWith('/ChangePassword.aspx')) {
    return PasswordChangePageProvider.AZURE;
  }
  if (url.host.match(/\.okta\.com$/)) {
    return PasswordChangePageProvider.OKTA;
  }
  if (url.pathname.match('/password/chg/') ||
      url.pathname.match('/pwdchange/')) {
    return PasswordChangePageProvider.PING;
  }
  return PasswordChangePageProvider.UNKNOWN;
}

/**
 * @param {string?} str A string that should be a valid URL.
 * @return {URL?} A valid URL object, or null.
 */
function safeParseUrl_(str) {
  try {
    return new URL(/** @type {string} */ (str));
  } catch (error) {
    console.error('Invalid url: ' + str);
    return null;
  }
}

/**
 * @param {URL?} postUrl Where the password change request was POSTed.
 * @param {URL?} redirectUrl Where the response redirected the browser.
 * @return {boolean} True if we detect that a password change was successful.
 */
export function detectPasswordChangeSuccess(postUrl, redirectUrl) {
  if (!postUrl || !redirectUrl) {
    return false;
  }

  // We count it as a success whenever "status=0" is in the query params.
  // This is what we use for ADFS, but for now, we allow it for every IdP, so
  // that an otherwise unsupported IdP can also send it as a success message.
  // TODO(crbug.com/40613129): Consider removing this entirely, or,
  // using a more self-documenting parameter like 'passwordChanged=1'.
  if (redirectUrl.searchParams.get('status') === '0') {
    return true;
  }

  const pageProvider = detectProvider_(postUrl);
  // These heuristics work for the following SAML IdPs:
  if (pageProvider === PasswordChangePageProvider.ADFS) {
    return redirectUrl.searchParams.get('status') === '0';
  }
  if (pageProvider === PasswordChangePageProvider.AZURE) {
    return redirectUrl.searchParams.get('ReturnCode') === '0';
  }
  if (pageProvider === PasswordChangePageProvider.PING) {
    // The returnurl is always preserved until password change succeeds - then
    // it is no longer needed.
    return (!!postUrl.searchParams.get('returnurl') &&
            !redirectUrl.searchParams.get('returnurl')) ||
        redirectUrl.pathname.endsWith('Success');
  }

  // We can't currently detect success for Okta just by inspecting the
  // URL or even response headers. To inspect the response body, we need
  // to inject scripts onto their page (see okta_detect_success_injected.js).

  return false;
}

/**
 * Initializes the authenticator component.
 */
export class PasswordChangeAuthenticator extends EventTarget {
  /**
   * @param {!WebView|string} webview The webview element or its ID to host
   *     IdP web pages.
   */
  constructor(webview) {
    super();

    this.initialFrameUrl_ = null;
    this.webviewEventManager_ = new WebviewEventManager();

    /**
     * @private {WebView|undefined}
     */
    this.webview_ = undefined;
    /**
     * @private {!SamlHandler|undefined}
     */
    this.samlHandler_ = undefined;

    this.bindToWebview_(webview);

    window.addEventListener('focus', this.onFocus_.bind(this), false);
  }

  /**
   * Reinitializes saml handler.
   */
  resetStates() {
    this.samlHandler_.reset();
  }

  /**
   * Resets the webview to the blank page.
   */
  resetWebview() {
    if (this.webview_.src && this.webview_.src !== BLANK_PAGE_URL) {
      this.webview_.src = BLANK_PAGE_URL;
    }
  }

  /**
   * Binds this authenticator to the passed webview.
   * @param {!WebView|string} webview the new webview to be used by this
   *     Authenticator.
   * @private
   */
  bindToWebview_(webview) {
    assert(!this.webview_);
    assert(!this.samlHandler_);

    this.webview_ = typeof webview === 'string' ?
        /** @type {WebView} */ ($(webview)) :
        webview;
    assert(this.webview_);

    this.samlHandler_ = new SamlHandler(
        /** @type {!WebView} */ (this.webview_), true /* startsOnSamlPage */);
    this.webviewEventManager_.addEventListener(
        this.samlHandler_, 'authPageLoaded', this.onAuthPageLoaded_.bind(this));

    // Listen for main-frame redirects to check for success - we can mostly
    // detect success by detecting we POSTed something to the password-change
    // URL, and the response redirected us to a particular success URL.
    this.webviewEventManager_.addWebRequestEventListener(
        this.webview_.request.onBeforeRedirect,
        this.onBeforeRedirect_.bind(this),
        {urls: ['*://*/*'], types: ['main_frame']},
        null, /* extraInfoSpec */
    );

    // Inject a custom script for detecting password change success in Okta.
    this.webview_.addContentScripts([{
      name: oktaInjectedScriptName,
      matches: ['*://*.okta.com/*'],
      js: {files: [oktaInjectedJsFile]},
      all_frames: true,
      run_at: 'document_start',
    }]);

    // Connect to the script running in Okta web pages once it loads.
    this.webviewEventManager_.addWebRequestEventListener(
        this.webview_.request.onCompleted,
        this.onOktaCompleted_.bind(this),
        {urls: ['*://*.okta.com/*'], types: ['main_frame']},
        null, /* extraInfoSpec */
    );

    // Okta-detect-success-inject script signals success by posting a message
    // that says "passwordChangeSuccess", which we listen for:
    this.webviewEventManager_.addEventListener(
        window, 'message', this.onMessageReceived_.bind(this));
  }

  /**
   * Unbinds this Authenticator from the currently bound webview.
   * @private
   */
  unbindFromWebview_() {
    assert(this.webview_);
    assert(this.samlHandler_);

    this.webviewEventManager_.removeAllListeners();

    this.webview_ = undefined;
    this.samlHandler_.unbindFromWebview();
    this.samlHandler_ = undefined;
  }

  /**
   * Re-binds to another webview.
   * @param {!WebView} webview the new webview to be used by this
   *     Authenticator.
   */
  rebindWebview(webview) {
    this.unbindFromWebview_();
    this.bindToWebview_(webview);
  }

  /**
   * Loads the authenticator component with the given parameters.
   * @param {Object} data Parameters for the authorization flow.
   */
  load(data) {
    this.resetStates();
    this.initialFrameUrl_ = this.constructInitialFrameUrl_(data);
    this.samlHandler_.blockInsecureContent = true;
    this.webview_.src = this.initialFrameUrl_;
  }

  constructInitialFrameUrl_(data) {
    let url;
    url = data.passwordChangeUrl;
    if (data.userName) {
      url = appendParam(url, 'username', data.userName);
    }
    return url;
  }

  /**
   * Invoked when the sign-in page takes focus.
   * @param {Object} e The focus event being triggered.
   * @private
   */
  onFocus_(e) {
    this.webview_.focus();
  }

  /**
   * Sends scraped password and resets the state.
   * @param {boolean} isOkta whether the page is Okta page.
   * @private
   */
  onPasswordChangeSuccess_(isOkta) {
    let passwordsOnce;
    let passwordsTwice;
    if (isOkta) {
      passwordsOnce = this.samlHandler_.getPasswordsWithPropertyScrapedTimes(
          1, 'oldPassword');
      const newPasswords =
          this.samlHandler_.getPasswordsWithPropertyScrapedTimes(
              1, 'newPassword');
      const verifyPasswords =
          this.samlHandler_.getPasswordsWithPropertyScrapedTimes(
              1, 'verifyPassword');
      if (newPasswords.length === 1 && verifyPasswords.length === 1 &&
          newPasswords[0] === verifyPasswords[0]) {
        passwordsTwice = Array.from(newPasswords);
      } else {
        passwordsTwice = [];
      }
    } else {
      passwordsOnce = this.samlHandler_.getPasswordsWithPropertyScrapedTimes(
          1, null /*passwordProperty*/);
      passwordsTwice = this.samlHandler_.getPasswordsWithPropertyScrapedTimes(
          2, null /*passwordProperty*/);
    }

    this.dispatchEvent(new CustomEvent('authCompleted', {
      detail: {
        old_passwords: passwordsOnce,
        new_passwords: passwordsTwice,
      },
    }));
    this.resetStates();
  }

  /**
   * Invoked when |samlHandler_| fires 'authPageLoaded' event.
   * @private
   */
  onAuthPageLoaded_(e) {
    this.webview_.focus();
  }

  /**
   * Invoked when a new document loading completes.
   * @param {Object} details The web-request details.
   * @private
   */
  onBeforeRedirect_(details) {
    if (details.method === 'POST') {
      const message = {
        name: 'detectPasswordChangeSuccess',
        url: details.url,
        redirectUrl: details.redirectUrl,
      };
      sendMessage_(extensionId, message, (passwordChangeSuccess) => {
        // SAML change password extension will be used to detect the password
        // change success from url passed.
        // 'passwordChangeSuccess' will be equal to undefined in case
        // extension isn't installed or disabled, In this case normal flow
        // will be used.
        // Otherwise 'passwordChangeSuccess' will indcate whether extension
        // detected password change successfully.
        if (passwordChangeSuccess ||
            (typeof passwordChangeSuccess === 'undefined' &&
             detectPasswordChangeSuccess(
                 safeParseUrl_(details.url),
                 safeParseUrl_(details.redirectUrl)))) {
          this.onPasswordChangeSuccess_(false /* isOkta */);
        }
      });
    }
  }

  /**
   * Invoked when loading completes on an Okta page.
   * @param {Object} details The web-request details.
   * @private
   */
  onOktaCompleted_(details) {
    // Okta_detect_success_injected.js needs to be contacted by the parent,
    // so that it can send messages back to the parent.
    // Using setTimeout gives the page time to finish initializing.
    // TODO: timeout value is chosen empirically, we need a better way
    // to pass this to the injected code.
    setTimeout(() => {
      this.webview_.contentWindow.postMessage('connect', details.url);
    }, 2000);
  }

  /**
   * Invoked when the webview posts a message.
   * @param {Object} event The message event.
   * @private
   */
  onMessageReceived_(event) {
    if (event.data === 'passwordChangeSuccess') {
      const message = {name: 'detectProvider', url: event.origin};
      sendMessage_(extensionId, message, (provider) => {
        // SAML change password extension will be used to detect provider
        // from url passed.
        // 'provider' will be equal to undefined in case
        // extension isn't installed or disabled, In this case normal flow
        // will be used.
        if (provider === PasswordChangePageProvider.OKTA ||
            (typeof provider === 'undefined' &&
             detectProvider_(safeParseUrl_(event.origin)) ===
                 PasswordChangePageProvider.OKTA)) {
          this.onPasswordChangeSuccess_(true /* isOkta */);
        }
      });
    }
  }
}