Skip to content

Commit 93e501b

Browse files
committed
fix: implement proxy authentication handling for browser tabs
1 parent cb241c8 commit 93e501b

File tree

2 files changed

+134
-5
lines changed

2 files changed

+134
-5
lines changed

pydoll/browser/chromium/base.py

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from random import randint
1010
from tempfile import TemporaryDirectory
1111
from typing import Any, Awaitable, Callable, Optional, overload
12+
from urllib.parse import urlsplit, urlunsplit
1213

1314
from pydoll.browser.interfaces import BrowserOptionsManager
1415
from pydoll.browser.managers import (
@@ -93,6 +94,7 @@ def __init__(
9394
self._connection_handler = ConnectionHandler(self._connection_port)
9495
self._backup_preferences_dir = ''
9596
self._tabs_opened: dict[str, Tab] = {}
97+
self._context_proxy_auth: dict[str, tuple[str, str]] = {}
9698

9799
async def __aenter__(self) -> 'Browser':
98100
"""Async context manager entry."""
@@ -203,13 +205,22 @@ async def create_browser_context(
203205
Returns:
204206
Browser context ID for use with other methods.
205207
"""
208+
# If proxy_server contains credentials, strip them and store per-context auth
209+
sanitized_proxy = proxy_server
210+
extracted_auth: Optional[tuple[str, str]] = None
211+
if proxy_server:
212+
sanitized_proxy, extracted_auth = self._sanitize_proxy_and_extract_auth(proxy_server)
213+
206214
response: CreateBrowserContextResponse = await self._execute_command(
207215
TargetCommands.create_browser_context(
208-
proxy_server=proxy_server,
216+
proxy_server=sanitized_proxy,
209217
proxy_bypass_list=proxy_bypass_list,
210218
)
211219
)
212-
return response['result']['browserContextId']
220+
context_id = response['result']['browserContextId']
221+
if extracted_auth:
222+
self._context_proxy_auth[context_id] = extracted_auth
223+
return context_id
213224

214225
async def delete_browser_context(self, browser_context_id: str):
215226
"""
@@ -251,8 +262,8 @@ async def new_tab(self, url: str = '', browser_context_id: Optional[str] = None)
251262
target_id = response['result']['targetId']
252263
tab = Tab(self, **self._get_tab_kwargs(target_id, browser_context_id))
253264
self._tabs_opened[target_id] = tab
254-
if url:
255-
await tab.go_to(url)
265+
await self._setup_context_proxy_auth_for_tab(tab, browser_context_id)
266+
if url: await tab.go_to(url)
256267
return tab
257268

258269
async def get_targets(self) -> list[TargetInfo]:
@@ -577,6 +588,60 @@ async def _continue_request_with_auth_callback(
577588
await self.disable_fetch_events()
578589
return response
579590

591+
@staticmethod
592+
async def _tab_continue_request_callback(event: RequestPausedEvent, tab: Tab):
593+
"""Internal callback to continue paused requests at Tab level."""
594+
request_id = event['params']['requestId']
595+
return await tab.continue_request(request_id)
596+
597+
@staticmethod
598+
async def _tab_continue_request_with_auth_callback(
599+
event: RequestPausedEvent,
600+
tab: Tab,
601+
proxy_username: Optional[str],
602+
proxy_password: Optional[str],
603+
):
604+
"""Internal callback for proxy/server authentication at Tab level."""
605+
request_id = event['params']['requestId']
606+
response: Response = await tab.continue_with_auth(
607+
request_id=request_id,
608+
auth_challenge_response=AuthChallengeResponseType.PROVIDE_CREDENTIALS,
609+
proxy_username=proxy_username,
610+
proxy_password=proxy_password,
611+
)
612+
await tab.disable_fetch_events()
613+
return response
614+
615+
async def _setup_context_proxy_auth_for_tab(
616+
self, tab: Tab, browser_context_id: Optional[str]
617+
) -> None:
618+
"""Enable proxy auth handling for a Tab if its context has credentials stored."""
619+
if not browser_context_id:
620+
return
621+
creds = self._context_proxy_auth.get(browser_context_id)
622+
if not creds:
623+
return
624+
username, password = creds
625+
await tab.enable_fetch_events(handle_auth=True)
626+
await tab.on(
627+
FetchEvent.REQUEST_PAUSED,
628+
partial(
629+
self._tab_continue_request_callback,
630+
tab=tab,
631+
),
632+
temporary=True,
633+
)
634+
await tab.on(
635+
FetchEvent.AUTH_REQUIRED,
636+
partial(
637+
self._tab_continue_request_with_auth_callback,
638+
tab=tab,
639+
proxy_username=username,
640+
proxy_password=password,
641+
),
642+
temporary=True,
643+
)
644+
580645
async def _verify_browser_running(self):
581646
"""
582647
Verify browser started successfully.
@@ -763,6 +828,49 @@ def _get_tab_ws_address(self, tab_id: str) -> str:
763828
ws_domain = '/'.join(self._ws_address.split('/')[:3])
764829
return f'{ws_domain}/devtools/page/{tab_id}'
765830

831+
@staticmethod
832+
def _sanitize_proxy_and_extract_auth(
833+
proxy_server: str,
834+
) -> tuple[str, Optional[tuple[str, str]]]:
835+
"""Strip credentials from a proxy URL and return sanitized URL plus (user, pass).
836+
837+
Accepts inputs like:
838+
- username:password@host:port
839+
- http://username:password@host:port
840+
- socks5://username:password@host:port
841+
- host:port (no credentials)
842+
Returns a (sanitized_proxy, (user, pass) | None).
843+
Ensures scheme is present in the sanitized URL (defaults to http).
844+
"""
845+
base = proxy_server if '://' in proxy_server else f'http://{proxy_server}'
846+
parts = urlsplit(base)
847+
netloc = parts.netloc
848+
creds: Optional[tuple[str, str]] = None
849+
if '@' in netloc:
850+
cred_part, host_part = netloc.split('@', 1)
851+
if ':' in cred_part:
852+
user, pwd = cred_part.split(':', 1)
853+
else:
854+
user, pwd = cred_part, ''
855+
creds = (user, pwd)
856+
sanitized = urlunsplit((
857+
parts.scheme,
858+
host_part,
859+
parts.path,
860+
parts.query,
861+
parts.fragment,
862+
))
863+
else:
864+
# No creds; ensure scheme
865+
sanitized = urlunsplit((
866+
parts.scheme,
867+
parts.netloc,
868+
parts.path,
869+
parts.query,
870+
parts.fragment,
871+
))
872+
return sanitized, creds
873+
766874
@abstractmethod
767875
def _get_default_binary_location(self) -> str:
768876
"""Get default browser executable path (implemented by subclasses)."""

pydoll/browser/tab.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
DownloadWillBeginEvent,
5757
)
5858
from pydoll.protocol.browser.types import DownloadBehavior, DownloadProgressState
59-
from pydoll.protocol.fetch.types import HeaderEntry, RequestStage
59+
from pydoll.protocol.fetch.types import AuthChallengeResponseType, HeaderEntry, RequestStage
6060
from pydoll.protocol.network.events import RequestWillBeSentEvent
6161
from pydoll.protocol.network.types import (
6262
Cookie,
@@ -709,6 +709,27 @@ async def fulfill_request(
709709
)
710710
)
711711

712+
async def continue_with_auth(
713+
self,
714+
request_id: str,
715+
auth_challenge_response: AuthChallengeResponseType,
716+
proxy_username: Optional[str] = None,
717+
proxy_password: Optional[str] = None,
718+
):
719+
"""Continue a paused request replying to an authentication challenge.
720+
721+
Useful for proxy auth (407) or server auth (401) when Fetch is enabled
722+
with handle_auth=True.
723+
"""
724+
return await self._execute_command(
725+
FetchCommands.continue_request_with_auth(
726+
request_id=request_id,
727+
auth_challenge_response=auth_challenge_response,
728+
proxy_username=proxy_username,
729+
proxy_password=proxy_password,
730+
)
731+
)
732+
712733
@asynccontextmanager
713734
async def expect_file_chooser(
714735
self, files: Union[str, Path, list[Union[str, Path]]]

0 commit comments

Comments
 (0)