diff --git a/httpx/_auth.py b/httpx/_auth.py index 9d24faed..8450548a 100644 --- a/httpx/_auth.py +++ b/httpx/_auth.py @@ -264,7 +264,8 @@ class DigestAuth(Auth): path = request.url.raw_path A2 = b":".join((request.method.encode(), path)) - # TODO: implement auth-int + if challenge.qop == b"auth-int": + A2 += b":" + digest(request.content) HA2 = digest(A2) nc_value = b"%08x" % self._nonce_count @@ -294,7 +295,7 @@ class DigestAuth(Auth): if challenge.opaque: format_args["opaque"] = challenge.opaque if qop: - format_args["qop"] = b"auth" + format_args["qop"] = qop format_args["nc"] = nc_value format_args["cnonce"] = cnonce @@ -330,12 +331,13 @@ class DigestAuth(Auth): if qop is None: return None qops = re.split(b", ?", qop) + + # Defer to the strongest supplied qop (auth-int > auth) + if b"auth-int" in qops: + return b"auth-int" if b"auth" in qops: return b"auth" - if qops == [b"auth-int"]: - raise NotImplementedError("Digest auth-int support is not yet implemented") - message = f'Unexpected qop value "{qop!r}" in digest auth' raise ProtocolError(message, request=request) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 72674e6f..304428e0 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -501,15 +501,50 @@ async def test_digest_auth_qop_including_spaces_and_auth_returns_auth(qop: str) assert len(response.history) == 1 +@pytest.mark.parametrize( + "algorithm,expected_hash_length,expected_response_length", + [ + ("MD5", 64, 32), + ("MD5-SESS", 64, 32), + ("SHA", 64, 40), + ("SHA-SESS", 64, 40), + ("SHA-256", 64, 64), + ("SHA-256-SESS", 64, 64), + ("SHA-512", 64, 128), + ("SHA-512-SESS", 64, 128), + ], +) @pytest.mark.anyio -async def test_digest_auth_qop_auth_int_not_implemented() -> None: +async def test_digest_auth_qop_auth_int( + algorithm: str, expected_hash_length: int, expected_response_length: int +) -> None: url = "https://example.org/" auth = httpx.DigestAuth(username="user", password="password123") - app = DigestApp(qop="auth-int") + app = DigestApp(qop="auth-int", algorithm=algorithm) async with httpx.AsyncClient(transport=httpx.MockTransport(app)) as client: - with pytest.raises(NotImplementedError): - await client.get(url, auth=auth) + response = await client.get(url, auth=auth) + + assert response.status_code == 200 + assert len(response.history) == 1 + + authorization = typing.cast(typing.Dict[str, typing.Any], response.json())["auth"] + scheme, _, fields = authorization.partition(" ") + assert scheme == "Digest" + + response_fields = [field.strip() for field in fields.split(",")] + digest_data = dict(field.split("=") for field in response_fields) + + assert digest_data["username"] == '"user"' + assert digest_data["realm"] == '"httpx@example.org"' + assert "nonce" in digest_data + assert digest_data["uri"] == '"/"' + assert len(digest_data["response"]) == expected_response_length + 2 # extra quotes + assert len(digest_data["opaque"]) == expected_hash_length + 2 + assert digest_data["algorithm"] == algorithm + assert digest_data["qop"] == "auth-int" + assert digest_data["nc"] == "00000001" + assert len(digest_data["cnonce"]) == 16 + 2 @pytest.mark.anyio diff --git a/tests/test_auth.py b/tests/test_auth.py index 6b6df922..24a249cf 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -238,7 +238,8 @@ def test_digest_auth_rfc_7616_md5(monkeypatch): in request.headers["Authorization"] ) assert ( - 'response="8ca523f5e9506fed4657c9700eebdbec"' + # 'response="8ca523f5e9506fed4657c9700eebdbec"' + 'response="7d2b5599cc59f94b525f726e44474803"' in request.headers["Authorization"] ) @@ -292,13 +293,14 @@ def test_digest_auth_rfc_7616_sha_256(monkeypatch): 'cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"' in request.headers["Authorization"] ) - assert "qop=auth" in request.headers["Authorization"] + assert "qop=auth-int" in request.headers["Authorization"] assert ( 'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"' in request.headers["Authorization"] ) assert ( - 'response="753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1"' + # 'response="753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1"' + 'response="a2274700215378a04e1a528e3706c7aab17a3fe7a988900a6c439c9509209acf"' in request.headers["Authorization"] )