diff --git a/CHANGES/12499.deprecation.rst b/CHANGES/12499.deprecation.rst new file mode 100644 index 00000000000..c9a5b533c77 --- /dev/null +++ b/CHANGES/12499.deprecation.rst @@ -0,0 +1,7 @@ +Deprecated :class:`~aiohttp.BasicAuth` and the ``auth`` / ``proxy_auth`` +parameters. They will be removed in aiohttp 4.0. Use the new +:func:`~aiohttp.encode_basic_auth` helper together with +``headers={"Authorization": ...}`` (or +``proxy_headers={"Proxy-Authorization": ...}`` for proxies) instead. +Note that ``encode_basic_auth()`` defaults to `utf-8`, not `latin1` +-- by :user:`Dreamsorcerer`. diff --git a/CHANGES/12499.feature.rst b/CHANGES/12499.feature.rst new file mode 100644 index 00000000000..8b242432367 --- /dev/null +++ b/CHANGES/12499.feature.rst @@ -0,0 +1,3 @@ +Added :func:`~aiohttp.encode_basic_auth` for encoding HTTP Basic +Authentication credentials. Replaces the now-deprecated +:class:`~aiohttp.BasicAuth` -- by :user:`Dreamsorcerer`. diff --git a/aiohttp/__init__.py b/aiohttp/__init__.py index e01204a5adb..59fc64160f1 100644 --- a/aiohttp/__init__.py +++ b/aiohttp/__init__.py @@ -53,7 +53,7 @@ from .connector import AddrInfoType, SocketFactoryType from .cookiejar import CookieJar, DummyCookieJar from .formdata import FormData -from .helpers import BasicAuth, ChainMapProxy, ETag +from .helpers import BasicAuth, ChainMapProxy, ETag, encode_basic_auth from .http import ( HttpVersion, HttpVersion10, @@ -172,6 +172,7 @@ "ChainMapProxy", "DigestAuthMiddleware", "ETag", + "encode_basic_auth", "set_zlib_backend", # http "HttpVersion", diff --git a/aiohttp/client.py b/aiohttp/client.py index 6a9b084a7b3..eb2d5e880d5 100644 --- a/aiohttp/client.py +++ b/aiohttp/client.py @@ -394,6 +394,22 @@ def __init__( if cookies: self._cookie_jar.update_cookies(cookies) + if auth is not None: + warnings.warn( + "The 'auth' parameter is deprecated and will be removed in v4;" + " pass headers={'Authorization': " + "aiohttp.encode_basic_auth(login, password)} instead", + DeprecationWarning, + stacklevel=2, + ) + if proxy_auth is not None: + warnings.warn( + "The 'proxy_auth' parameter is deprecated and will be removed in v4;" + " pass proxy_headers={'Proxy-Authorization': " + "aiohttp.encode_basic_auth(login, password)} instead", + DeprecationWarning, + stacklevel=2, + ) self._connector_owner = connector_owner self._default_auth = auth self._version = version @@ -516,6 +532,23 @@ async def _request( if self.closed: raise RuntimeError("Session is closed") + if auth is not None: + warnings.warn( + "The 'auth' parameter is deprecated and will be removed in v4;" + " pass headers={'Authorization': " + "aiohttp.encode_basic_auth(login, password)} instead", + DeprecationWarning, + stacklevel=3, + ) + if proxy_auth is not None: + warnings.warn( + "The 'proxy_auth' parameter is deprecated and will be removed in v4;" + " pass proxy_headers={'Proxy-Authorization': " + "aiohttp.encode_basic_auth(login, password)} instead", + DeprecationWarning, + stacklevel=3, + ) + if not isinstance(ssl, SSL_ALLOWED_TYPES): raise TypeError( "ssl should be SSLContext, Fingerprint, or bool, " @@ -1065,6 +1098,22 @@ async def _ws_connect( max_msg_size: int = 4 * 1024 * 1024, decode_text: bool = True, ) -> "ClientWebSocketResponse[bool]": + if auth is not None: + warnings.warn( + "The 'auth' parameter is deprecated and will be removed in v4;" + " pass headers={'Authorization': " + "aiohttp.encode_basic_auth(login, password)} instead", + DeprecationWarning, + stacklevel=3, + ) + if proxy_auth is not None: + warnings.warn( + "The 'proxy_auth' parameter is deprecated and will be removed in v4;" + " pass proxy_headers={'Proxy-Authorization': " + "aiohttp.encode_basic_auth(login, password)} instead", + DeprecationWarning, + stacklevel=3, + ) if timeout is not sentinel: if isinstance(timeout, ClientWSTimeout): ws_timeout = timeout diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py index 815f9c2dd61..8c353822b58 100644 --- a/aiohttp/client_reqrep.py +++ b/aiohttp/client_reqrep.py @@ -41,6 +41,7 @@ HeadersDictProxy, HeadersMixin, TimerNoop, + _basic_auth_no_warn, frozen_dataclass_decorator, is_expected_content_type, parse_mimetype, @@ -807,7 +808,7 @@ def _update_host(self, url: URL) -> None: # basic auth info if url.raw_user or url.raw_password: - self.auth = BasicAuth(url.user or "", url.password or "") + self.auth = _basic_auth_no_warn(url.user or "", url.password or "") def _update_headers(self, headers: CIMultiDict[str]) -> None: """Update request headers.""" diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 3e5677a46bd..bb07a8f79a2 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -153,6 +153,18 @@ json_re = re.compile(r"^(?:application/|[\w.-]+/[\w.+-]+?\+)json$", re.IGNORECASE) +def encode_basic_auth(login: str, password: str = "", encoding: str = "utf-8") -> str: + """Encode HTTP Basic Authentication credentials as an Authorization header value. + + Returns a string of the form ``"Basic "`` suitable for use as the + value of the ``Authorization`` (or ``Proxy-Authorization``) header. + """ + if ":" in login: + raise ValueError('A ":" is not allowed in login (RFC 7617#section-2)') + creds = f"{login}:{password}".encode(encoding) + return "Basic " + base64.b64encode(creds).decode(encoding) + + class BasicAuth(namedtuple("BasicAuth", ["login", "password", "encoding"])): """Http basic authentication helper.""" @@ -168,6 +180,13 @@ def __new__( if ":" in login: raise ValueError('A ":" is not allowed in login (RFC 1945#section-11.1)') + warnings.warn( + "BasicAuth is deprecated and will be removed in aiohttp 4.0; " + "use aiohttp.encode_basic_auth() with " + "headers={'Authorization': ...} instead", + DeprecationWarning, + stacklevel=2, + ) return super().__new__(cls, login, password, encoding) @classmethod @@ -197,7 +216,7 @@ def decode(cls, auth_header: str, encoding: str = "latin1") -> "BasicAuth": except ValueError: raise ValueError("Invalid credentials.") - return cls(username, password, encoding=encoding) + return _basic_auth_no_warn(username, password, encoding) @classmethod def from_url(cls, url: URL, *, encoding: str = "latin1") -> Optional["BasicAuth"]: @@ -208,12 +227,22 @@ def from_url(cls, url: URL, *, encoding: str = "latin1") -> Optional["BasicAuth" # to already have these values parsed from the netloc in the cache. if url.raw_user is None and url.raw_password is None: return None - return cls(url.user or "", url.password or "", encoding=encoding) + return _basic_auth_no_warn(url.user or "", url.password or "", encoding) def encode(self) -> str: """Encode credentials.""" - creds = (f"{self.login}:{self.password}").encode(self.encoding) - return "Basic %s" % base64.b64encode(creds).decode(self.encoding) + return encode_basic_auth(self.login, self.password, self.encoding) + + +def _basic_auth_no_warn( + login: str, password: str = "", encoding: str = "latin1" +) -> BasicAuth: + """Construct a BasicAuth without emitting the deprecation warning. + + For internal use only. Bypasses BasicAuth.__new__ so that aiohttp's own + machinery doesn't trigger deprecation warnings in user code. + """ + return tuple.__new__(BasicAuth, (login, password, encoding)) def strip_auth_from_url(url: URL) -> tuple[URL, BasicAuth | None]: @@ -222,7 +251,7 @@ def strip_auth_from_url(url: URL) -> tuple[URL, BasicAuth | None]: # to already have these values parsed from the netloc in the cache. if url.raw_user is None and url.raw_password is None: return url, None - return url.with_user(None), BasicAuth(url.user or "", url.password or "") + return url.with_user(None), _basic_auth_no_warn(url.user or "", url.password or "") def netrc_from_env() -> netrc.netrc | None: @@ -302,7 +331,7 @@ def basicauth_from_netrc(netrc_obj: netrc.netrc | None, host: str) -> BasicAuth: if password is None: password = "" # type: ignore[unreachable] - return BasicAuth(username, password) + return _basic_auth_no_warn(username, password) def proxies_from_env() -> dict[str, ProxyInfo]: diff --git a/docs/client_advanced.rst b/docs/client_advanced.rst index dc0dfb3660e..bfea3295d1a 100644 --- a/docs/client_advanced.rst +++ b/docs/client_advanced.rst @@ -59,14 +59,21 @@ For ``text/plain``:: Authentication -------------- -Instead of setting the ``Authorization`` header directly, -:class:`ClientSession` and individual request methods provide an ``auth`` -argument. An instance of :class:`BasicAuth` can be passed in like this:: +For HTTP Basic Authentication, build the ``Authorization`` header using +:func:`encode_basic_auth` and pass it via ``headers``:: - auth = BasicAuth(login="...", password="...") - async with ClientSession(auth=auth) as session: + from aiohttp import ClientSession, encode_basic_auth + + headers = {"Authorization": encode_basic_auth("user", "pass")} + async with ClientSession(headers=headers) as session: ... +.. deprecated:: 3.14 + + The ``auth`` parameter and the :class:`BasicAuth` class are deprecated and + will be removed in 4.0. Use :func:`encode_basic_auth` together with the + ``headers`` parameter as shown above. + For HTTP digest authentication, use the :class:`DigestAuthMiddleware` client middleware:: from aiohttp import ClientSession, DigestAuthMiddleware @@ -718,11 +725,13 @@ To connect, use the *proxy* parameter:: It also supports proxy authorization:: + from aiohttp import encode_basic_auth + async with aiohttp.ClientSession() as session: - proxy_auth = aiohttp.BasicAuth('user', 'pass') + proxy_headers = {"Proxy-Authorization": encode_basic_auth("user", "pass")} async with session.get("http://python.org", proxy="http://proxy.com", - proxy_auth=proxy_auth) as resp: + proxy_headers=proxy_headers) as resp: print(resp.status) Authentication credentials can be passed in proxy URL:: @@ -732,11 +741,19 @@ Authentication credentials can be passed in proxy URL:: And you may set default proxy:: - proxy_auth = aiohttp.BasicAuth('user', 'pass') - async with aiohttp.ClientSession(proxy="http://proxy.com", proxy_auth=proxy_auth) as session: + proxy_headers = {"Proxy-Authorization": encode_basic_auth("user", "pass")} + async with aiohttp.ClientSession( + proxy="http://proxy.com", proxy_headers=proxy_headers + ) as session: async with session.get("http://python.org") as resp: print(resp.status) +.. deprecated:: 3.14 + + The ``proxy_auth`` parameter is deprecated and will be removed in 4.0. Use + :func:`encode_basic_auth` with ``proxy_headers={"Proxy-Authorization": ...}`` + as shown above. + Contrary to the ``requests`` library, it won't read environment variables by default. But you can do so by passing ``trust_env=True`` into :class:`aiohttp.ClientSession` diff --git a/docs/client_reference.rst b/docs/client_reference.rst index ccbac8b6885..657b463a1a8 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -2326,6 +2326,22 @@ Utilities +.. function:: encode_basic_auth(login, password='', encoding='utf-8') + + Encode HTTP Basic Authentication credentials as a value suitable for the + ``Authorization`` (or ``Proxy-Authorization``) header:: + + headers = {"Authorization": encode_basic_auth("user", "pass")} + + :param str login: login + :param str password: password (``''`` by default) + :param str encoding: encoding (``'utf-8'`` by default) + :return: a string of the form ``"Basic "`` + :rtype: str + + .. versionadded:: 3.14 + + .. class:: BasicAuth(login, password='', encoding='latin1') :canonical: aiohttp.helpers.BasicAuth @@ -2336,9 +2352,17 @@ Utilities :param str encoding: encoding (``'latin1'`` by default) - Should be used for specifying authorization data in client API, + Previously this was used for specifying authorization data in client API, e.g. *auth* parameter for :meth:`ClientSession.request() `. + .. deprecated:: 3.14 + + Constructing :class:`BasicAuth` is deprecated and will be removed in + 4.0. Use :func:`encode_basic_auth` together with the ``headers`` + parameter (or ``proxy_headers`` for proxies) instead. The + :meth:`decode` and :meth:`from_url` class methods remain available for + parsing. + .. classmethod:: decode(auth_header, encoding='latin1') diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index b6efc7e07c9..9c8e274bab3 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -65,6 +65,16 @@ from aiohttp.test_utils import TestClient, TestServer from aiohttp.typedefs import Handler +pytestmark = [ + pytest.mark.filterwarnings(r"ignore:BasicAuth is deprecated:DeprecationWarning"), + pytest.mark.filterwarnings( + r"ignore:The 'auth' parameter is deprecated:DeprecationWarning" + ), + pytest.mark.filterwarnings( + r"ignore:The 'proxy_auth' parameter is deprecated:DeprecationWarning" + ), +] + @pytest.fixture def here() -> pathlib.Path: diff --git a/tests/test_client_request.py b/tests/test_client_request.py index bc72704ff28..08d075bbba4 100644 --- a/tests/test_client_request.py +++ b/tests/test_client_request.py @@ -457,19 +457,17 @@ async def test_ipv6_nondefault_https_port(make_client_request: _RequestMaker) -> async def test_basic_auth(make_client_request: _RequestMaker) -> None: - req = make_client_request( - "get", URL("http://python.org"), auth=aiohttp.BasicAuth("nkim", "1234") - ) + with pytest.warns(DeprecationWarning, match="BasicAuth is deprecated"): + auth = aiohttp.BasicAuth("nkim", "1234") + req = make_client_request("get", URL("http://python.org"), auth=auth) assert "AUTHORIZATION" in req.headers assert "Basic bmtpbToxMjM0" == req.headers["AUTHORIZATION"] async def test_basic_auth_utf8(make_client_request: _RequestMaker) -> None: - req = make_client_request( - "get", - URL("http://python.org"), - auth=aiohttp.BasicAuth("nkim", "секрет", "utf-8"), - ) + with pytest.warns(DeprecationWarning, match="BasicAuth is deprecated"): + auth = aiohttp.BasicAuth("nkim", "секрет", "utf-8") + req = make_client_request("get", URL("http://python.org"), auth=auth) assert "AUTHORIZATION" in req.headers assert "Basic bmtpbTrRgdC10LrRgNC10YI=" == req.headers["AUTHORIZATION"] @@ -496,9 +494,9 @@ async def test_basic_auth_no_user_from_url(make_client_request: _RequestMaker) - async def test_basic_auth_from_url_overridden( make_client_request: _RequestMaker, ) -> None: - req = make_client_request( - "get", URL("http://garbage@python.org"), auth=aiohttp.BasicAuth("nkim", "1234") - ) + with pytest.warns(DeprecationWarning, match="BasicAuth is deprecated"): + auth = aiohttp.BasicAuth("nkim", "1234") + req = make_client_request("get", URL("http://garbage@python.org"), auth=auth) assert "AUTHORIZATION" in req.headers assert "Basic bmtpbToxMjM0" == req.headers["AUTHORIZATION"] assert "python.org" == req.url.host diff --git a/tests/test_client_session.py b/tests/test_client_session.py index 959c1c166bb..dc46b20fd05 100644 --- a/tests/test_client_session.py +++ b/tests/test_client_session.py @@ -892,6 +892,9 @@ async def test_proxy_str(session: ClientSession, params: _Params) -> None: ] +@pytest.mark.filterwarnings( + r"ignore:The 'proxy_auth' parameter is deprecated:DeprecationWarning" +) async def test_default_proxy() -> None: proxy_url = URL("http://proxy.example.com") proxy_auth = mock.Mock() @@ -1497,6 +1500,10 @@ async def test_netrc_auth_from_home_directory(auth_server: TestServer) -> None: @pytest.mark.usefixtures("netrc_default_contents") +@pytest.mark.filterwarnings( + r"ignore:The 'auth' parameter is deprecated:DeprecationWarning", + r"ignore:BasicAuth is deprecated:DeprecationWarning", +) async def test_netrc_auth_overridden_by_explicit_auth(auth_server: TestServer) -> None: """Test that explicit auth parameter overrides netrc authentication.""" async with ( @@ -1511,6 +1518,24 @@ async def test_netrc_auth_overridden_by_explicit_auth(auth_server: TestServer) - assert text == "auth:Basic ZXhwbGljaXRfdXNlcjpleHBsaWNpdF9wYXNz" +async def test_client_session_auth_deprecated() -> None: + """ClientSession(auth=...) emits a DeprecationWarning.""" + with pytest.warns(DeprecationWarning, match="'auth' parameter is deprecated"): + session = ClientSession( + auth=aiohttp.helpers._basic_auth_no_warn("user", "pass") + ) + await session.close() + + +async def test_client_session_proxy_auth_deprecated() -> None: + """ClientSession(proxy_auth=...) emits a DeprecationWarning.""" + with pytest.warns(DeprecationWarning, match="'proxy_auth' parameter is deprecated"): + session = ClientSession( + proxy_auth=aiohttp.helpers._basic_auth_no_warn("user", "pass") + ) + await session.close() + + @pytest.mark.usefixtures("netrc_other_host") async def test_netrc_auth_host_not_in_netrc(auth_server: TestServer) -> None: """Test that netrc lookup returns None when host is not in netrc file.""" diff --git a/tests/test_helpers.py b/tests/test_helpers.py index a257a1d18f6..500dd89a849 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -3,6 +3,7 @@ import datetime import gc import sys +import warnings import weakref from collections.abc import Iterator from math import ceil, modf @@ -144,18 +145,56 @@ def test_basic_with_auth_colon_in_login() -> None: def test_basic_auth3() -> None: - auth = helpers.BasicAuth("nkim") + with pytest.warns(DeprecationWarning, match="BasicAuth is deprecated"): + auth = helpers.BasicAuth("nkim") assert auth.login == "nkim" assert auth.password == "" def test_basic_auth4() -> None: - auth = helpers.BasicAuth("nkim", "pwd") + with pytest.warns(DeprecationWarning, match="BasicAuth is deprecated"): + auth = helpers.BasicAuth("nkim", "pwd") assert auth.login == "nkim" assert auth.password == "pwd" assert auth.encode() == "Basic bmtpbTpwd2Q=" +def test_basic_auth_deprecated() -> None: + with pytest.warns( + DeprecationWarning, + match=( + "BasicAuth is deprecated and will be removed in aiohttp 4.0; " + "use aiohttp.encode_basic_auth" + ), + ): + helpers.BasicAuth("user", "pass") + + +def test_encode_basic_auth() -> None: + assert helpers.encode_basic_auth("nkim", "pwd") == "Basic bmtpbTpwd2Q=" + assert helpers.encode_basic_auth("") == "Basic Og==" + assert ( + helpers.encode_basic_auth("usér", "pàss", encoding="utf-8") + == "Basic dXPDqXI6cMOgc3M=" + ) + + +def test_encode_basic_auth_rejects_colon_in_login() -> None: + with pytest.raises(ValueError): + helpers.encode_basic_auth("user:1", "pwd") + + +def test_basic_auth_no_warn_helpers_silent() -> None: + """Internal aiohttp paths must not raise BasicAuth's deprecation warning.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + url = URL("http://user:pass@example.com/") + helpers.strip_auth_from_url(url) + helpers.BasicAuth.decode("Basic dXNlcjpwYXNz") + helpers.BasicAuth.from_url(url) + helpers._basic_auth_no_warn("user", "pass") + + @pytest.mark.parametrize( "header", ( @@ -199,18 +238,12 @@ def test_basic_auth_decode_invalid_credentials() -> None: @pytest.mark.parametrize( "credentials, expected_auth", ( - (":", helpers.BasicAuth(login="", password="", encoding="latin1")), - ( - "username:", - helpers.BasicAuth(login="username", password="", encoding="latin1"), - ), - ( - ":password", - helpers.BasicAuth(login="", password="password", encoding="latin1"), - ), + (":", helpers._basic_auth_no_warn("", "", "latin1")), + ("username:", helpers._basic_auth_no_warn("username", "", "latin1")), + (":password", helpers._basic_auth_no_warn("", "password", "latin1")), ( "username:password", - helpers.BasicAuth(login="username", password="password", encoding="latin1"), + helpers._basic_auth_no_warn("username", "password", "latin1"), ), ), ) @@ -1109,15 +1142,15 @@ def test_netrc_from_home_does_not_raise_if_access_denied( [ ( "machine example.com login username password pass\n", - helpers.BasicAuth("username", "pass"), + helpers._basic_auth_no_warn("username", "pass", "latin1"), ), ( "machine example.com account username password pass\n", - helpers.BasicAuth("username", "pass"), + helpers._basic_auth_no_warn("username", "pass", "latin1"), ), ( "machine example.com password pass\n", - helpers.BasicAuth("", "pass"), + helpers._basic_auth_no_warn("", "pass", "latin1"), ), ], indirect=("netrc_contents",), diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 147b5998b8e..c96bbcf1c3a 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -964,7 +964,7 @@ async def test_proxy_auth_property( "GET", URL("http://localhost:1234/path"), proxy=URL("http://proxy.example.com"), - proxy_auth=aiohttp.helpers.BasicAuth("user", "pass"), + proxy_auth=aiohttp.helpers._basic_auth_no_warn("user", "pass"), loop=event_loop, ) assert ("user", "pass", "latin1") == req.proxy_auth @@ -1090,7 +1090,7 @@ async def test_https_auth( # type: ignore[misc] proxy_req = ClientRequestBase( "GET", URL("http://proxy.example.com"), - auth=aiohttp.helpers.BasicAuth("user", "pass"), + auth=aiohttp.helpers._basic_auth_no_warn("user", "pass"), loop=event_loop, ssl=True, headers=CIMultiDict({}), diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index 709a8ff9ffa..2df3915f617 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -25,6 +25,16 @@ ASYNCIO_SUPPORTS_TLS_IN_TLS = sys.version_info >= (3, 11) +pytestmark = [ + pytest.mark.filterwarnings(r"ignore:BasicAuth is deprecated:DeprecationWarning"), + pytest.mark.filterwarnings( + r"ignore:The 'auth' parameter is deprecated:DeprecationWarning" + ), + pytest.mark.filterwarnings( + r"ignore:The 'proxy_auth' parameter is deprecated:DeprecationWarning" + ), +] + class _ResponseArgs(TypedDict): status: int