Commit 3ba68b08 authored by A Olsen's avatar A Olsen Committed by Commit Bot

Azure, Okta success detection for pw change

(Test is currently disabled for ASAN since it cannot find
all the JS libraries are runtime for ASAN builds on MAC.)

Adds Azure and Okta success detection by checking URL and response.
Ping is still TODO.

Bug: 930109
Change-Id: I6f495ccd1371e171e6df2e3a4b9eb9bca5152723
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1826786Reviewed-by: default avatarXiyuan Xia <xiyuan@chromium.org>
Reviewed-by: default avatarRoman Sorokin [CET] <rsorokin@chromium.org>
Commit-Queue: A Olsen <olsen@chromium.org>
Cr-Commit-Position: refs/heads/master@{#706375}
parent 7cc83457
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
# found in the LICENSE file. # found in the LICENSE file.
import("//chrome/common/features.gni") import("//chrome/common/features.gni")
import("//chrome/test/base/js2gtest.gni")
import("//tools/grit/grit_rule.gni") import("//tools/grit/grit_rule.gni")
import("//tools/grit/repack.gni") import("//tools/grit/repack.gni")
...@@ -371,3 +372,35 @@ repack("dev_ui_paks") { ...@@ -371,3 +372,35 @@ repack("dev_ui_paks") {
"//chrome/browser/resources/usb_internals:resources", "//chrome/browser/resources/usb_internals:resources",
] ]
} }
# TODO(https://crbug.com/930109): Figure out why this test fails on MAC ASAN.
if (!is_asan || !is_mac) {
js2gtest("resources_unitjs_tests") {
test_type = "webui"
sources = [
"gaia_auth_host/password_change_authenticator_test.unitjs",
]
# This has to be a gen_include, so it doesn't collide with other js2gtests
gen_include_files = [ "//ui/webui/resources/js/cr.js" ]
# But these have to be extra_js_files, since it uses a native object
# EventTarget, which doesn't work at compile time.
extra_js_files = [
"//ui/webui/resources/js/cr/event_target.js",
"gaia_auth_host/password_change_authenticator.js",
]
defines = [ "HAS_OUT_OF_PROC_TEST_RUNNER" ]
}
source_set("browser_tests") {
testonly = true
deps = [
":resources_unitjs_tests",
]
}
} else {
source_set("browser_tests") {
testonly = true
}
}
// Copyright 2019 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.
/**
* Intercept Ajax responses, detect responses to the password-change endpoint
* that don't contain any errors.
*/
(function() {
function oktaDetectSuccess() {
const PARENT_ORIGIN = 'chrome://password-change';
let messageFromParent;
function onMessageReceived(event) {
if (event.origin == PARENT_ORIGIN) {
messageFromParent = event;
}
}
window.addEventListener('message', onMessageReceived, false);
function checkResponse(responseUrl, responseData) {
if (responseUrl.includes('/internal_login/password') &&
!responseData.match(/"has[A-Za-z]*Errors":true/)) {
console.info('passwordChangeSuccess');
messageFromParent.source.postMessage(
'passwordChangeSuccess', PARENT_ORIGIN);
}
}
const proxied = window.XMLHttpRequest.prototype.send;
window.XMLHttpRequest.prototype.send = function() {
this.addEventListener('load', function() {
checkResponse(this.responseURL, this.response);
});
return proxied.apply(this, arguments);
};
}
/** Run a script in the window context - not isolated as a content-script. */
function runInPageContext(jsFn) {
const script = document.createElement('script');
script.type = 'text/javascript';
script.innerHTML = '(' + jsFn + ')();';
document.head.prepend(script);
}
/** Wait until DOM is loaded, then run oktaDetectSuccess script. */
function initialize() {
if (document.body && document.head) {
console.info('initialize');
runInPageContext(oktaDetectSuccess);
} else {
requestIdleCallback(initialize);
}
}
requestIdleCallback(initialize);
})();
...@@ -12,8 +12,104 @@ ...@@ -12,8 +12,104 @@
cr.define('cr.samlPasswordChange', function() { cr.define('cr.samlPasswordChange', function() {
'use strict'; 'use strict';
/** @const */
const oktaInjectedScriptName = 'oktaInjected';
/**
* The script to inject into Okta user settings page.
* @type {string}
*/
const oktaInjectedJs = String.raw`
// <include src="okta_detect_success_injected.js">
`;
const BLANK_PAGE_URL = 'about:blank'; const BLANK_PAGE_URL = 'about:blank';
/**
* The different providers of password-change pages that we support, or are
* working on supporting.
* @enum {number}
*/
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/')) {
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(str);
} catch (error) {
console.error('Invalid url: ' + str);
return null;
}
}
/**
* @param {Object} details The web-request details.
* @return {boolean} True if we detect that a password change was successful.
*/
function detectPasswordChangeSuccess(details) {
const url = safeParseUrl_(details.url);
if (!url) {
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(https://crbug.com/930109): Consider removing this entirely, or,
// using a more self-documenting parameter like 'passwordChanged=1'.
if (url.searchParams.get('status') == '0') {
return true;
}
const pageProvider = detectProvider_(url);
// These heuristics work for the following SAML IdPs:
if (pageProvider == PasswordChangePageProvider.ADFS) {
return url.searchParams.get('status') == '0';
}
if (pageProvider == PasswordChangePageProvider.AZURE) {
return url.searchParams.get('ReturnCode') == '0';
}
// We can't currently detect success for Okta or Ping 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. * Initializes the authenticator component.
*/ */
...@@ -67,8 +163,27 @@ cr.define('cr.samlPasswordChange', function() { ...@@ -67,8 +163,27 @@ cr.define('cr.samlPasswordChange', function() {
this.samlHandler_, 'authPageLoaded', this.samlHandler_, 'authPageLoaded',
this.onAuthPageLoaded_.bind(this)); this.onAuthPageLoaded_.bind(this));
// Listen for completed main-frame requests to check for password-change
// success.
this.webviewEventManager_.addWebRequestEventListener(
this.webview_.request.onCompleted,
this.onCompleted_.bind(this),
{urls: ['*://*/*'], types: ['main_frame']},
);
// Inject a custom script for detecting password change success in Okta.
this.webview_.addContentScripts([{
name: oktaInjectedScriptName,
matches: ['*://*.okta.com/*'],
js: {code: oktaInjectedJs},
all_frames: true,
run_at: 'document_start'
}]);
// Okta-detect-success-inject script signals success by posting a message
// that says "passwordChangeSuccess", which we listen for:
this.webviewEventManager_.addEventListener( this.webviewEventManager_.addEventListener(
this.webview_, 'contentload', this.onContentLoad_.bind(this)); window, 'message', this.onMessageReceived_.bind(this));
} }
/** /**
...@@ -129,7 +244,7 @@ cr.define('cr.samlPasswordChange', function() { ...@@ -129,7 +244,7 @@ cr.define('cr.samlPasswordChange', function() {
* Sends scraped password and resets the state. * Sends scraped password and resets the state.
* @private * @private
*/ */
completeAuth_() { onPasswordChangeSuccess_() {
const passwordsOnce = this.samlHandler_.getPasswordsScrapedTimes(1); const passwordsOnce = this.samlHandler_.getPasswordsScrapedTimes(1);
const passwordsTwice = this.samlHandler_.getPasswordsScrapedTimes(2); const passwordsTwice = this.samlHandler_.getPasswordsScrapedTimes(2);
...@@ -151,17 +266,43 @@ cr.define('cr.samlPasswordChange', function() { ...@@ -151,17 +266,43 @@ cr.define('cr.samlPasswordChange', function() {
} }
/** /**
* Invoked when a new document is loaded. * Invoked when a new document loading completes.
* @param {Object} details The web-request details.
* @private * @private
*/ */
onContentLoad_(e) { onCompleted_(details) {
const currentUrl = this.webview_.src; if (detectPasswordChangeSuccess(details)) {
// TODO(rsorokin): Implement more robust check. this.onPasswordChangeSuccess_();
if (currentUrl.lastIndexOf('status=0') != -1) { }
this.completeAuth_();
// Okta_detect_success_injected.js needs to be contacted by the parent,
// so that it can send messages back to the parent.
const pageProvider = detectProvider_(safeParseUrl_(details.url));
if (pageProvider == PasswordChangePageProvider.OKTA) {
// Using setTimeout gives the page time to finish initializing.
setTimeout(() => {
this.webview_.contentWindow.postMessage('connect', details.url);
}, 1000);
}
}
/**
* Invoked when the webview posts a message.
* @param {Object} event The message event.
* @private
*/
onMessageReceived_(event) {
if (event.data == 'passwordChangeSuccess') {
const pageProvider = detectProvider_(safeParseUrl_(event.origin));
if (pageProvider == PasswordChangePageProvider.OKTA) {
this.onPasswordChangeSuccess_();
}
} }
} }
} }
return {Authenticator: Authenticator}; return {
Authenticator: Authenticator,
detectPasswordChangeSuccess: detectPasswordChangeSuccess,
};
}); });
// Copyright 2019 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.
GEN_INCLUDE(['//ui/webui/resources/js/cr.js']);
const EXAMPLE_ADFS_ENDPOINT =
'https://example.com/adfs/portal/updatepassword/';
const EXAMPLE_AZURE_ENDPOINT =
'https://example.windowsazure.com/ChangePassword.aspx';
const EXAMPLE_OKTA_ENDPOINT =
'https://example.okta.com/user/profile/internal_login/password';
const EXAMPLE_PING_ENDPOINT =
'https://login.pingone.com/idp/directory/a/12345/password/chg/67890';
PasswordChangeAuthenticatorUnitTest = class extends testing.Test {
get browsePreload() {
return DUMMY_URL;
}
// No need to run these checks - see comment in SamlPasswordAttributesTest.
get runAccessibilityChecks() {
return false;
}
get extraLibraries() {
return [
'//ui/webui/resources/js/cr/event_target.js',
'password_change_authenticator.js',
];
}
assertSuccess(details) {
assertTrue(this.detectSuccess(details));
}
assertNotSuccess(details, responseData) {
assertFalse(this.detectSuccess(details));
}
detectSuccess(details) {
if (typeof details == 'string') {
details = {'url': details};
}
return cr.samlPasswordChange.detectPasswordChangeSuccess(details);
}
}
TEST_F('PasswordChangeAuthenticatorUnitTest', 'DetectAdfsSuccess', function() {
const endpointUrl = EXAMPLE_ADFS_ENDPOINT;
this.assertNotSuccess(endpointUrl);
this.assertNotSuccess(endpointUrl + '?status=1');
this.assertSuccess(endpointUrl + '?status=0');
// We allow "status=0" to count as success everywhere right now, but this
// should be narrowed down to ADFS - see the TODO in the code.
this.assertSuccess(EXAMPLE_AZURE_ENDPOINT + '?status=0');
});
TEST_F('PasswordChangeAuthenticatorUnitTest', 'DetectAzureSuccess', function() {
const endpointUrl = EXAMPLE_AZURE_ENDPOINT;
const extraParam = 'BrandContextID=O123';
this.assertNotSuccess(endpointUrl);
this.assertNotSuccess(endpointUrl + '?' + extraParam);
this.assertNotSuccess(endpointUrl + '?ReturnCode=1&' + extraParam);
this.assertNotSuccess(endpointUrl + '?' + extraParam + '&ReturnCode=1');
this.assertNotSuccess(EXAMPLE_PING_ENDPOINT + '?ReturnCode=0');
this.assertSuccess(endpointUrl + '?ReturnCode=0&' + extraParam);
this.assertSuccess(endpointUrl + '?' + extraParam + '&ReturnCode=0');
});
\ No newline at end of file
...@@ -667,6 +667,7 @@ if (!is_android) { ...@@ -667,6 +667,7 @@ if (!is_android) {
"//chrome/browser/devtools:test_support", "//chrome/browser/devtools:test_support",
"//chrome/browser/notifications/scheduler/test:test_support", "//chrome/browser/notifications/scheduler/test:test_support",
"//chrome/browser/profiling_host:profiling_browsertests", "//chrome/browser/profiling_host:profiling_browsertests",
"//chrome/browser/resources:browser_tests",
"//chrome/browser/web_applications:browser_tests", "//chrome/browser/web_applications:browser_tests",
"//chrome/browser/web_applications/extensions:browser_tests", "//chrome/browser/web_applications/extensions:browser_tests",
"//chrome/renderer", "//chrome/renderer",
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment