Browse Source

feat: support app#login event for utility process net requests (#42631)

* feat: support app#login event for utility process net requests

* chore: address review feedback

* GlobalRequestID: Avoid unwanted inlining and narrowing int conversions

Refs https://chromium-review.googlesource.com/c/chromium/src/+/5702737
Robo 8 months ago
parent
commit
9b166b3ed4

+ 3 - 2
docs/api/app.md

@@ -345,9 +345,10 @@ app.on('select-client-certificate', (event, webContents, url, list, callback) =>
 Returns:
 
 * `event` Event
-* `webContents` [WebContents](web-contents.md)
+* `webContents` [WebContents](web-contents.md) (optional)
 * `authenticationResponseDetails` Object
   * `url` URL
+  * `pid` number
 * `authInfo` Object
   * `isProxy` boolean
   * `scheme` string
@@ -358,7 +359,7 @@ Returns:
   * `username` string (optional)
   * `password` string (optional)
 
-Emitted when `webContents` wants to do basic auth.
+Emitted when `webContents` or [Utility process](../glossary.md#utility-process) wants to do basic auth.
 
 The default behavior is to cancel all authentications. To override this you
 should prevent the default behavior with `event.preventDefault()` and call

+ 2 - 0
docs/api/utility-process.md

@@ -36,6 +36,8 @@ Process: [Main](../glossary.md#main-process)<br />
     `com.apple.security.cs.allow-unsigned-executable-memory` entitlements. This will allow the utility process
     to load unsigned libraries. Unless you specifically need this capability, it is best to leave this disabled.
     Default is `false`.
+  * `respondToAuthRequestsFromMainProcess` boolean (optional) - With this flag, all HTTP 401 and 407 network
+    requests created via the [net module](net.md) will allow responding to them via the [`app#login`](app.md#event-login) event in the main process instead of the default [`login`](client-request.md#event-login) event on the [`ClientRequest`](client-request.md) object.
 
 Returns [`UtilityProcess`](utility-process.md#class-utilityprocess)
 

+ 6 - 0
docs/breaking-changes.md

@@ -14,6 +14,12 @@ This document uses the following convention to categorize breaking changes:
 
 ## Planned Breaking API Changes (33.0)
 
+### Behavior Changed: `webContents` property on `login` on `app`
+
+The `webContents` property in the `login` event from `app` will be `null`
+when the event is triggered for requests from the [utility process](api/utility-process.md)
+created with `respondToAuthRequestsFromMainProcess` option.
+
 ### Deprecated: `textured` option in `BrowserWindowConstructorOption.type`
 
 The `textured` option of `type` in `BrowserWindowConstructorOptions` has been deprecated with no replacement. This option relied on the [`NSWindowStyleMaskTexturedBackground`](https://developer.apple.com/documentation/appkit/nswindowstylemask/nswindowstylemasktexturedbackground) style mask on macOS, which has been deprecated with no alternative.

+ 2 - 0
filenames.gni

@@ -453,6 +453,8 @@ filenames = {
     "shell/browser/net/resolve_proxy_helper.h",
     "shell/browser/net/system_network_context_manager.cc",
     "shell/browser/net/system_network_context_manager.h",
+    "shell/browser/net/url_loader_network_observer.cc",
+    "shell/browser/net/url_loader_network_observer.h",
     "shell/browser/net/url_pipe_loader.cc",
     "shell/browser/net/url_pipe_loader.h",
     "shell/browser/net/web_request_api_interface.h",

+ 18 - 5
shell/browser/api/electron_api_utility_process.cc

@@ -63,7 +63,8 @@ UtilityProcessWrapper::UtilityProcessWrapper(
     std::map<IOHandle, IOType> stdio,
     base::EnvironmentMap env_map,
     base::FilePath current_working_directory,
-    bool use_plugin_helper) {
+    bool use_plugin_helper,
+    bool create_network_observer) {
 #if BUILDFLAG(IS_WIN)
   base::win::ScopedHandle stdout_write(nullptr);
   base::win::ScopedHandle stderr_write(nullptr);
@@ -203,6 +204,11 @@ UtilityProcessWrapper::UtilityProcessWrapper(
   loader_params->process_id = pid_;
   loader_params->is_orb_enabled = false;
   loader_params->is_trusted = true;
+  if (create_network_observer) {
+    url_loader_network_observer_.emplace();
+    loader_params->url_loader_network_observer =
+        url_loader_network_observer_->Bind();
+  }
   network::mojom::NetworkContext* network_context =
       g_browser_process->system_network_context_manager()->GetContext();
   network_context->CreateURLLoaderFactory(
@@ -213,6 +219,8 @@ UtilityProcessWrapper::UtilityProcessWrapper(
   network_context->CreateHostResolver(
       {}, host_resolver.InitWithNewPipeAndPassReceiver());
   params->host_resolver = std::move(host_resolver);
+  params->use_network_observer_from_url_loader_factory =
+      create_network_observer;
 
   node_service_remote_->Initialize(std::move(params));
 }
@@ -230,6 +238,9 @@ void UtilityProcessWrapper::OnServiceProcessLaunch(
     EmitWithoutEvent("stdout", stdout_read_fd_);
   if (stderr_read_fd_ != -1)
     EmitWithoutEvent("stderr", stderr_read_fd_);
+  if (url_loader_network_observer_.has_value()) {
+    url_loader_network_observer_->set_process_id(pid_);
+  }
   EmitWithoutEvent("spawn");
 }
 
@@ -378,6 +389,7 @@ gin::Handle<UtilityProcessWrapper> UtilityProcessWrapper::Create(
 
   std::u16string display_name;
   bool use_plugin_helper = false;
+  bool create_network_observer = false;
   std::map<IOHandle, IOType> stdio;
   base::FilePath current_working_directory;
   base::EnvironmentMap env_map;
@@ -403,6 +415,7 @@ gin::Handle<UtilityProcessWrapper> UtilityProcessWrapper::Create(
 
     opts.Get("serviceName", &display_name);
     opts.Get("cwd", &current_working_directory);
+    opts.Get("respondToAuthRequestsFromMainProcess", &create_network_observer);
 
     std::vector<std::string> stdio_arr{"ignore", "inherit", "inherit"};
     opts.Get("stdio", &stdio_arr);
@@ -423,10 +436,10 @@ gin::Handle<UtilityProcessWrapper> UtilityProcessWrapper::Create(
 #endif
   }
   auto handle = gin::CreateHandle(
-      args->isolate(),
-      new UtilityProcessWrapper(std::move(params), display_name,
-                                std::move(stdio), env_map,
-                                current_working_directory, use_plugin_helper));
+      args->isolate(), new UtilityProcessWrapper(
+                           std::move(params), display_name, std::move(stdio),
+                           env_map, current_working_directory,
+                           use_plugin_helper, create_network_observer));
   handle->Pin(args->isolate());
   return handle;
 }

+ 5 - 1
shell/browser/api/electron_api_utility_process.h

@@ -18,6 +18,7 @@
 #include "mojo/public/cpp/bindings/message.h"
 #include "mojo/public/cpp/bindings/remote.h"
 #include "shell/browser/event_emitter_mixin.h"
+#include "shell/browser/net/url_loader_network_observer.h"
 #include "shell/common/gin_helper/pinnable.h"
 #include "shell/services/node/public/mojom/node_service.mojom.h"
 #include "v8/include/v8-forward.h"
@@ -66,7 +67,8 @@ class UtilityProcessWrapper
                         std::map<IOHandle, IOType> stdio,
                         base::EnvironmentMap env_map,
                         base::FilePath current_working_directory,
-                        bool use_plugin_helper);
+                        bool use_plugin_helper,
+                        bool create_network_observer);
   void OnServiceProcessLaunch(const base::Process& process);
   void CloseConnectorPort();
 
@@ -101,6 +103,8 @@ class UtilityProcessWrapper
   std::unique_ptr<mojo::Connector> connector_;
   blink::MessagePortDescriptor host_port_;
   mojo::Remote<node::mojom::NodeService> node_service_remote_;
+  std::optional<electron::URLLoaderNetworkObserver>
+      url_loader_network_observer_;
   base::WeakPtrFactory<UtilityProcessWrapper> weak_factory_{this};
 };
 

+ 2 - 2
shell/browser/electron_browser_client.cc

@@ -1649,8 +1649,8 @@ ElectronBrowserClient::CreateLoginDelegate(
     bool first_auth_attempt,
     LoginAuthRequiredCallback auth_required_callback) {
   return std::make_unique<LoginHandler>(
-      auth_info, web_contents, is_main_frame, url, response_headers,
-      first_auth_attempt, std::move(auth_required_callback));
+      auth_info, web_contents, is_main_frame, base::kNullProcessId, url,
+      response_headers, first_auth_attempt, std::move(auth_required_callback));
 }
 
 std::vector<std::unique_ptr<blink::URLLoaderThrottle>>

+ 27 - 13
shell/browser/login_handler.cc

@@ -9,6 +9,7 @@
 #include "base/task/sequenced_task_runner.h"
 #include "gin/arguments.h"
 #include "gin/dictionary.h"
+#include "shell/browser/api/electron_api_app.h"
 #include "shell/browser/api/electron_api_web_contents.h"
 #include "shell/browser/javascript_environment.h"
 #include "shell/common/gin_converters/callback_converter.h"
@@ -24,39 +25,44 @@ LoginHandler::LoginHandler(
     const net::AuthChallengeInfo& auth_info,
     content::WebContents* web_contents,
     bool is_main_frame,
+    base::ProcessId process_id,
     const GURL& url,
     scoped_refptr<net::HttpResponseHeaders> response_headers,
     bool first_auth_attempt,
     LoginAuthRequiredCallback auth_required_callback)
-
-    : WebContentsObserver(web_contents),
-      auth_required_callback_(std::move(auth_required_callback)) {
+    : auth_required_callback_(std::move(auth_required_callback)) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
 
   base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
       FROM_HERE,
       base::BindOnce(&LoginHandler::EmitEvent, weak_factory_.GetWeakPtr(),
-                     auth_info, is_main_frame, url, response_headers,
-                     first_auth_attempt));
+                     auth_info, web_contents, is_main_frame, process_id, url,
+                     response_headers, first_auth_attempt));
 }
 
 void LoginHandler::EmitEvent(
     net::AuthChallengeInfo auth_info,
+    content::WebContents* web_contents,
     bool is_main_frame,
+    base::ProcessId process_id,
     const GURL& url,
     scoped_refptr<net::HttpResponseHeaders> response_headers,
     bool first_auth_attempt) {
   v8::Isolate* isolate = JavascriptEnvironment::GetIsolate();
   v8::HandleScope scope(isolate);
 
-  api::WebContents* api_web_contents = api::WebContents::From(web_contents());
-  if (!api_web_contents) {
-    std::move(auth_required_callback_).Run(std::nullopt);
-    return;
+  raw_ptr<api::WebContents> api_web_contents = nullptr;
+  if (web_contents) {
+    api_web_contents = api::WebContents::From(web_contents);
+    if (!api_web_contents) {
+      std::move(auth_required_callback_).Run(std::nullopt);
+      return;
+    }
   }
 
   auto details = gin::Dictionary::CreateEmpty(isolate);
   details.Set("url", url);
+  details.Set("pid", process_id);
 
   // These parameters aren't documented, and I'm not sure that they're useful,
   // but we might as well stick 'em on the details object. If it turns out they
@@ -66,10 +72,18 @@ void LoginHandler::EmitEvent(
   details.Set("responseHeaders", response_headers.get());
 
   auto weak_this = weak_factory_.GetWeakPtr();
-  bool default_prevented =
-      api_web_contents->Emit("login", std::move(details), auth_info,
-                             base::BindOnce(&LoginHandler::CallbackFromJS,
-                                            weak_factory_.GetWeakPtr()));
+  bool default_prevented = false;
+  if (api_web_contents) {
+    default_prevented =
+        api_web_contents->Emit("login", std::move(details), auth_info,
+                               base::BindOnce(&LoginHandler::CallbackFromJS,
+                                              weak_factory_.GetWeakPtr()));
+  } else {
+    default_prevented =
+        api::App::Get()->Emit("login", nullptr, std::move(details), auth_info,
+                              base::BindOnce(&LoginHandler::CallbackFromJS,
+                                             weak_factory_.GetWeakPtr()));
+  }
   // ⚠️ NB, if CallbackFromJS is called during Emit(), |this| will have been
   // deleted. Check the weak ptr before accessing any member variables to
   // prevent UAF.

+ 5 - 3
shell/browser/login_handler.h

@@ -5,9 +5,9 @@
 #ifndef ELECTRON_SHELL_BROWSER_LOGIN_HANDLER_H_
 #define ELECTRON_SHELL_BROWSER_LOGIN_HANDLER_H_
 
+#include "base/process/process_handle.h"
 #include "content/public/browser/content_browser_client.h"
 #include "content/public/browser/login_delegate.h"
-#include "content/public/browser/web_contents_observer.h"
 
 namespace content {
 class WebContents;
@@ -20,12 +20,12 @@ class Arguments;
 namespace electron {
 
 // Handles HTTP basic auth.
-class LoginHandler : public content::LoginDelegate,
-                     private content::WebContentsObserver {
+class LoginHandler : public content::LoginDelegate {
  public:
   LoginHandler(const net::AuthChallengeInfo& auth_info,
                content::WebContents* web_contents,
                bool is_main_frame,
+               base::ProcessId process_id,
                const GURL& url,
                scoped_refptr<net::HttpResponseHeaders> response_headers,
                bool first_auth_attempt,
@@ -38,7 +38,9 @@ class LoginHandler : public content::LoginDelegate,
 
  private:
   void EmitEvent(net::AuthChallengeInfo auth_info,
+                 content::WebContents* web_contents,
                  bool is_main_frame,
+                 base::ProcessId process_id,
                  const GURL& url,
                  scoped_refptr<net::HttpResponseHeaders> response_headers,
                  bool first_auth_attempt);

+ 120 - 0
shell/browser/net/url_loader_network_observer.cc

@@ -0,0 +1,120 @@
+// Copyright (c) 2024 Microsoft, GmbH
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#include "shell/browser/net/url_loader_network_observer.h"
+
+#include "base/functional/bind.h"
+#include "content/public/browser/browser_thread.h"
+#include "shell/browser/login_handler.h"
+
+namespace electron {
+
+namespace {
+
+class LoginHandlerDelegate {
+ public:
+  LoginHandlerDelegate(
+      mojo::PendingRemote<network::mojom::AuthChallengeResponder>
+          auth_challenge_responder,
+      const net::AuthChallengeInfo& auth_info,
+      const GURL& url,
+      scoped_refptr<net::HttpResponseHeaders> response_headers,
+      base::ProcessId process_id,
+      bool first_auth_attempt)
+      : auth_challenge_responder_(std::move(auth_challenge_responder)) {
+    DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
+    auth_challenge_responder_.set_disconnect_handler(base::BindOnce(
+        &LoginHandlerDelegate::OnRequestCancelled, base::Unretained(this)));
+
+    login_handler_ = std::make_unique<LoginHandler>(
+        auth_info, nullptr, false, process_id, url, response_headers,
+        first_auth_attempt,
+        base::BindOnce(&LoginHandlerDelegate::OnAuthCredentials,
+                       weak_factory_.GetWeakPtr()));
+  }
+
+ private:
+  void OnRequestCancelled() {
+    DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
+    delete this;
+  }
+
+  void OnAuthCredentials(
+      const std::optional<net::AuthCredentials>& auth_credentials) {
+    DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
+    auth_challenge_responder_->OnAuthCredentials(auth_credentials);
+    delete this;
+  }
+
+  mojo::Remote<network::mojom::AuthChallengeResponder>
+      auth_challenge_responder_;
+  std::unique_ptr<LoginHandler> login_handler_;
+  base::WeakPtrFactory<LoginHandlerDelegate> weak_factory_{this};
+};
+
+}  // namespace
+
+URLLoaderNetworkObserver::URLLoaderNetworkObserver() = default;
+URLLoaderNetworkObserver::~URLLoaderNetworkObserver() = default;
+
+mojo::PendingRemote<network::mojom::URLLoaderNetworkServiceObserver>
+URLLoaderNetworkObserver::Bind() {
+  mojo::PendingRemote<network::mojom::URLLoaderNetworkServiceObserver>
+      pending_remote;
+  receivers_.Add(this, pending_remote.InitWithNewPipeAndPassReceiver());
+  return pending_remote;
+}
+
+void URLLoaderNetworkObserver::OnAuthRequired(
+    const std::optional<base::UnguessableToken>& window_id,
+    int32_t request_id,
+    const GURL& url,
+    bool first_auth_attempt,
+    const net::AuthChallengeInfo& auth_info,
+    const scoped_refptr<net::HttpResponseHeaders>& head_headers,
+    mojo::PendingRemote<network::mojom::AuthChallengeResponder>
+        auth_challenge_responder) {
+  new LoginHandlerDelegate(std::move(auth_challenge_responder), auth_info, url,
+                           head_headers, process_id_, first_auth_attempt);
+}
+
+void URLLoaderNetworkObserver::OnSSLCertificateError(
+    const GURL& url,
+    int net_error,
+    const net::SSLInfo& ssl_info,
+    bool fatal,
+    OnSSLCertificateErrorCallback response) {
+  std::move(response).Run(net_error);
+}
+
+void URLLoaderNetworkObserver::OnClearSiteData(
+    const GURL& url,
+    const std::string& header_value,
+    int32_t load_flags,
+    const std::optional<net::CookiePartitionKey>& cookie_partition_key,
+    bool partitioned_state_allowed_only,
+    OnClearSiteDataCallback callback) {
+  std::move(callback).Run();
+}
+
+void URLLoaderNetworkObserver::OnLoadingStateUpdate(
+    network::mojom::LoadInfoPtr info,
+    OnLoadingStateUpdateCallback callback) {
+  std::move(callback).Run();
+}
+
+void URLLoaderNetworkObserver::OnSharedStorageHeaderReceived(
+    const url::Origin& request_origin,
+    std::vector<network::mojom::SharedStorageOperationPtr> operations,
+    OnSharedStorageHeaderReceivedCallback callback) {
+  std::move(callback).Run();
+}
+
+void URLLoaderNetworkObserver::Clone(
+    mojo::PendingReceiver<network::mojom::URLLoaderNetworkServiceObserver>
+        observer) {
+  receivers_.Add(this, std::move(observer));
+}
+
+}  // namespace electron

+ 82 - 0
shell/browser/net/url_loader_network_observer.h

@@ -0,0 +1,82 @@
+// Copyright (c) 2024 Microsoft, GmbH
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#ifndef ELECTRON_SHELL_BROWSER_NET_URL_LOADER_NETWORK_OBSERVER_H_
+#define ELECTRON_SHELL_BROWSER_NET_URL_LOADER_NETWORK_OBSERVER_H_
+
+#include "base/memory/weak_ptr.h"
+#include "base/process/process_handle.h"
+#include "mojo/public/cpp/bindings/receiver_set.h"
+#include "services/network/public/mojom/url_loader_network_service_observer.mojom.h"
+
+namespace electron {
+
+class URLLoaderNetworkObserver
+    : public network::mojom::URLLoaderNetworkServiceObserver {
+ public:
+  URLLoaderNetworkObserver();
+  ~URLLoaderNetworkObserver() override;
+
+  URLLoaderNetworkObserver(const URLLoaderNetworkObserver&) = delete;
+  URLLoaderNetworkObserver& operator=(const URLLoaderNetworkObserver&) = delete;
+
+  mojo::PendingRemote<network::mojom::URLLoaderNetworkServiceObserver> Bind();
+  void set_process_id(base::ProcessId pid) { process_id_ = pid; }
+
+ private:
+  void OnAuthRequired(
+      const std::optional<base::UnguessableToken>& window_id,
+      int32_t request_id,
+      const GURL& url,
+      bool first_auth_attempt,
+      const net::AuthChallengeInfo& auth_info,
+      const scoped_refptr<net::HttpResponseHeaders>& head_headers,
+      mojo::PendingRemote<network::mojom::AuthChallengeResponder>
+          auth_challenge_responder) override;
+  void OnSSLCertificateError(const GURL& url,
+                             int net_error,
+                             const net::SSLInfo& ssl_info,
+                             bool fatal,
+                             OnSSLCertificateErrorCallback response) override;
+  void OnClearSiteData(
+      const GURL& url,
+      const std::string& header_value,
+      int32_t load_flags,
+      const std::optional<net::CookiePartitionKey>& cookie_partition_key,
+      bool partitioned_state_allowed_only,
+      OnClearSiteDataCallback callback) override;
+  void OnLoadingStateUpdate(network::mojom::LoadInfoPtr info,
+                            OnLoadingStateUpdateCallback callback) override;
+  void OnSharedStorageHeaderReceived(
+      const url::Origin& request_origin,
+      std::vector<network::mojom::SharedStorageOperationPtr> operations,
+      OnSharedStorageHeaderReceivedCallback callback) override;
+  void OnDataUseUpdate(int32_t network_traffic_annotation_id_hash,
+                       int64_t recv_bytes,
+                       int64_t sent_bytes) override {}
+  void OnWebSocketConnectedToPrivateNetwork(
+      network::mojom::IPAddressSpace ip_address_space) override {}
+  void OnCertificateRequested(
+      const std::optional<base::UnguessableToken>& window_id,
+      const scoped_refptr<net::SSLCertRequestInfo>& cert_info,
+      mojo::PendingRemote<network::mojom::ClientCertificateResponder>
+          client_cert_responder) override {}
+  void OnPrivateNetworkAccessPermissionRequired(
+      const GURL& url,
+      const net::IPAddress& ip_address,
+      const std::optional<std::string>& private_network_device_id,
+      const std::optional<std::string>& private_network_device_name,
+      OnPrivateNetworkAccessPermissionRequiredCallback callback) override {}
+  void Clone(
+      mojo::PendingReceiver<network::mojom::URLLoaderNetworkServiceObserver>
+          observer) override;
+
+  mojo::ReceiverSet<network::mojom::URLLoaderNetworkServiceObserver> receivers_;
+  base::ProcessId process_id_ = base::kNullProcessId;
+  base::WeakPtrFactory<URLLoaderNetworkObserver> weak_factory_{this};
+};
+
+}  // namespace electron
+
+#endif  // ELECTRON_SHELL_BROWSER_NET_URL_LOADER_NETWORK_OBSERVER_H_

+ 15 - 7
shell/common/api/electron_api_url_loader.cc

@@ -335,13 +335,21 @@ SimpleURLLoaderWrapper::SimpleURLLoaderWrapper(
   DETACH_FROM_SEQUENCE(sequence_checker_);
   if (!request_->trusted_params)
     request_->trusted_params = network::ResourceRequest::TrustedParams();
-  mojo::PendingRemote<network::mojom::URLLoaderNetworkServiceObserver>
-      url_loader_network_observer_remote;
-  url_loader_network_observer_receivers_.Add(
-      this,
-      url_loader_network_observer_remote.InitWithNewPipeAndPassReceiver());
-  request_->trusted_params->url_loader_network_observer =
-      std::move(url_loader_network_observer_remote);
+  bool create_network_observer = true;
+  if (electron::IsUtilityProcess()) {
+    create_network_observer =
+        !URLLoaderBundle::GetInstance()
+             ->ShouldUseNetworkObserverfromURLLoaderFactory();
+  }
+  if (create_network_observer) {
+    mojo::PendingRemote<network::mojom::URLLoaderNetworkServiceObserver>
+        url_loader_network_observer_remote;
+    url_loader_network_observer_receivers_.Add(
+        this,
+        url_loader_network_observer_remote.InitWithNewPipeAndPassReceiver());
+    request_->trusted_params->url_loader_network_observer =
+        std::move(url_loader_network_observer_remote);
+  }
   // Chromium filters headers using browser rules, while for net module we have
   // every header passed. The following setting will allow us to capture the
   // raw headers in the URLLoader.

+ 10 - 2
shell/services/node/node_service.cc

@@ -33,11 +33,14 @@ URLLoaderBundle* URLLoaderBundle::GetInstance() {
 
 void URLLoaderBundle::SetURLLoaderFactory(
     mojo::PendingRemote<network::mojom::URLLoaderFactory> pending_factory,
-    mojo::Remote<network::mojom::HostResolver> host_resolver) {
+    mojo::Remote<network::mojom::HostResolver> host_resolver,
+    bool use_network_observer_from_url_loader_factory) {
   factory_ = network::SharedURLLoaderFactory::Create(
       std::make_unique<network::WrapperPendingSharedURLLoaderFactory>(
           std::move(pending_factory)));
   host_resolver_ = std::move(host_resolver);
+  should_use_network_observer_from_url_loader_factory_ =
+      use_network_observer_from_url_loader_factory;
 }
 
 scoped_refptr<network::SharedURLLoaderFactory>
@@ -50,6 +53,10 @@ network::mojom::HostResolver* URLLoaderBundle::GetHostResolver() {
   return host_resolver_.get();
 }
 
+bool URLLoaderBundle::ShouldUseNetworkObserverfromURLLoaderFactory() const {
+  return should_use_network_observer_from_url_loader_factory_;
+}
+
 NodeService::NodeService(
     mojo::PendingReceiver<node::mojom::NodeService> receiver)
     : node_bindings_{NodeBindings::Create(
@@ -76,7 +83,8 @@ void NodeService::Initialize(node::mojom::NodeServiceParamsPtr params) {
 
   URLLoaderBundle::GetInstance()->SetURLLoaderFactory(
       std::move(params->url_loader_factory),
-      mojo::Remote(std::move(params->host_resolver)));
+      mojo::Remote(std::move(params->host_resolver)),
+      params->use_network_observer_from_url_loader_factory);
 
   js_env_ = std::make_unique<JavascriptEnvironment>(node_bindings_->uv_loop());
 

+ 4 - 1
shell/services/node/node_service.h

@@ -39,13 +39,16 @@ class URLLoaderBundle {
   static URLLoaderBundle* GetInstance();
   void SetURLLoaderFactory(
       mojo::PendingRemote<network::mojom::URLLoaderFactory> factory,
-      mojo::Remote<network::mojom::HostResolver> host_resolver);
+      mojo::Remote<network::mojom::HostResolver> host_resolver,
+      bool use_network_observer_from_url_loader_factory);
   scoped_refptr<network::SharedURLLoaderFactory> GetSharedURLLoaderFactory();
   network::mojom::HostResolver* GetHostResolver();
+  bool ShouldUseNetworkObserverfromURLLoaderFactory() const;
 
  private:
   scoped_refptr<network::SharedURLLoaderFactory> factory_;
   mojo::Remote<network::mojom::HostResolver> host_resolver_;
+  bool should_use_network_observer_from_url_loader_factory_ = false;
 };
 
 class NodeService : public node::mojom::NodeService {

+ 1 - 0
shell/services/node/public/mojom/node_service.mojom

@@ -17,6 +17,7 @@ struct NodeServiceParams {
   blink.mojom.MessagePortDescriptor port;
   pending_remote<network.mojom.URLLoaderFactory> url_loader_factory;
   pending_remote<network.mojom.HostResolver> host_resolver;
+  bool use_network_observer_from_url_loader_factory = false;
 };
 
 [ServiceSandbox=sandbox.mojom.Sandbox.kNoSandbox]

+ 190 - 1
spec/api-utility-process-spec.ts

@@ -2,8 +2,9 @@ import { expect } from 'chai';
 import * as childProcess from 'node:child_process';
 import * as path from 'node:path';
 import { BrowserWindow, MessageChannelMain, utilityProcess, app } from 'electron/main';
-import { ifit } from './lib/spec-helpers';
+import { ifit, startRemoteControlApp } from './lib/spec-helpers';
 import { closeWindow } from './lib/window-helpers';
+import { respondOnce, randomString, kOneKiloByte } from './lib/net-helpers';
 import { once } from 'node:events';
 import { pathToFileURL } from 'node:url';
 import { setImmediate } from 'node:timers/promises';
@@ -508,5 +509,193 @@ describe('utilityProcess module', () => {
       expect(child.kill()).to.be.true();
       await exit;
     });
+
+    it('should emit the app#login event when 401', async () => {
+      const { remotely } = await startRemoteControlApp();
+      const serverUrl = await respondOnce.toSingleURL((request, response) => {
+        if (!request.headers.authorization) {
+          return response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' }).end();
+        }
+        response.writeHead(200).end('ok');
+      });
+      const [loginAuthInfo, statusCode] = await remotely(async (serverUrl: string, fixture: string) => {
+        const { app, utilityProcess } = require('electron');
+        const { once } = require('node:events');
+        const child = utilityProcess.fork(fixture, [`--server-url=${serverUrl}`], {
+          stdio: 'ignore',
+          respondToAuthRequestsFromMainProcess: true
+        });
+        await once(child, 'spawn');
+        const [ev,,, authInfo, cb] = await once(app, 'login');
+        ev.preventDefault();
+        cb('dummy', 'pass');
+        const [result] = await once(child, 'message');
+        return [authInfo, ...result];
+      }, serverUrl, path.join(fixturesPath, 'net.js'));
+      expect(statusCode).to.equal(200);
+      expect(loginAuthInfo!.realm).to.equal('Foo');
+      expect(loginAuthInfo!.scheme).to.equal('basic');
+    });
+
+    it('should receive 401 response when cancelling authentication via app#login event', async () => {
+      const { remotely } = await startRemoteControlApp();
+      const serverUrl = await respondOnce.toSingleURL((request, response) => {
+        if (!request.headers.authorization) {
+          response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' });
+          response.end('unauthenticated');
+        } else {
+          response.writeHead(200).end('ok');
+        }
+      });
+      const [authDetails, responseBody, statusCode] = await remotely(async (serverUrl: string, fixture: string) => {
+        const { app, utilityProcess } = require('electron');
+        const { once } = require('node:events');
+        const child = utilityProcess.fork(fixture, [`--server-url=${serverUrl}`], {
+          stdio: 'ignore',
+          respondToAuthRequestsFromMainProcess: true
+        });
+        await once(child, 'spawn');
+        const [,, details,, cb] = await once(app, 'login');
+        cb();
+        const [response] = await once(child, 'message');
+        const [responseBody] = await once(child, 'message');
+        return [details, responseBody, ...response];
+      }, serverUrl, path.join(fixturesPath, 'net.js'));
+      expect(authDetails.url).to.equal(serverUrl);
+      expect(statusCode).to.equal(401);
+      expect(responseBody).to.equal('unauthenticated');
+    });
+
+    it('should upload body when 401', async () => {
+      const { remotely } = await startRemoteControlApp();
+      const serverUrl = await respondOnce.toSingleURL((request, response) => {
+        if (!request.headers.authorization) {
+          return response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' }).end();
+        }
+        response.writeHead(200);
+        request.on('data', (chunk) => response.write(chunk));
+        request.on('end', () => response.end());
+      });
+      const requestData = randomString(kOneKiloByte);
+      const [authDetails, responseBody, statusCode] = await remotely(async (serverUrl: string, requestData: string, fixture: string) => {
+        const { app, utilityProcess } = require('electron');
+        const { once } = require('node:events');
+        const child = utilityProcess.fork(fixture, [`--server-url=${serverUrl}`, '--request-data'], {
+          stdio: 'ignore',
+          respondToAuthRequestsFromMainProcess: true
+        });
+        await once(child, 'spawn');
+        await once(child, 'message');
+        child.postMessage(requestData);
+        const [,, details,, cb] = await once(app, 'login');
+        cb('user', 'pass');
+        const [response] = await once(child, 'message');
+        const [responseBody] = await once(child, 'message');
+        return [details, responseBody, ...response];
+      }, serverUrl, requestData, path.join(fixturesPath, 'net.js'));
+      expect(authDetails.url).to.equal(serverUrl);
+      expect(statusCode).to.equal(200);
+      expect(responseBody).to.equal(requestData);
+    });
+
+    it('should not emit the app#login event when 401 with {"credentials":"omit"}', async () => {
+      const rc = await startRemoteControlApp();
+      const serverUrl = await respondOnce.toSingleURL((request, response) => {
+        if (!request.headers.authorization) {
+          return response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' }).end();
+        }
+        response.writeHead(200).end('ok');
+      });
+      const [statusCode, responseHeaders] = await rc.remotely(async (serverUrl: string, fixture: string) => {
+        const { app, utilityProcess } = require('electron');
+        const { once } = require('node:events');
+        let gracefulExit = true;
+        const child = utilityProcess.fork(fixture, [`--server-url=${serverUrl}`, '--omit-credentials'], {
+          stdio: 'ignore',
+          respondToAuthRequestsFromMainProcess: true
+        });
+        await once(child, 'spawn');
+        app.on('login', () => {
+          gracefulExit = false;
+        });
+        const [result] = await once(child, 'message');
+        setTimeout(() => {
+          if (gracefulExit) {
+            app.quit();
+          } else {
+            process.exit(1);
+          }
+        });
+        return result;
+      }, serverUrl, path.join(fixturesPath, 'net.js'));
+      const [code] = await once(rc.process, 'exit');
+      expect(code).to.equal(0);
+      expect(statusCode).to.equal(401);
+      expect(responseHeaders['www-authenticate']).to.equal('Basic realm="Foo"');
+    });
+
+    it('should not emit the app#login event with default respondToAuthRequestsFromMainProcess', async () => {
+      const rc = await startRemoteControlApp();
+      const serverUrl = await respondOnce.toSingleURL((request, response) => {
+        if (!request.headers.authorization) {
+          return response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' }).end();
+        }
+        response.writeHead(200).end('ok');
+      });
+      const [loginAuthInfo, statusCode] = await rc.remotely(async (serverUrl: string, fixture: string) => {
+        const { app, utilityProcess } = require('electron');
+        const { once } = require('node:events');
+        let gracefulExit = true;
+        const child = utilityProcess.fork(fixture, [`--server-url=${serverUrl}`, '--use-net-login-event'], {
+          stdio: 'ignore'
+        });
+        await once(child, 'spawn');
+        app.on('login', () => {
+          gracefulExit = false;
+        });
+        const [authInfo] = await once(child, 'message');
+        const [result] = await once(child, 'message');
+        setTimeout(() => {
+          if (gracefulExit) {
+            app.quit();
+          } else {
+            process.exit(1);
+          }
+        });
+        return [authInfo, ...result];
+      }, serverUrl, path.join(fixturesPath, 'net.js'));
+      const [code] = await once(rc.process, 'exit');
+      expect(code).to.equal(0);
+      expect(statusCode).to.equal(200);
+      expect(loginAuthInfo!.realm).to.equal('Foo');
+      expect(loginAuthInfo!.scheme).to.equal('basic');
+    });
+
+    it('should emit the app#login event when creating requests with fetch API', async () => {
+      const { remotely } = await startRemoteControlApp();
+      const serverUrl = await respondOnce.toSingleURL((request, response) => {
+        if (!request.headers.authorization) {
+          return response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' }).end();
+        }
+        response.writeHead(200).end('ok');
+      });
+      const [loginAuthInfo, statusCode] = await remotely(async (serverUrl: string, fixture: string) => {
+        const { app, utilityProcess } = require('electron');
+        const { once } = require('node:events');
+        const child = utilityProcess.fork(fixture, [`--server-url=${serverUrl}`, '--use-fetch-api'], {
+          stdio: 'ignore',
+          respondToAuthRequestsFromMainProcess: true
+        });
+        await once(child, 'spawn');
+        const [ev,,, authInfo, cb] = await once(app, 'login');
+        ev.preventDefault();
+        cb('dummy', 'pass');
+        const [response] = await once(child, 'message');
+        return [authInfo, ...response];
+      }, serverUrl, path.join(fixturesPath, 'net.js'));
+      expect(statusCode).to.equal(200);
+      expect(loginAuthInfo!.realm).to.equal('Foo');
+      expect(loginAuthInfo!.scheme).to.equal('basic');
+    });
   });
 });

+ 44 - 0
spec/fixtures/api/utility-process/net.js

@@ -0,0 +1,44 @@
+const { net } = require('electron');
+const serverUrl = process.argv[2].split('=')[1];
+let configurableArg = null;
+if (process.argv[3]) {
+  configurableArg = process.argv[3].split('=')[0];
+}
+const data = [];
+
+let request = null;
+if (configurableArg === '--omit-credentials') {
+  request = net.request({ method: 'GET', url: serverUrl, credentials: 'omit' });
+} else if (configurableArg === '--use-fetch-api') {
+  net.fetch(serverUrl).then((response) => {
+    process.parentPort.postMessage([response.status, response.headers]);
+  });
+} else {
+  request = net.request({ method: 'GET', url: serverUrl });
+}
+
+if (request) {
+  if (configurableArg === '--use-net-login-event') {
+    request.on('login', (authInfo, cb) => {
+      process.parentPort.postMessage(authInfo);
+      cb('user', 'pass');
+    });
+  }
+  request.on('response', (response) => {
+    process.parentPort.postMessage([response.statusCode, response.headers]);
+    response.on('data', (chunk) => data.push(chunk));
+    response.on('end', (chunk) => {
+      if (chunk) data.push(chunk);
+      process.parentPort.postMessage(Buffer.concat(data).toString());
+    });
+  });
+  if (configurableArg === '--request-data') {
+    process.parentPort.on('message', (e) => {
+      request.write(e.data);
+      request.end();
+    });
+    process.parentPort.postMessage('get-request-data');
+  } else {
+    request.end();
+  }
+}