From 1a70dd3342efe39eb8a2e5f2887d0de2f60c88a7 Mon Sep 17 00:00:00 2001 From: Julian Descottes Date: Mon, 27 Nov 2023 11:27:20 +0000 Subject: [PATCH] Bug 1826198 - [devtools] Add an optional auth prompt listener to the NetworkObserver r=bomsy,devtools-reviewers,valentin,necko-reviewers Differential Revision: https://phabricator.services.mozilla.com/D189515 --- .../NetworkAuthListener.sys.mjs | 185 +++++++++ .../network-observer/NetworkObserver.sys.mjs | 20 + devtools/shared/network-observer/moz.build | 1 + .../test/browser/browser.toml | 3 + .../test/browser/browser_networkobserver.js | 8 +- .../browser_networkobserver_auth_listener.js | 374 ++++++++++++++++++ .../network-observer/test/browser/head.js | 60 ++- .../sjs_network-auth-listener-test-server.sjs | 31 ++ netwerk/protocol/http/nsHttpChannel.cpp | 1 + 9 files changed, 662 insertions(+), 21 deletions(-) create mode 100644 devtools/shared/network-observer/NetworkAuthListener.sys.mjs create mode 100644 devtools/shared/network-observer/test/browser/browser_networkobserver_auth_listener.js create mode 100644 devtools/shared/network-observer/test/browser/sjs_network-auth-listener-test-server.sjs diff --git a/devtools/shared/network-observer/NetworkAuthListener.sys.mjs b/devtools/shared/network-observer/NetworkAuthListener.sys.mjs new file mode 100644 index 000000000000..2ab5517aa17e --- /dev/null +++ b/devtools/shared/network-observer/NetworkAuthListener.sys.mjs @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +/** + * This class is a simplified version from the AuthRequestor used by the + * WebExtensions codebase at: + * https://searchfox.org/mozilla-central/rev/fd2325f5b2a5be8f8f2acf9307285f2b7de06582/toolkit/components/extensions/webrequest/WebRequest.sys.mjs#434-579 + * + * The NetworkAuthListener will monitor the provided channel and will invoke the + * owner's `onAuthPrompt` end point whenever an auth challenge is requested. + * + * The owner will receive several callbacks to proceed with the prompt: + * - cancelAuthPrompt(): cancel the authentication attempt + * - forwardAuthPrompt(): forward the auth prompt request to the next + * notification callback. If no other custom callback is set, this will + * typically lead to show the auth prompt dialog in the browser UI. + * - provideAuthCredentials(username, password): attempt to authenticate with + * the provided username and password. + * + * Please note that the request will be blocked until the consumer calls one of + * the callbacks listed above. Make sure to eventually unblock the request if + * you implement `onAuthPrompt`. + * + * @param {nsIChannel} channel + * The channel to monitor. + * @param {object} owner + * The owner object, expected to implement `onAuthPrompt`. + */ +export class NetworkAuthListener { + constructor(channel, owner) { + this.notificationCallbacks = channel.notificationCallbacks; + this.loadGroupCallbacks = + channel.loadGroup && channel.loadGroup.notificationCallbacks; + this.owner = owner; + + // Setup the channel's notificationCallbacks to be handled by this instance. + channel.notificationCallbacks = this; + } + + // See https://searchfox.org/mozilla-central/source/netwerk/base/nsIAuthPrompt2.idl + asyncPromptAuth(channel, callback, context, level, authInfo) { + const isProxy = !!(authInfo.flags & authInfo.AUTH_PROXY); + const cancelAuthPrompt = () => { + if (channel.canceled) { + return; + } + + try { + callback.onAuthCancelled(context, false); + } catch (e) { + console.error(`NetworkAuthListener failed to cancel auth prompt ${e}`); + } + }; + + const forwardAuthPrompt = () => { + if (channel.canceled) { + return; + } + + const prompt = this.#getForwardPrompt(isProxy); + prompt.asyncPromptAuth(channel, callback, context, level, authInfo); + }; + + const provideAuthCredentials = (username, password) => { + if (channel.canceled) { + return; + } + + authInfo.username = username; + authInfo.password = password; + try { + callback.onAuthAvailable(context, authInfo); + } catch (e) { + console.error( + `NetworkAuthListener failed to provide auth credentials ${e}` + ); + } + }; + + const authDetails = { + isProxy, + realm: authInfo.realm, + scheme: authInfo.authenticationScheme, + }; + const authCallbacks = { + cancelAuthPrompt, + forwardAuthPrompt, + provideAuthCredentials, + }; + + // The auth callbacks may only be called asynchronously after this method + // successfully returned. + lazy.setTimeout(() => this.#notifyOwner(authDetails, authCallbacks), 1); + + return { + QueryInterface: ChromeUtils.generateQI(["nsICancelable"]), + cancel: cancelAuthPrompt, + }; + } + + getInterface(iid) { + if (iid.equals(Ci.nsIAuthPromptProvider) || iid.equals(Ci.nsIAuthPrompt2)) { + return this; + } + try { + return this.notificationCallbacks.getInterface(iid); + } catch (e) {} + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + } + + // See https://searchfox.org/mozilla-central/source/netwerk/base/nsIAuthPromptProvider.idl + 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 + ); + } + + // See https://searchfox.org/mozilla-central/source/netwerk/base/nsIAuthPrompt2.idl + promptAuth(channel, level, authInfo) { + this.#getForwardedInterface(Ci.nsIAuthPrompt2).promptAuth( + channel, + level, + authInfo + ); + } + + #getForwardedInterface(iid) { + try { + return this.notificationCallbacks.getInterface(iid); + } catch (e) { + return this.loadGroupCallbacks.getInterface(iid); + } + } + + #getForwardPrompt(isProxy) { + const reason = isProxy + ? Ci.nsIAuthPromptProvider.PROMPT_PROXY + : Ci.nsIAuthPromptProvider.PROMPT_NORMAL; + for (const 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 Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + } + + #notifyOwner(authDetails, authCallbacks) { + if (typeof this.owner.onAuthPrompt == "function") { + this.owner.onAuthPrompt(authDetails, authCallbacks); + } else { + console.error( + "NetworkObserver owner enabled the auth prompt listener " + + "but does not implement 'onAuthPrompt'. " + + "Forwarding the auth prompt to the next notification callback." + ); + authCallbacks.forwardAuthPrompt(); + } + } +} + +NetworkAuthListener.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIInterfaceRequestor", + "nsIAuthPromptProvider", + "nsIAuthPrompt2", +]); diff --git a/devtools/shared/network-observer/NetworkObserver.sys.mjs b/devtools/shared/network-observer/NetworkObserver.sys.mjs index 7c5ee2a6ff88..9f3f799adb52 100644 --- a/devtools/shared/network-observer/NetworkObserver.sys.mjs +++ b/devtools/shared/network-observer/NetworkObserver.sys.mjs @@ -21,6 +21,8 @@ import { DevToolsInfaillibleUtils } from "resource://devtools/shared/DevToolsInf ChromeUtils.defineESModuleGetters(lazy, { ChannelMap: "resource://devtools/shared/network-observer/ChannelMap.sys.mjs", + NetworkAuthListener: + "resource://devtools/shared/network-observer/NetworkAuthListener.sys.mjs", NetworkHelper: "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs", NetworkOverride: @@ -123,6 +125,12 @@ export class NetworkObserver { * @type {Map} */ #decodedCertificateCache = new Map(); + /** + * Whether the consumer supports listening and handling auth prompts. + * + * @type {boolean} + */ + #authPromptListenerEnabled = false; /** * See constructor argument of the same name. * @@ -229,6 +237,10 @@ export class NetworkObserver { ); } + setAuthPromptListenerEnabled(enabled) { + this.#authPromptListenerEnabled = enabled; + } + setSaveRequestAndResponseBodies(save) { this.#saveRequestAndResponseBodies = save; } @@ -705,6 +717,10 @@ export class NetworkObserver { }); } + if (this.#authPromptListenerEnabled) { + new lazy.NetworkAuthListener(httpActivity.channel, httpActivity.owner); + } + return httpActivity; } @@ -1375,6 +1391,10 @@ export class NetworkObserver { * listening. */ destroy() { + if (this.#isDestroyed) { + return; + } + if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT) { gActivityDistributor.removeObserver(this); Services.obs.removeObserver( diff --git a/devtools/shared/network-observer/moz.build b/devtools/shared/network-observer/moz.build index 5a5fbee04b40..3c782ea9aab3 100644 --- a/devtools/shared/network-observer/moz.build +++ b/devtools/shared/network-observer/moz.build @@ -10,6 +10,7 @@ XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] DevToolsModules( "ChannelMap.sys.mjs", + "NetworkAuthListener.sys.mjs", "NetworkHelper.sys.mjs", "NetworkObserver.sys.mjs", "NetworkOverride.sys.mjs", diff --git a/devtools/shared/network-observer/test/browser/browser.toml b/devtools/shared/network-observer/test/browser/browser.toml index 9d301ec8b943..bc43c659e68e 100644 --- a/devtools/shared/network-observer/test/browser/browser.toml +++ b/devtools/shared/network-observer/test/browser/browser.toml @@ -7,6 +7,7 @@ support-files = [ "gzipped.sjs", "override.html", "override.js", + "sjs_network-auth-listener-test-server.sjs", "sjs_network-observer-test-server.sjs", ] @@ -16,6 +17,8 @@ skip-if = [ "http2", ] +["browser_networkobserver_auth_listener.js"] + ["browser_networkobserver_invalid_constructor.js"] ["browser_networkobserver_override.js"] diff --git a/devtools/shared/network-observer/test/browser/browser_networkobserver.js b/devtools/shared/network-observer/test/browser/browser_networkobserver.js index 73e9f8510aaa..4a678360f1d1 100644 --- a/devtools/shared/network-observer/test/browser/browser_networkobserver.js +++ b/devtools/shared/network-observer/test/browser/browser_networkobserver.js @@ -17,8 +17,8 @@ add_task(async function testSingleRequest() { content.wrappedJSObject.sendRequest(_url); }); - const eventsCount = await onNetworkEvents; - is(eventsCount, 1, "Received the expected number of network events"); + const events = await onNetworkEvents; + is(events.length, 1, "Received the expected number of network events"); }); add_task(async function testMultipleRequests() { @@ -39,9 +39,9 @@ add_task(async function testMultipleRequests() { } ); - const eventsCount = await onNetworkEvents; + const events = await onNetworkEvents; is( - eventsCount, + events.length, EXPECTED_REQUESTS_COUNT, "Received the expected number of network events" ); diff --git a/devtools/shared/network-observer/test/browser/browser_networkobserver_auth_listener.js b/devtools/shared/network-observer/test/browser/browser_networkobserver_auth_listener.js new file mode 100644 index 000000000000..84abb0218701 --- /dev/null +++ b/devtools/shared/network-observer/test/browser/browser_networkobserver_auth_listener.js @@ -0,0 +1,374 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); + +const TEST_URL = URL_ROOT + "doc_network-observer.html"; +const AUTH_URL = URL_ROOT + `sjs_network-auth-listener-test-server.sjs`; + +// Correct credentials for sjs_network-auth-listener-test-server.sjs. +const USERNAME = "guest"; +const PASSWORD = "guest"; +const BAD_PASSWORD = "bad"; + +// NetworkEventOwner which will cancel all auth prompt requests. +class AuthCancellingOwner extends NetworkEventOwner { + hasAuthPrompt = false; + + onAuthPrompt(authDetails, authCallbacks) { + this.hasAuthPrompt = true; + authCallbacks.cancelAuthPrompt(); + } +} + +// NetworkEventOwner which will forward all auth prompt requests to the browser. +class AuthForwardingOwner extends NetworkEventOwner { + hasAuthPrompt = false; + + onAuthPrompt(authDetails, authCallbacks) { + this.hasAuthPrompt = true; + authCallbacks.forwardAuthPrompt(); + } +} + +// NetworkEventOwner which will answer provided credentials to auth prompts. +class AuthCredentialsProvidingOwner extends NetworkEventOwner { + hasAuthPrompt = false; + + constructor(channel, username, password) { + super(); + + this.channel = channel; + this.username = username; + this.password = password; + } + + async onAuthPrompt(authDetails, authCallbacks) { + this.hasAuthPrompt = true; + + // Providing credentials immediately can lead to intermittent failures. + // TODO: Investigate and remove. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 100)); + + await authCallbacks.provideAuthCredentials(this.username, this.password); + } + + addResponseContent(content) { + super.addResponseContent(); + this.responseContent = content.text; + } +} + +add_task(async function testAuthRequestWithoutListener() { + cleanupAuthManager(); + const tab = await addTab(TEST_URL); + + const events = []; + const networkObserver = new NetworkObserver({ + ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL, + onNetworkEvent: event => { + const owner = new AuthForwardingOwner(); + events.push(owner); + return owner; + }, + }); + registerCleanupFunction(() => networkObserver.destroy()); + + const onAuthPrompt = waitForAuthPrompt(tab); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => { + content.wrappedJSObject.sendRequest(_url); + }); + + info("Wait for a network event to be created"); + await BrowserTestUtils.waitForCondition(() => events.length >= 1); + is(events.length, 1, "Received the expected number of network events"); + + info("Wait for the auth prompt to be displayed"); + await onAuthPrompt; + ok( + getTabAuthPrompts(tab).length == 1, + "The auth prompt was not blocked by the network observer" + ); + + // The event owner should have been called for ResponseStart and EventTimings + assertEventOwner(events[0], { + hasResponseStart: true, + hasEventTimings: true, + }); + + networkObserver.destroy(); + gBrowser.removeTab(tab); +}); + +add_task(async function testAuthRequestWithForwardingListener() { + cleanupAuthManager(); + const tab = await addTab(TEST_URL); + + const events = []; + const networkObserver = new NetworkObserver({ + ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL, + onNetworkEvent: event => { + info("waitForNetworkEvents received a new event"); + const owner = new AuthForwardingOwner(); + events.push(owner); + return owner; + }, + }); + registerCleanupFunction(() => networkObserver.destroy()); + + info("Enable the auth prompt listener for this network observer"); + networkObserver.setAuthPromptListenerEnabled(true); + + const onAuthPrompt = waitForAuthPrompt(tab); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => { + content.wrappedJSObject.sendRequest(_url); + }); + + info("Wait for a network event to be received"); + await BrowserTestUtils.waitForCondition(() => events.length >= 1); + is(events.length, 1, "Received the expected number of network events"); + + // The auth prompt should still be displayed since the network event owner + // forwards the auth notification immediately. + info("Wait for the auth prompt to be displayed"); + await onAuthPrompt; + ok( + getTabAuthPrompts(tab).length == 1, + "The auth prompt was not blocked by the network observer" + ); + + // The event owner should have been called for ResponseStart, EventTimings and + // AuthPrompt + assertEventOwner(events[0], { + hasResponseStart: true, + hasEventTimings: true, + hasAuthPrompt: true, + }); + + networkObserver.destroy(); + gBrowser.removeTab(tab); +}); + +add_task(async function testAuthRequestWithCancellingListener() { + cleanupAuthManager(); + const tab = await addTab(TEST_URL); + + const events = []; + const networkObserver = new NetworkObserver({ + ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL, + onNetworkEvent: event => { + const owner = new AuthCancellingOwner(); + events.push(owner); + return owner; + }, + }); + registerCleanupFunction(() => networkObserver.destroy()); + + info("Enable the auth prompt listener for this network observer"); + networkObserver.setAuthPromptListenerEnabled(true); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => { + content.wrappedJSObject.sendRequest(_url); + }); + + info("Wait for a network event to be received"); + await BrowserTestUtils.waitForCondition(() => events.length >= 1); + is(events.length, 1, "Received the expected number of network events"); + + await BrowserTestUtils.waitForCondition(() => events[0].hasResponseContent); + + // The auth prompt should not be displayed since the authentication was + // cancelled. + ok( + !getTabAuthPrompts(tab).length, + "The auth prompt was cancelled by the network event owner" + ); + + assertEventOwner(events[0], { + hasResponseStart: true, + hasResponseContent: true, + hasEventTimings: true, + hasServerTimings: true, + hasAuthPrompt: true, + hasSecurityInfo: true, + }); + + networkObserver.destroy(); + gBrowser.removeTab(tab); +}); + +add_task(async function testAuthRequestWithWrongCredentialsListener() { + cleanupAuthManager(); + const tab = await addTab(TEST_URL); + + const events = []; + const networkObserver = new NetworkObserver({ + ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL, + onNetworkEvent: (event, channel) => { + const owner = new AuthCredentialsProvidingOwner( + channel, + USERNAME, + BAD_PASSWORD + ); + events.push(owner); + return owner; + }, + }); + registerCleanupFunction(() => networkObserver.destroy()); + + info("Enable the auth prompt listener for this network observer"); + networkObserver.setAuthPromptListenerEnabled(true); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => { + content.wrappedJSObject.sendRequest(_url); + }); + + info("Wait for all network events to be received"); + await BrowserTestUtils.waitForCondition(() => events.length >= 1); + is(events.length, 1, "Received the expected number of network events"); + + // The auth prompt should not be displayed since the authentication was + // fulfilled. + ok( + !getTabAuthPrompts(tab).length, + "The auth prompt was handled by the network event owner" + ); + + assertEventOwner(events[0], { + hasAuthPrompt: true, + hasResponseStart: true, + hasEventTimings: true, + }); + + networkObserver.destroy(); + gBrowser.removeTab(tab); +}); + +add_task(async function testAuthRequestWithCredentialsListener() { + cleanupAuthManager(); + const tab = await addTab(TEST_URL); + + const events = []; + const networkObserver = new NetworkObserver({ + ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL, + onNetworkEvent: (event, channel) => { + const owner = new AuthCredentialsProvidingOwner( + channel, + USERNAME, + PASSWORD + ); + events.push(owner); + return owner; + }, + }); + registerCleanupFunction(() => networkObserver.destroy()); + + info("Enable the auth prompt listener for this network observer"); + networkObserver.setAuthPromptListenerEnabled(true); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => { + content.wrappedJSObject.sendRequest(_url); + }); + + // TODO: At the moment, providing credentials will result in additional + // network events collected by the NetworkObserver, whereas we would expect + // to keep the same event. + // For successful auth prompts, we receive an additional event. + // The last event will contain the responseContent flag. + info("Wait for all network events to be received"); + await BrowserTestUtils.waitForCondition(() => events.length >= 2); + is(events.length, 2, "Received the expected number of network events"); + + // Since the auth prompt was canceled we should also receive the security + // information for the channel, but we still don't get response content. + await BrowserTestUtils.waitForCondition(() => events[1].hasResponseContent); + + // The auth prompt should not be displayed since the authentication was + // fulfilled. + ok( + !getTabAuthPrompts(tab).length, + "The auth prompt was handled by the network event owner" + ); + + assertEventOwner(events[1], { + hasResponseStart: true, + hasEventTimings: true, + hasSecurityInfo: true, + hasServerTimings: true, + hasResponseContent: true, + }); + + is(events[1].responseContent, "success", "Auth prompt was successful"); + + networkObserver.destroy(); + gBrowser.removeTab(tab); +}); + +function assertEventOwner(event, expectedFlags) { + is( + event.hasResponseStart, + !!expectedFlags.hasResponseStart, + "network event has the expected ResponseStart flag" + ); + is( + event.hasEventTimings, + !!expectedFlags.hasEventTimings, + "network event has the expected EventTimings flag" + ); + is( + event.hasAuthPrompt, + !!expectedFlags.hasAuthPrompt, + "network event has the expected AuthPrompt flag" + ); + is( + event.hasResponseCache, + !!expectedFlags.hasResponseCache, + "network event has the expected ResponseCache flag" + ); + is( + event.hasResponseContent, + !!expectedFlags.hasResponseContent, + "network event has the expected ResponseContent flag" + ); + is( + event.hasSecurityInfo, + !!expectedFlags.hasSecurityInfo, + "network event has the expected SecurityInfo flag" + ); + is( + event.hasServerTimings, + !!expectedFlags.hasServerTimings, + "network event has the expected ServerTimings flag" + ); +} + +function getTabAuthPrompts(tab) { + const tabDialogBox = gBrowser.getTabDialogBox(tab.linkedBrowser); + return tabDialogBox + .getTabDialogManager() + ._dialogs.filter( + d => d.frameContentWindow?.Dialog.args.promptType == "promptUserAndPass" + ); +} + +function waitForAuthPrompt(tab) { + return PromptTestUtils.waitForPrompt(tab.linkedBrowser, { + modalType: Services.prompt.MODAL_TYPE_TAB, + promptType: "promptUserAndPass", + }); +} + +// Cleanup potentially stored credentials before running any test. +function cleanupAuthManager() { + const authManager = SpecialPowers.Cc[ + "@mozilla.org/network/http-auth-manager;1" + ].getService(SpecialPowers.Ci.nsIHttpAuthManager); + authManager.clearAll(); +} diff --git a/devtools/shared/network-observer/test/browser/head.js b/devtools/shared/network-observer/test/browser/head.js index 0626505c2c56..4200ab2d5634 100644 --- a/devtools/shared/network-observer/test/browser/head.js +++ b/devtools/shared/network-observer/test/browser/head.js @@ -46,18 +46,43 @@ async function addTab(url) { } /** - * Create a simple network event owner, with empty implementations of all + * Base network event owner class implementing all mandatory callbacks and + * keeping track of which callbacks have been called. + */ +class NetworkEventOwner { + hasEventTimings = false; + hasResponseCache = false; + hasResponseContent = false; + hasResponseStart = false; + hasSecurityInfo = false; + hasServerTimings = false; + + addEventTimings() { + this.hasEventTimings = true; + } + addResponseCache() { + this.hasResponseCache = true; + } + addResponseContent() { + this.hasResponseContent = true; + } + addResponseStart() { + this.hasResponseStart = true; + } + addSecurityInfo() { + this.hasSecurityInfo = true; + } + addServerTimings() { + this.hasServerTimings = true; + } +} + +/** + * Create a simple network event owner, with mock implementations of all * the expected APIs for a NetworkEventOwner. */ function createNetworkEventOwner(event) { - return { - addEventTimings: () => {}, - addResponseCache: () => {}, - addResponseContent: () => {}, - addResponseStart: () => {}, - addSecurityInfo: () => {}, - addServerTimings: () => {}, - }; + return new NetworkEventOwner(); } /** @@ -69,24 +94,25 @@ function createNetworkEventOwner(event) { * @param {number} expectedRequestsCount * How many different events (requests) are expected. * @returns {Promise} - * A promise which will resolve with the current count, when the expected - * count is reached. + * A promise which will resolve with an array of network event owners, when + * the expected event count is reached. */ async function waitForNetworkEvents(expectedUrl, expectedRequestsCount) { - let eventsCount = 0; + const events = []; const networkObserver = new NetworkObserver({ ignoreChannelFunction: channel => channel.URI.spec !== expectedUrl, - onNetworkEvent: event => { + onNetworkEvent: () => { info("waitForNetworkEvents received a new event"); - eventsCount++; - return createNetworkEventOwner(event); + const owner = createNetworkEventOwner(); + events.push(owner); + return owner; }, }); registerCleanupFunction(() => networkObserver.destroy()); info("Wait until the events count reaches " + expectedRequestsCount); await BrowserTestUtils.waitForCondition( - () => eventsCount >= expectedRequestsCount + () => events.length >= expectedRequestsCount ); - return eventsCount; + return events; } diff --git a/devtools/shared/network-observer/test/browser/sjs_network-auth-listener-test-server.sjs b/devtools/shared/network-observer/test/browser/sjs_network-auth-listener-test-server.sjs new file mode 100644 index 000000000000..028a26ebfe1b --- /dev/null +++ b/devtools/shared/network-observer/test/browser/sjs_network-auth-listener-test-server.sjs @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function handleRequest(request, response) { + let body; + + // Expect guest/guest as correct credentials, but `btoa` is unavailable in sjs + // "Z3Vlc3Q6Z3Vlc3Q=" == btoa("guest:guest") + const expectedHeader = "Basic Z3Vlc3Q6Z3Vlc3Q="; + + // correct login credentials provided + if ( + request.hasHeader("Authorization") && + request.getHeader("Authorization") == expectedHeader + ) { + response.setStatusLine(request.httpVersion, 200, "OK, authorized"); + response.setHeader("Content-Type", "text", false); + + body = "success"; + } else { + // incorrect credentials + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + response.setHeader("Content-Type", "text", false); + + body = "failed"; + } + response.bodyOutputStream.write(body, body.length); +} diff --git a/netwerk/protocol/http/nsHttpChannel.cpp b/netwerk/protocol/http/nsHttpChannel.cpp index 4efc4883bc94..ef2b06f45cdc 100644 --- a/netwerk/protocol/http/nsHttpChannel.cpp +++ b/netwerk/protocol/http/nsHttpChannel.cpp @@ -5557,6 +5557,7 @@ NS_IMETHODIMP nsHttpChannel::OnAuthAvailable() { NS_IMETHODIMP nsHttpChannel::OnAuthCancelled(bool userCancel) { LOG(("nsHttpChannel::OnAuthCancelled [this=%p]", this)); + MOZ_ASSERT(mAuthRetryPending, "OnAuthCancelled should not be called twice"); if (mTransactionPump) { // If the channel is trying to authenticate to a proxy and