Skip to content

Commit c2abbfe

Browse files
committed
feat: support "auth-int" quality-of-protection in HTTP digest requests
1 parent ae1b9f6 commit c2abbfe

File tree

3 files changed

+51
-12
lines changed

3 files changed

+51
-12
lines changed

httpx/_auth.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,8 @@ def digest(data: bytes) -> bytes:
264264

265265
path = request.url.raw_path
266266
A2 = b":".join((request.method.encode(), path))
267-
# TODO: implement auth-int
267+
if challenge.qop == b"auth-int":
268+
A2 += b":" + digest(request.content)
268269
HA2 = digest(A2)
269270

270271
nc_value = b"%08x" % self._nonce_count
@@ -294,7 +295,7 @@ def digest(data: bytes) -> bytes:
294295
if challenge.opaque:
295296
format_args["opaque"] = challenge.opaque
296297
if qop:
297-
format_args["qop"] = b"auth"
298+
format_args["qop"] = qop
298299
format_args["nc"] = nc_value
299300
format_args["cnonce"] = cnonce
300301

@@ -330,12 +331,13 @@ def _resolve_qop(self, qop: bytes | None, request: Request) -> bytes | None:
330331
if qop is None:
331332
return None
332333
qops = re.split(b", ?", qop)
334+
335+
# Defer to the strongest supplied qop (auth-int > auth)
336+
if b"auth-int" in qops:
337+
return b"auth-int"
333338
if b"auth" in qops:
334339
return b"auth"
335340

336-
if qops == [b"auth-int"]:
337-
raise NotImplementedError("Digest auth-int support is not yet implemented")
338-
339341
message = f'Unexpected qop value "{qop!r}" in digest auth'
340342
raise ProtocolError(message, request=request)
341343

tests/client/test_auth.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -501,15 +501,50 @@ async def test_digest_auth_qop_including_spaces_and_auth_returns_auth(qop: str)
501501
assert len(response.history) == 1
502502

503503

504+
@pytest.mark.parametrize(
505+
"algorithm,expected_hash_length,expected_response_length",
506+
[
507+
("MD5", 64, 32),
508+
("MD5-SESS", 64, 32),
509+
("SHA", 64, 40),
510+
("SHA-SESS", 64, 40),
511+
("SHA-256", 64, 64),
512+
("SHA-256-SESS", 64, 64),
513+
("SHA-512", 64, 128),
514+
("SHA-512-SESS", 64, 128),
515+
],
516+
)
504517
@pytest.mark.anyio
505-
async def test_digest_auth_qop_auth_int_not_implemented() -> None:
518+
async def test_digest_auth_qop_auth_int(
519+
algorithm: str, expected_hash_length: int, expected_response_length: int
520+
) -> None:
506521
url = "https://example.org/"
507522
auth = httpx.DigestAuth(username="user", password="password123")
508-
app = DigestApp(qop="auth-int")
523+
app = DigestApp(qop="auth-int", algorithm=algorithm)
509524

510525
async with httpx.AsyncClient(transport=httpx.MockTransport(app)) as client:
511-
with pytest.raises(NotImplementedError):
512-
await client.get(url, auth=auth)
526+
response = await client.get(url, auth=auth)
527+
528+
assert response.status_code == 200
529+
assert len(response.history) == 1
530+
531+
authorization = typing.cast(typing.Dict[str, typing.Any], response.json())["auth"]
532+
scheme, _, fields = authorization.partition(" ")
533+
assert scheme == "Digest"
534+
535+
response_fields = [field.strip() for field in fields.split(",")]
536+
digest_data = dict(field.split("=") for field in response_fields)
537+
538+
assert digest_data["username"] == '"user"'
539+
assert digest_data["realm"] == '"httpx@example.org"'
540+
assert "nonce" in digest_data
541+
assert digest_data["uri"] == '"/"'
542+
assert len(digest_data["response"]) == expected_response_length + 2 # extra quotes
543+
assert len(digest_data["opaque"]) == expected_hash_length + 2
544+
assert digest_data["algorithm"] == algorithm
545+
assert digest_data["qop"] == "auth-int"
546+
assert digest_data["nc"] == "00000001"
547+
assert len(digest_data["cnonce"]) == 16 + 2
513548

514549

515550
@pytest.mark.anyio

tests/test_auth.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,8 @@ def mock_get_client_nonce(nonce_count: int, nonce: bytes) -> bytes:
238238
in request.headers["Authorization"]
239239
)
240240
assert (
241-
'response="8ca523f5e9506fed4657c9700eebdbec"'
241+
# 'response="8ca523f5e9506fed4657c9700eebdbec"'
242+
'response="7d2b5599cc59f94b525f726e44474803"'
242243
in request.headers["Authorization"]
243244
)
244245

@@ -292,13 +293,14 @@ def mock_get_client_nonce(nonce_count: int, nonce: bytes) -> bytes:
292293
'cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"'
293294
in request.headers["Authorization"]
294295
)
295-
assert "qop=auth" in request.headers["Authorization"]
296+
assert "qop=auth-int" in request.headers["Authorization"]
296297
assert (
297298
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"'
298299
in request.headers["Authorization"]
299300
)
300301
assert (
301-
'response="753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1"'
302+
# 'response="753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1"'
303+
'response="a2274700215378a04e1a528e3706c7aab17a3fe7a988900a6c439c9509209acf"'
302304
in request.headers["Authorization"]
303305
)
304306

0 commit comments

Comments
 (0)