Bug 1190689 implement onAuthRequired for WebRequest, r=kmag
authorShane Caraveo <scaraveo@mozilla.com>
Thu, 26 Jan 2017 13:40:36 -0800
changeset 380365 8b0fc745a65e390d8ad2989a1abd747c43f232af
parent 380364 b0aad844ced1da050be3092b62cac7b981d307cd
child 380366 831c20c3086bf27da1ea8170090df58746caa152
push id1468
push userasasaki@mozilla.com
push dateMon, 05 Jun 2017 19:31:07 +0000
treeherdermozilla-release@0641fc6ee9d1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskmag
bugs1190689
milestone54.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1190689 implement onAuthRequired for WebRequest, r=kmag MozReview-Commit-ID: D6ydPIMNzDI
browser/components/extensions/test/browser/browser_ext_webRequest.js
toolkit/components/extensions/ext-webRequest.js
toolkit/components/extensions/schemas/web_request.json
toolkit/components/extensions/test/mochitest/head_webrequest.js
toolkit/components/extensions/test/mochitest/mochitest-common.ini
toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html
toolkit/modules/addons/WebRequest.jsm
--- a/browser/components/extensions/test/browser/browser_ext_webRequest.js
+++ b/browser/components/extensions/test/browser/browser_ext_webRequest.js
@@ -50,21 +50,16 @@ let headers = {
     },
     remove: [
       "connection",
     ],
   },
 };
 
 add_task(function* setup() {
-  // SelfSupport has a tendency to fire when running this test alone, without
-  // a good way to turn it off we just set the url to ""
-  yield SpecialPowers.pushPrefEnv({
-    set: [["browser.selfsupport.url", ""]],
-  });
   extension = makeExtension();
   yield extension.startup();
 });
 
 add_task(function* test_newWindow() {
   let expect = {
     "file_dummy.html": {
       type: "main_frame",
--- a/toolkit/components/extensions/ext-webRequest.js
+++ b/toolkit/components/extensions/ext-webRequest.js
@@ -53,17 +53,17 @@ function WebRequestEventManager(context,
         data2.fromCache = !!data.fromCache;
       }
 
       if ("ip" in data) {
         data2.ip = data.ip;
       }
 
       let optional = ["requestHeaders", "responseHeaders", "statusCode", "statusLine", "error", "redirectUrl",
-                      "requestBody"];
+                      "requestBody", "scheme", "realm", "isProxy", "challenger"];
       for (let opt of optional) {
         if (opt in data) {
           data2[opt] = data[opt];
         }
       }
 
       return fire.sync(data2);
     };
@@ -105,16 +105,17 @@ WebRequestEventManager.prototype = Objec
 
 extensions.registerSchemaAPI("webRequest", "addon_parent", context => {
   return {
     webRequest: {
       onBeforeRequest: new WebRequestEventManager(context, "onBeforeRequest").api(),
       onBeforeSendHeaders: new WebRequestEventManager(context, "onBeforeSendHeaders").api(),
       onSendHeaders: new WebRequestEventManager(context, "onSendHeaders").api(),
       onHeadersReceived: new WebRequestEventManager(context, "onHeadersReceived").api(),
+      onAuthRequired: new WebRequestEventManager(context, "onAuthRequired").api(),
       onBeforeRedirect: new WebRequestEventManager(context, "onBeforeRedirect").api(),
       onResponseStarted: new WebRequestEventManager(context, "onResponseStarted").api(),
       onErrorOccurred: new WebRequestEventManager(context, "onErrorOccurred").api(),
       onCompleted: new WebRequestEventManager(context, "onCompleted").api(),
       handlerBehaviorChanged: function() {
         // TODO: Flush all caches.
       },
     },
--- a/toolkit/components/extensions/schemas/web_request.json
+++ b/toolkit/components/extensions/schemas/web_request.json
@@ -392,17 +392,16 @@
         "returns": {
           "$ref": "BlockingResponse",
           "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.",
           "optional": true
         }
       },
       {
         "name": "onAuthRequired",
-        "unsupported": true,
         "type": "function",
         "description": "Fired when an authentication failure is received. The listener has three options: it can provide authentication credentials, it can cancel the request and display the error page, or it can take no action on the challenge. If bad user credentials are provided, this may be called multiple times for the same request.",
         "parameters": [
           {
             "type": "object",
             "name": "details",
             "properties": {
               "requestId": {"type": "string", "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request."},
--- a/toolkit/components/extensions/test/mochitest/head_webrequest.js
+++ b/toolkit/components/extensions/test/mochitest/head_webrequest.js
@@ -1,25 +1,35 @@
 "use strict";
 
+// SelfSupport has a tendency to fire when running a test alone (it would
+// fire in some earlier test otherwise), without a good way to turn it off
+// we just set the url to "".
+SpecialPowers.pushPrefEnv({
+  set: [["browser.selfsupport.url", ""]],
+});
+
 let commonEvents = {
   "onBeforeRequest":     [{urls: ["<all_urls>"]}, ["blocking"]],
   "onBeforeSendHeaders": [{urls: ["<all_urls>"]}, ["blocking", "requestHeaders"]],
   "onSendHeaders":       [{urls: ["<all_urls>"]}, ["requestHeaders"]],
   "onBeforeRedirect":    [{urls: ["<all_urls>"]}],
   "onHeadersReceived":   [{urls: ["<all_urls>"]}, ["blocking", "responseHeaders"]],
+  // Auth tests will need to set their own events object
+  // "onAuthRequired":      [{urls: ["<all_urls>"]}, ["blocking", "responseHeaders"]],
   "onResponseStarted":   [{urls: ["<all_urls>"]}],
   "onCompleted":         [{urls: ["<all_urls>"]}, ["responseHeaders"]],
   "onErrorOccurred":     [{urls: ["<all_urls>"]}],
 };
 
 function background(events) {
   let expect;
   let ignore;
   let defaultOrigin;
+  let watchAuth = Object.keys(events).includes("onAuthRequired");
 
   browser.test.onMessage.addListener((msg, expected) => {
     if (msg !== "set-expected") {
       return;
     }
     expect = expected.expect;
     defaultOrigin = expected.origin;
     ignore = expected.ignore;
@@ -147,16 +157,86 @@ function background(events) {
     }
 
     for (let name of remove) {
       let found = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
       browser.test.assertFalse(!!found, `deleted header ${name} still found in ${phase}Headers`);
     }
   }
 
+  let listeners = {
+    onBeforeRequest(expected, details, result) {
+      // Save some values to test request consistency in later events.
+      browser.test.assertTrue(details.tabId !== undefined, `tabId ${details.tabId}`);
+      browser.test.assertTrue(details.requestId !== undefined, `requestId ${details.requestId}`);
+      // Validate requestId if it's already set, this happens with redirects.
+      if (expected.test.requestId !== undefined) {
+        browser.test.assertEq("string", typeof expected.test.requestId, `requestid ${expected.test.requestId} is string`);
+        browser.test.assertEq("string", typeof details.requestId, `requestid ${details.requestId} is string`);
+        browser.test.assertEq("number", typeof parseInt(details.requestId, 10), "parsed requestid is number");
+        browser.test.assertEq(expected.test.requestId, details.requestId, "redirects will keep the same requestId");
+      } else {
+        // Save any values we want to validate in later events.
+        expected.test.requestId = details.requestId;
+        expected.test.tabId = details.tabId;
+      }
+      // Tests we don't need to do every event.
+      browser.test.assertTrue(details.type.toUpperCase() in browser.webRequest.ResourceType, `valid resource type ${details.type}`);
+      if (details.type == "main_frame") {
+        browser.test.assertEq(0, details.frameId, "frameId is zero when type is main_frame bug 1329299");
+      }
+    },
+    onBeforeSendHeaders(expected, details, result) {
+      if (expected.headers && expected.headers.request) {
+        result.requestHeaders = processHeaders("request", expected, details);
+      }
+      if (expected.redirect) {
+        browser.test.log(`${name} redirect request`);
+        result.redirectUrl = details.url.replace(expected.test.filename, expected.redirect);
+      }
+    },
+    onBeforeRedirect() {},
+    onSendHeaders(expected, details, result) {
+      if (expected.headers && expected.headers.request) {
+        checkHeaders("request", expected, details);
+      }
+    },
+    onResponseStarted() {},
+    onHeadersReceived(expected, details, result) {
+      let expectedStatus = expected.status || 200;
+      // If authentication is being requested we don't fail on the status code.
+      if (watchAuth && [401, 407].includes(details.statusCode)) {
+        expectedStatus = details.statusCode;
+      }
+      browser.test.assertEq(expectedStatus, details.statusCode,
+                            `expected HTTP status received for ${details.url} ${details.statusLine}`);
+      if (expected.headers && expected.headers.response) {
+        result.responseHeaders = processHeaders("response", expected, details);
+      }
+    },
+    onAuthRequired(expected, details, result) {
+      result.authCredentials = expected.authInfo;
+    },
+    onCompleted(expected, details, result) {
+      // If we have already completed a GET request for this url,
+      // and it was found, we expect for the response to come fromCache.
+      // expected.cached may be undefined, force boolean.
+      let expectCached = !!expected.cached && details.method === "GET" && details.statusCode != 404;
+      browser.test.assertEq(expectCached, details.fromCache, "fromCache is correct");
+      // We can only tell IPs for non-cached HTTP requests.
+      if (!details.fromCache && /^https?:/.test(details.url)) {
+        browser.test.assertEq("127.0.0.1", details.ip, `correct ip for ${details.url}`);
+      }
+      if (expected.headers && expected.headers.response) {
+        checkHeaders("response", expected, details);
+      }
+    },
+    onErrorOccurred() {},
+  };
+
   function getListener(name) {
     return details => {
       let result = {};
       browser.test.log(`${name} ${details.requestId} ${details.url}`);
       let expected = getExpected(details);
       if (!expected) {
         return result;
       }
@@ -165,76 +245,30 @@ function background(events) {
         expected.events.shift();
       } else {
         // e10s vs. non-e10s errors can end with either onCompleted or onErrorOccurred
         expectedEvent = expected.optional_events.includes(name);
       }
       browser.test.assertTrue(expectedEvent, `received ${name}`);
       browser.test.assertEq(expected.type, details.type, "resource type is correct");
       browser.test.assertEq(expected.origin || defaultOrigin, details.originUrl, "origin is correct");
+      // ignore origin test for generated background page
+      if (!details.originUrl || !details.originUrl.endsWith("_generated_background_page.html")) {
+        browser.test.assertEq(expected.origin || defaultOrigin, details.originUrl, "origin is correct");
+      }
 
-      if (name == "onBeforeRequest") {
-        // Save some values to test request consistency in later events.
-        browser.test.assertTrue(details.tabId !== undefined, `tabId ${details.tabId}`);
-        browser.test.assertTrue(details.requestId !== undefined, `requestId ${details.requestId}`);
-        // Validate requestId if it's already set, this happens with redirects.
-        if (expected.test.requestId !== undefined) {
-          browser.test.assertEq("string", typeof expected.test.requestId, `requestid ${expected.test.requestId} is string`);
-          browser.test.assertEq("string", typeof details.requestId, `requestid ${details.requestId} is string`);
-          browser.test.assertEq("number", typeof parseInt(details.requestId, 10), "parsed requestid is number");
-          browser.test.assertEq(expected.test.requestId, details.requestId, "redirects will keep the same requestId");
-        } else {
-          // Save any values we want to validate in later events.
-          expected.test.requestId = details.requestId;
-          expected.test.tabId = details.tabId;
-        }
-        // Tests we don't need to do every event.
-        browser.test.assertTrue(details.type.toUpperCase() in browser.webRequest.ResourceType, `valid resource type ${details.type}`);
-        if (details.type == "main_frame") {
-          browser.test.assertEq(0, details.frameId, "frameId is zero when type is main_frame bug 1329299");
-        }
-      } else {
+      if (name != "onBeforeRequest") {
         // On events after onBeforeRequest, check the previous values.
         browser.test.assertEq(expected.test.requestId, details.requestId, "correct requestId");
         browser.test.assertEq(expected.test.tabId, details.tabId, "correct tabId");
       }
-      if (name == "onBeforeSendHeaders") {
-        if (expected.headers && expected.headers.request) {
-          result.requestHeaders = processHeaders("request", expected, details);
-        }
-        if (expected.redirect) {
-          browser.test.log(`${name} redirect request`);
-          result.redirectUrl = details.url.replace(expected.test.filename, expected.redirect);
-        }
-      }
-      if (name == "onSendHeaders") {
-        if (expected.headers && expected.headers.request) {
-          checkHeaders("request", expected, details);
-        }
-      }
-      if (name == "onHeadersReceived") {
-        browser.test.assertEq(expected.status || 200, details.statusCode,
-                              `expected HTTP status received for ${details.url}`);
-        if (expected.headers && expected.headers.response) {
-          result.responseHeaders = processHeaders("response", expected, details);
-        }
-      }
-      if (name == "onCompleted") {
-        // If we have already completed a GET request for this url,
-        // and it was found, we expect for the response to come fromCache.
-        // expected.cached may be undefined, force boolean.
-        let expectCached = !!expected.cached && details.method === "GET" && details.statusCode != 404;
-        browser.test.assertEq(expectCached, details.fromCache, "fromCache is correct");
-        // We can only tell IPs for non-cached HTTP requests.
-        if (!details.fromCache && /^https?:/.test(details.url)) {
-          browser.test.assertEq("127.0.0.1", details.ip, `correct ip for ${details.url}`);
-        }
-        if (expected.headers && expected.headers.response) {
-          checkHeaders("response", expected, details);
-        }
+      try {
+        listeners[name](expected, details, result);
+      } catch (e) {
+        browser.test.fail(`unexpected webrequest failure ${name} ${e}`);
       }
 
       if (expected.cancel && expected.cancel == name) {
         browser.test.log(`${name} cancel request`);
         browser.test.sendMessage("cancelled");
         result.cancel = true;
       }
       // If we've used up all the events for this test, resolve the promise.
--- a/toolkit/components/extensions/test/mochitest/mochitest-common.ini
+++ b/toolkit/components/extensions/test/mochitest/mochitest-common.ini
@@ -33,16 +33,17 @@ support-files =
   file_sample.html
   redirection.sjs
   file_privilege_escalation.html
   file_ext_test_api_injection.js
   file_permission_xhr.html
   file_teardown_test.js
   return_headers.sjs
   webrequest_worker.js
+  !/toolkit/components/passwordmgr/test/authenticate.sjs
 
 [test_clipboard.html]
 # skip-if = # disabled test case with_permission_allow_copy, see inline comment.
 [test_ext_inIncognitoContext_window.html]
 skip-if = os == 'android' # Android does not currently support windows.
 [test_ext_geturl.html]
 [test_ext_background_canvas.html]
 [test_ext_content_security_policy.html]
@@ -93,16 +94,18 @@ skip-if = os == 'android' # Bug 1258975 
 [test_ext_tab_teardown.html]
 skip-if = (os == 'android') # Android does not support tabs API. Bug 1260250
 [test_ext_unload_frame.html]
 [test_ext_i18n.html]
 skip-if = (os == 'android') # Bug 1258975 on android.
 [test_ext_listener_proxies.html]
 [test_ext_web_accessible_resources.html]
 skip-if = (os == 'android') # Bug 1258975 on android.
+[test_ext_webrequest_auth.html]
+skip-if = os == 'android' # webrequest api unsupported (bug 1258975).
 [test_ext_webrequest_background_events.html]
 skip-if = os == 'android' # webrequest api unsupported (bug 1258975).
 [test_ext_webrequest_basic.html]
 skip-if = os == 'android' # webrequest api unsupported (bug 1258975).
 [test_ext_webrequest_filter.html]
 skip-if = os == 'android' # webrequest api unsupported (bug 1258975).
 [test_ext_webrequest_suspend.html]
 skip-if = os == 'android' # webrequest api unsupported (bug 1258975).
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html
@@ -0,0 +1,435 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+  <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head_webrequest.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+let Assert = {
+  rejects(promise, msg) {
+    return promise.then(() => {
+      ok(false, msg);
+    }, () => {
+      ok(true, msg);
+    });
+  },
+};
+
+let baseUrl = "http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/authenticate.sjs";
+function testXHR(url) {
+  return new Promise((resolve, reject) => {
+    let xhr = new XMLHttpRequest();
+    xhr.open("GET", url);
+    xhr.onload = resolve;
+    xhr.onabort = reject;
+    xhr.onerror = reject;
+    xhr.send();
+  });
+}
+
+function getAuthHandler(result, blocking = true) {
+  function background(result) {
+    browser.webRequest.onAuthRequired.addListener((details) => {
+      browser.test.succeed(`authHandler.onAuthRequired called with ${details.requestId} ${details.url} result ${JSON.stringify(result)}`);
+      browser.test.sendMessage("onAuthRequired");
+      return result;
+    }, {urls: ["*://mochi.test/*"]}, ["blocking"]);
+    browser.webRequest.onCompleted.addListener((details) => {
+      browser.test.succeed(`authHandler.onCompleted called with ${details.requestId} ${details.url}`);
+      browser.test.sendMessage("onCompleted");
+    }, {urls: ["*://mochi.test/*"]});
+    browser.webRequest.onErrorOccurred.addListener((details) => {
+      browser.test.succeed(`authHandler.onErrorOccurred called with ${details.requestId} ${details.url}`);
+      browser.test.sendMessage("onErrorOccurred");
+    }, {urls: ["*://mochi.test/*"]});
+  }
+
+  let permissions = [
+    "webRequest",
+    "*://mochi.test/*",
+  ];
+  if (blocking) {
+    permissions.push("webRequestBlocking");
+  }
+  return ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions,
+    },
+    background: `(${background})(${JSON.stringify(result)})`,
+  });
+}
+
+add_task(function* test_webRequest_auth() {
+  // Make use of head_webrequest to ensure event sequence.
+  let events = {
+    "onBeforeRequest":     [{urls: ["*://mochi.test/*"]}, ["blocking"]],
+    "onBeforeSendHeaders": [{urls: ["*://mochi.test/*"]}, ["blocking", "requestHeaders"]],
+    "onSendHeaders":       [{urls: ["*://mochi.test/*"]}, ["requestHeaders"]],
+    "onBeforeRedirect":    [{urls: ["*://mochi.test/*"]}],
+    "onHeadersReceived":   [{urls: ["*://mochi.test/*"]}, ["blocking", "responseHeaders"]],
+    "onAuthRequired":      [{urls: ["*://mochi.test/*"]}, ["blocking", "responseHeaders"]],
+    "onResponseStarted":   [{urls: ["*://mochi.test/*"]}],
+    "onCompleted":         [{urls: ["*://mochi.test/*"]}, ["responseHeaders"]],
+    "onErrorOccurred":     [{urls: ["*://mochi.test/*"]}],
+  };
+
+  let extension = makeExtension(events);
+  yield extension.startup();
+  let authInfo = {
+    username: "testuser",
+    password: "testpass",
+  };
+  let expect = {
+    "authenticate.sjs": {
+      type: "xmlhttprequest",
+      // we expect these additional events after onAuthRequired
+      optional_events: ["onBeforeRequest", "onHeadersReceived"],
+      authInfo,
+    },
+  };
+  // expecting origin == undefined
+  extension.sendMessage("set-expected", {expect, origin: location.href});
+  yield extension.awaitMessage("continue");
+
+  yield testXHR(`${baseUrl}?realm=webRequest_auth&user=${authInfo.username}&pass=${authInfo.password}`);
+
+  yield extension.awaitMessage("done");
+  yield extension.unload();
+});
+
+// This test is the same as above, however we shouldn't receive onAuthRequired
+// since those credentials are now cached (thus optional_events is not set).
+add_task(function* test_webRequest_cached_credentials() {
+  // Make use of head_webrequest to ensure event sequence.
+  let events = {
+    "onBeforeRequest":     [{urls: ["*://mochi.test/*"]}, ["blocking"]],
+    "onBeforeSendHeaders": [{urls: ["*://mochi.test/*"]}, ["blocking", "requestHeaders"]],
+    "onSendHeaders":       [{urls: ["*://mochi.test/*"]}, ["requestHeaders"]],
+    "onBeforeRedirect":    [{urls: ["*://mochi.test/*"]}],
+    "onHeadersReceived":   [{urls: ["*://mochi.test/*"]}, ["blocking", "responseHeaders"]],
+    "onAuthRequired":      [{urls: ["*://mochi.test/*"]}, ["blocking", "responseHeaders"]],
+    "onResponseStarted":   [{urls: ["*://mochi.test/*"]}],
+    "onCompleted":         [{urls: ["*://mochi.test/*"]}, ["responseHeaders"]],
+    "onErrorOccurred":     [{urls: ["*://mochi.test/*"]}],
+  };
+
+  let extension = makeExtension(events);
+  yield extension.startup();
+  let authInfo = {
+    username: "testuser",
+    password: "testpass",
+  };
+  let expect = {
+    "authenticate.sjs": {
+      type: "xmlhttprequest",
+      events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onResponseStarted", "onCompleted"],
+    },
+  };
+  // expecting origin == undefined
+  extension.sendMessage("set-expected", {expect, origin: location.href});
+  yield extension.awaitMessage("continue");
+
+  yield testXHR(`${baseUrl}?realm=webRequest_auth&user=${authInfo.username}&pass=${authInfo.password}`);
+
+  yield extension.awaitMessage("done");
+  yield extension.unload();
+});
+
+add_task(function* test_webRequest_cached_credentials2() {
+  let authCredentials = {
+    username: "testuser",
+    password: "testpass",
+  };
+  let ex1 = getAuthHandler();
+  yield ex1.startup();
+
+  yield testXHR(`${baseUrl}?realm=webRequest_auth&user=${authCredentials.username}&pass=${authCredentials.password}`);
+
+  yield ex1.awaitMessage("onCompleted");
+  yield ex1.unload();
+});
+
+add_task(function* test_webRequest_window() {
+  let authCredentials = {
+    username: "testuser",
+    password: "testpass",
+  };
+  let ex1 = getAuthHandler();
+  yield ex1.startup();
+
+  let win = window.open(`${baseUrl}?realm=test_webRequest_window&user=${authCredentials.username}&pass=${authCredentials.password}`);
+
+  yield ex1.awaitMessage("onCompleted");
+  yield ex1.unload();
+  win.close();
+});
+
+add_task(function* test_webRequest_auth_cancelled() {
+  let authCredentials = {
+    username: "testuser_canceled",
+    password: "testpass_canceled",
+  };
+  let ex1 = getAuthHandler({authCredentials});
+  yield ex1.startup();
+  let ex2 = getAuthHandler({cancel: true});
+  yield ex2.startup();
+
+  yield Assert.rejects(testXHR(`${baseUrl}?realm=test_webRequest_auth_cancelled&user=${authCredentials.username}&pass=${authCredentials.password}`), "caught rejected xhr");
+
+  yield Promise.all([
+    ex1.awaitMessage("onAuthRequired"),
+    ex2.awaitMessage("onAuthRequired"),
+    ex1.awaitMessage("onErrorOccurred"),
+    ex2.awaitMessage("onErrorOccurred"),
+  ]);
+  yield ex1.unload();
+  yield ex2.unload();
+});
+
+add_task(function* test_webRequest_auth_nonblocking() {
+  // The first listener handles the auth request, the second listener
+  // is a non-blocking listener and cannot respond but will get the call.
+  let authCredentials = {
+    username: "foobar",
+    password: "testpass",
+  };
+  let handlingExt = getAuthHandler({authCredentials});
+  yield handlingExt.startup();
+  let extension = getAuthHandler({}, false);
+  yield extension.startup();
+
+  yield testXHR(`${baseUrl}?realm=webRequest_auth_nonblocking&user=${authCredentials.username}&pass=${authCredentials.password}`);
+
+  yield Promise.all([
+    extension.awaitMessage("onAuthRequired"),
+    extension.awaitMessage("onCompleted"),
+    handlingExt.awaitMessage("onAuthRequired"),
+    handlingExt.awaitMessage("onCompleted"),
+  ]);
+  yield extension.unload();
+  yield handlingExt.unload();
+});
+
+
+add_task(function* test_webRequest_auth_blocking_noreturn() {
+  // The first listener is blocking but doesn't return anything.  The second
+  // listener cancels the request.
+  let ext = getAuthHandler();
+  yield ext.startup();
+  let canceler = getAuthHandler({cancel: true});
+  yield canceler.startup();
+
+  yield Assert.rejects(testXHR(`${baseUrl}?realm=auth_blocking_noreturn&user=auth_blocking_noreturn&pass=auth_blocking_noreturn`), "caught rejected xhr");
+
+  yield Promise.all([
+    ext.awaitMessage("onAuthRequired"),
+    ext.awaitMessage("onErrorOccurred"),
+    canceler.awaitMessage("onAuthRequired"),
+    canceler.awaitMessage("onErrorOccurred"),
+  ]);
+  yield ext.unload();
+  yield canceler.unload();
+});
+
+add_task(function* test_webRequest_auth_nonblocking_forwardAuthProvider() {
+  // The chrome script sets up a default auth handler on the channel, the
+  // extension does not return anything in the authRequred call.  We should
+  // get the call in the extension first, then in the chrome code where we
+  // cancel the request to avoid dealing with the prompt dialog here.  The test
+  // is to ensure that WebRequest calls the previous notificationCallbacks
+  // if the authorization is not handled by the onAuthRequired handler.
+
+  let chromeScript = SpecialPowers.loadChromeScript(() => {
+    const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+    Cu.import("resource://gre/modules/Services.jsm");
+    Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+    let observer = channel => {
+      if (!(channel instanceof Ci.nsIHttpChannel && channel.URI.host === "mochi.test")) {
+        return;
+      }
+      Services.obs.removeObserver(observer, "http-on-modify-request");
+      channel.notificationCallbacks = {
+        QueryInterface: XPCOMUtils.generateQI([Ci.nsIInterfaceRequestor,
+                                               Ci.nsIAuthPromptProvider,
+                                               Ci.nsIAuthPrompt2]),
+        getInterface: XPCOMUtils.generateQI([Ci.nsIAuthPromptProvider,
+                                             Ci.nsIAuthPrompt2]),
+        promptAuth(channel, level, authInfo) {
+          throw Cr.NS_ERROR_NO_INTERFACE;
+        },
+        getAuthPrompt(reason, iid) {
+          return this;
+        },
+        asyncPromptAuth(channel, callback, context, level, authInfo) {
+          // We just cancel here, we're only ensuring that non-webrequest
+          // notificationcallbacks get called if webrequest doesn't handle it.
+          Promise.resolve().then(() => {
+            callback.onAuthCancelled(context, false);
+            channel.cancel(Cr.NS_BINDING_ABORTED);
+            sendAsyncMessage("callback-complete");
+          });
+        },
+      };
+    };
+    Services.obs.addObserver(observer, "http-on-modify-request", false);
+    sendAsyncMessage("chrome-ready");
+  });
+  yield chromeScript.promiseOneMessage("chrome-ready");
+  let callbackComplete = chromeScript.promiseOneMessage("callback-complete");
+
+  let handlingExt = getAuthHandler();
+  yield handlingExt.startup();
+
+  yield Assert.rejects(testXHR(`${baseUrl}?realm=auth_nonblocking_forwardAuth&user=auth_nonblocking_forwardAuth&pass=auth_nonblocking_forwardAuth`), "caught rejected xhr");
+
+  yield callbackComplete;
+  yield handlingExt.awaitMessage("onAuthRequired");
+  // We expect onErrorOccurred because the "default" authprompt above cancelled
+  // the auth request to avoid a dialog.
+  yield handlingExt.awaitMessage("onErrorOccurred");
+  yield handlingExt.unload();
+  chromeScript.destroy();
+});
+
+add_task(function* test_webRequest_auth_nonblocking_forwardAuthPrompt2() {
+  // The chrome script sets up a default auth handler on the channel, the
+  // extension does not return anything in the authRequred call.  We should
+  // get the call in the extension first, then in the chrome code where we
+  // cancel the request to avoid dealing with the prompt dialog here.  The test
+  // is to ensure that WebRequest calls the previous notificationCallbacks
+  // if the authorization is not handled by the onAuthRequired handler.
+
+  let chromeScript = SpecialPowers.loadChromeScript(() => {
+    const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+    Cu.import("resource://gre/modules/Services.jsm");
+    Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+    let observer = channel => {
+      if (!(channel instanceof Ci.nsIHttpChannel && channel.URI.host === "mochi.test")) {
+        return;
+      }
+      Services.obs.removeObserver(observer, "http-on-modify-request");
+      channel.notificationCallbacks = {
+        QueryInterface: XPCOMUtils.generateQI([Ci.nsIInterfaceRequestor,
+                                               Ci.nsIAuthPrompt2]),
+        getInterface: XPCOMUtils.generateQI([Ci.nsIAuthPrompt2]),
+        promptAuth(channel, level, authInfo) {
+          throw Cr.NS_ERROR_NO_INTERFACE;
+        },
+        asyncPromptAuth(channel, callback, context, level, authInfo) {
+          // We just cancel here, we're only ensuring that non-webrequest
+          // notificationcallbacks get called if webrequest doesn't handle it.
+          Promise.resolve().then(() => {
+            channel.cancel(Cr.NS_BINDING_ABORTED);
+            sendAsyncMessage("callback-complete");
+          });
+        },
+      };
+    };
+    Services.obs.addObserver(observer, "http-on-modify-request", false);
+    sendAsyncMessage("chrome-ready");
+  });
+  yield chromeScript.promiseOneMessage("chrome-ready");
+  let callbackComplete = chromeScript.promiseOneMessage("callback-complete");
+
+  let handlingExt = getAuthHandler();
+  yield handlingExt.startup();
+
+  yield Assert.rejects(testXHR(`${baseUrl}?realm=auth_nonblocking_forwardAuthPromptProvider&user=auth_nonblocking_forwardAuth&pass=auth_nonblocking_forwardAuth`), "caught rejected xhr");
+
+  yield callbackComplete;
+  yield handlingExt.awaitMessage("onAuthRequired");
+  // We expect onErrorOccurred because the "default" authprompt above cancelled
+  // the auth request to avoid a dialog.
+  yield handlingExt.awaitMessage("onErrorOccurred");
+  yield handlingExt.unload();
+  chromeScript.destroy();
+});
+
+add_task(function* test_webRequest_duelingAuth() {
+  let exNone = getAuthHandler();
+  yield exNone.startup();
+  let authCredentials = {
+    username: "testuser_da1",
+    password: "testpass_da1",
+  };
+  let ex1 = getAuthHandler({authCredentials});
+  yield ex1.startup();
+  let exEmpty = getAuthHandler({});
+  yield exEmpty.startup();
+  let ex2 = getAuthHandler({authCredentials: {
+    username: "testuser_da2",
+    password: "testpass_da2",
+  }});
+  yield ex2.startup();
+
+  // XHR should succeed since the first credentials win, and they are correct.
+  yield testXHR(`${baseUrl}?realm=test_webRequest_duelingAuth&user=${authCredentials.username}&pass=${authCredentials.password}`);
+
+  yield Promise.all([
+    exNone.awaitMessage("onAuthRequired"),
+    exNone.awaitMessage("onCompleted"),
+    exEmpty.awaitMessage("onAuthRequired"),
+    exEmpty.awaitMessage("onCompleted"),
+    ex1.awaitMessage("onAuthRequired"),
+    ex1.awaitMessage("onCompleted"),
+    ex2.awaitMessage("onAuthRequired"),
+    ex2.awaitMessage("onCompleted"),
+  ]);
+  yield Promise.all([
+    exNone.unload(),
+    exEmpty.unload(),
+    ex1.unload(),
+    ex2.unload(),
+  ]);
+});
+
+add_task(function* test_webRequest_auth_proxy() {
+  function background() {
+    let proxyOk = false;
+    browser.webRequest.onAuthRequired.addListener((details) => {
+      browser.test.succeed(`handlingExt onAuthRequired called with ${details.requestId} ${details.url}`);
+      if (details.isProxy) {
+        browser.test.succeed("providing proxy authorization");
+        proxyOk = true;
+        return {authCredentials: {username: "puser", password: "ppass"}};
+      }
+      browser.test.assertTrue(proxyOk, "providing www authorization after proxy auth");
+      browser.test.sendMessage("done");
+      return {authCredentials: {username: "auser", password: "apass"}};
+    }, {urls: ["*://mochi.test/*"]}, ["blocking"]);
+  }
+
+  let handlingExt = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: [
+        "webRequest",
+        "webRequestBlocking",
+        "*://mochi.test/*",
+      ],
+    },
+    background,
+  });
+
+  yield handlingExt.startup();
+
+  yield testXHR(`${baseUrl}?realm=auth_proxy&user=auser&pass=apass&proxy_user=puser&proxy_pass=ppass`);
+
+  yield handlingExt.awaitMessage("done");
+  yield handlingExt.unload();
+});
+</script>
+</head>
+<body>
+<div id="test">Authorization Test</div>
+
+</body>
+</html>
--- a/toolkit/modules/addons/WebRequest.jsm
+++ b/toolkit/modules/addons/WebRequest.jsm
@@ -393,28 +393,161 @@ var ChannelEventSink = {
       throw Cr.NS_ERROR_NO_AGGREGATION;
     }
     return this.QueryInterface(iid);
   },
 };
 
 ChannelEventSink.init();
 
+// nsIAuthPrompt2 implementation for onAuthRequired
+class AuthRequestor {
+  constructor(channel, httpObserver) {
+    this.notificationCallbacks = channel.notificationCallbacks;
+    this.loadGroupCallbacks = channel.loadGroup && channel.loadGroup.notificationCallbacks;
+    this.httpObserver = httpObserver;
+  }
+
+  QueryInterface(iid) {
+    if (iid.equals(Ci.nsISupports) ||
+        iid.equals(Ci.nsIInterfaceRequestor) ||
+        iid.equals(Ci.nsIAuthPromptProvider) ||
+        iid.equals(Ci.nsIAuthPrompt2)) {
+      return this;
+    }
+    try {
+      return this.notificationCallbacks.QueryInterface(iid);
+    } catch (e) {}
+    throw Cr.NS_ERROR_NO_INTERFACE;
+  }
+
+  getInterface(iid) {
+    if (iid.equals(Ci.nsIAuthPromptProvider) || iid.equals(Ci.nsIAuthPrompt2)) {
+      return this;
+    }
+    try {
+      return this.notificationCallbacks.getInterface(iid);
+    } catch (e) {}
+    throw Cr.NS_ERROR_NO_INTERFACE;
+  }
+
+  _getForwardedInterface(iid) {
+    try {
+      return this.notificationCallbacks.getInterface(iid);
+    } catch (e) {
+      return this.loadGroupCallbacks.getInterface(iid);
+    }
+  }
+
+  // nsIAuthPromptProvider getAuthPrompt
+  getAuthPrompt(reason, iid) {
+    // This should never get called without getInterface having been called first.
+    if (iid.equals(Ci.nsIAuthPrompt2)) {
+      return this;
+    }
+    return this._getForwardedInterface(Ci.nsIAuthPromptProvider).getAuthPrompt(reason, iid);
+  }
+
+  // nsIAuthPrompt2 promptAuth
+  promptAuth(channel, level, authInfo) {
+    this._getForwardedInterface(Ci.nsIAuthPrompt2).promptAuth(channel, level, authInfo);
+  }
+
+  _getForwardPrompt(data) {
+    let reason = data.isProxy ? Ci.nsIAuthPromptProvider.PROMPT_PROXY : Ci.nsIAuthPromptProvider.PROMPT_NORMAL;
+    for (let callbacks of [this.notificationCallbacks, this.loadGroupCallbacks]) {
+      try {
+        return callbacks.getInterface(Ci.nsIAuthPromptProvider).getAuthPrompt(reason, Ci.nsIAuthPrompt2);
+      } catch (e) {}
+      try {
+        return callbacks.getInterface(Ci.nsIAuthPrompt2);
+      } catch (e) {}
+    }
+    throw Cr.NS_ERROR_NO_INTERFACE;
+  }
+
+  // nsIAuthPrompt2 asyncPromptAuth
+  asyncPromptAuth(channel, callback, context, level, authInfo) {
+    let uri = channel.URI;
+    let data = {
+      scheme: authInfo.authenticationScheme,
+      realm: authInfo.realm,
+      isProxy: !!(authInfo.flags & authInfo.AUTH_PROXY),
+      challenger: {
+        host: uri.host,
+        port: uri.port,
+      },
+    };
+
+    let channelData = getData(channel);
+    // In the case that no listener provides credentials, we fallback to the
+    // previously set callback class for authentication.
+    channelData.authPromptForward = () => {
+      try {
+        let prompt = this._getForwardPrompt(data);
+        prompt.asyncPromptAuth(channel, callback, context, level, authInfo);
+      } catch (e) {
+        Cu.reportError(`webRequest asyncPromptAuth failure ${e}`);
+        callback.onAuthCancelled(context, false);
+      }
+      channelData.authPromptForward = null;
+      channelData.authPromptCallback = null;
+    };
+    channelData.authPromptCallback = (authCredentials) => {
+      // The API allows for canceling the request, providing credentials or
+      // doing nothing, so we do not provide a way to call onAuthCanceled.
+      // Canceling the request will result in canceling the authentication.
+      if (authCredentials &&
+          typeof authCredentials.username === "string" &&
+          typeof authCredentials.password === "string") {
+        authInfo.username = authCredentials.username;
+        authInfo.password = authCredentials.password;
+        try {
+          callback.onAuthAvailable(context, authInfo);
+        } catch (e) {
+          Cu.reportError(`webRequest onAuthAvailable failure ${e}`);
+        }
+        // At least one addon has responded, so we wont forward to the regular
+        // prompt handlers.
+        channelData.authPromptForward = null;
+        channelData.authPromptCallback = null;
+      }
+    };
+
+    let loadContext = this.httpObserver.getLoadContext(channel);
+    this.httpObserver.runChannelListener(channel, loadContext, "authRequired", data);
+
+    return {
+      QueryInterface: XPCOMUtils.generateQI([Ci.nsICancelable]),
+      cancel() {
+        try {
+          callback.onAuthCancelled(context, false);
+        } catch (e) {
+          Cu.reportError(`webRequest onAuthCancelled failure ${e}`);
+        }
+        channelData.authPromptForward = null;
+        channelData.authPromptCallback = null;
+      },
+    };
+  }
+}
+
 HttpObserverManager = {
   modifyInitialized: false,
   examineInitialized: false,
   redirectInitialized: false,
   activityInitialized: false,
   needTracing: false,
 
   listeners: {
     opening: new Map(),
     modify: new Map(),
     afterModify: new Map(),
     headersReceived: new Map(),
+    authRequired: new Map(),
     onRedirect: new Map(),
     onStart: new Map(),
     onError: new Map(),
     onStop: new Map(),
   },
 
   get activityDistributor() {
     return Cc["@mozilla.org/network/http-activity-distributor;1"].getService(Ci.nsIHttpActivityDistributor);
@@ -429,17 +562,18 @@ HttpObserverManager = {
       this.modifyInitialized = false;
       Services.obs.removeObserver(this, "http-on-modify-request");
     }
     this.needTracing = this.listeners.onStart.size ||
                        this.listeners.onError.size ||
                        this.listeners.onStop.size;
 
     let needExamine = this.needTracing ||
-                      this.listeners.headersReceived.size;
+                      this.listeners.headersReceived.size ||
+                      this.listeners.authRequired.size;
 
     if (needExamine && !this.examineInitialized) {
       this.examineInitialized = true;
       Services.obs.addObserver(this, "http-on-examine-response", false);
       Services.obs.addObserver(this, "http-on-examine-cached-response", false);
       Services.obs.addObserver(this, "http-on-examine-merged-response", false);
     } else if (!needExamine && this.examineInitialized) {
       this.examineInitialized = false;
@@ -692,17 +826,17 @@ HttpObserverManager = {
           return;
         }
       }
 
       let {loadInfo} = channel;
       let policyType = (loadInfo ? loadInfo.externalContentPolicyType
                                  : Ci.nsIContentPolicy.TYPE_OTHER);
 
-      let includeStatus = (["headersReceived", "onRedirect", "onStart", "onStop"].includes(kind) &&
+      let includeStatus = (["headersReceived", "authRequired", "onRedirect", "onStart", "onStop"].includes(kind) &&
                            channel instanceof Ci.nsIHttpChannel);
 
       let canModify = this.canModify(channel);
       let commonData = null;
       let uri = channel.URI;
       let requestBody;
       for (let [callback, opts] of this.listeners[kind].entries()) {
         if (!this.shouldRunListener(policyType, uri, opts.filter)) {
@@ -796,16 +930,31 @@ HttpObserverManager = {
 
         if (opts.requestHeaders && result.requestHeaders && requestHeaders) {
           requestHeaders.applyChanges(result.requestHeaders);
         }
 
         if (opts.responseHeaders && result.responseHeaders && responseHeaders) {
           responseHeaders.applyChanges(result.responseHeaders);
         }
+
+        if (kind === "authRequired" && opts.blocking && result.authCredentials) {
+          let channelData = getData(channel);
+          if (channelData.authPromptCallback) {
+            channelData.authPromptCallback(result.authCredentials);
+          }
+        }
+      }
+      // If a listener did not cancel the request or provide credentials, we
+      // forward the auth request to the base handler.
+      if (kind === "authRequired") {
+        let channelData = getData(channel);
+        if (channelData.authPromptForward) {
+          channelData.authPromptForward();
+        }
       }
 
       if (kind === "opening") {
         yield this.runChannelListener(channel, loadContext, "modify");
       } else if (kind === "modify") {
         yield this.runChannelListener(channel, loadContext, "afterModify");
       }
     } catch (e) {
@@ -813,36 +962,60 @@ HttpObserverManager = {
     }
 
     // Only resume the channel if it was suspended by this call.
     if (shouldResume) {
       this.maybeResume(channel);
     }
   }),
 
+  shouldHookListener(listener, channel) {
+    if (listener.size == 0) {
+      return false;
+    }
+
+    let {loadInfo} = channel;
+    let policyType = (loadInfo ? loadInfo.externalContentPolicyType
+                               : Ci.nsIContentPolicy.TYPE_OTHER);
+    let uri = channel.URI;
+    for (let opts of listener.values()) {
+      if (this.shouldRunListener(policyType, uri, opts.filter)) {
+        return true;
+      }
+    }
+    return false;
+  },
+
   examine(channel, topic, data) {
     let loadContext = this.getLoadContext(channel);
 
+    let channelData = getData(channel);
     if (this.needTracing) {
       // Check whether we've already added a listener to this channel,
       // so we don't wind up chaining multiple listeners.
-      let channelData = getData(channel);
       if (!channelData.hasListener && channel instanceof Ci.nsITraceableChannel) {
         let responseStatus = channel.responseStatus;
         // skip redirections, https://bugzilla.mozilla.org/show_bug.cgi?id=728901#c8
         if (responseStatus < 300 || responseStatus >= 400) {
           let listener = new StartStopListener(this, loadContext);
           let orig = channel.setNewListener(listener);
           listener.orig = orig;
           channelData.hasListener = true;
         }
       }
     }
 
-    this.runChannelListener(channel, loadContext, "headersReceived");
+    if (this.listeners.headersReceived.size) {
+      this.runChannelListener(channel, loadContext, "headersReceived");
+    }
+
+    if (!channelData.hasAuthRequestor && this.shouldHookListener(this.listeners.authRequired, channel)) {
+      channel.notificationCallbacks = new AuthRequestor(channel, this);
+      channelData.hasAuthRequestor = true;
+    }
   },
 
   onChannelReplaced(oldChannel, newChannel) {
     this.runChannelListener(oldChannel, this.getLoadContext(oldChannel),
                             "onRedirect", {redirectUrl: newChannel.URI.spec});
   },
 
   onStartRequest(channel, loadContext) {
@@ -885,16 +1058,17 @@ HttpEvent.prototype = {
   removeListener(callback) {
     HttpObserverManager.removeListener(this.internalEvent, callback);
   },
 };
 
 var onBeforeSendHeaders = new HttpEvent("modify", ["requestHeaders", "blocking"]);
 var onSendHeaders = new HttpEvent("afterModify", ["requestHeaders"]);
 var onHeadersReceived = new HttpEvent("headersReceived", ["blocking", "responseHeaders"]);
+var onAuthRequired = new HttpEvent("authRequired", ["blocking", "responseHeaders"]); // TODO asyncBlocking
 var onBeforeRedirect = new HttpEvent("onRedirect", ["responseHeaders"]);
 var onResponseStarted = new HttpEvent("onStart", ["responseHeaders"]);
 var onCompleted = new HttpEvent("onStop", ["responseHeaders"]);
 var onErrorOccurred = new HttpEvent("onError");
 
 var WebRequest = {
   // http-on-modify observer for HTTP(S), content policy for the other protocols (notably, data:)
   onBeforeRequest: onBeforeRequest,
@@ -903,16 +1077,19 @@ var WebRequest = {
   onBeforeSendHeaders: onBeforeSendHeaders,
 
   // http-on-modify observer.
   onSendHeaders: onSendHeaders,
 
   // http-on-examine-*observer.
   onHeadersReceived: onHeadersReceived,
 
+  // http-on-examine-*observer.
+  onAuthRequired: onAuthRequired,
+
   // nsIChannelEventSink.
   onBeforeRedirect: onBeforeRedirect,
 
   // OnStartRequest channel listener.
   onResponseStarted: onResponseStarted,
 
   // OnStopRequest channel listener.
   onCompleted: onCompleted,