Skip to content

Commit 2c8e4d2

Browse files
bdracopatchback[bot]
authored andcommitted
Add preemptive authentication support to DigestAuthMiddleware (#11129)
(cherry picked from commit c0449bb)
1 parent 1b9a3c6 commit 2c8e4d2

File tree

5 files changed

+523
-4
lines changed

5 files changed

+523
-4
lines changed

CHANGES/11128.feature.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Added preemptive digest authentication to :class:`~aiohttp.DigestAuthMiddleware` -- by :user:`bdraco`.
2+
3+
The middleware now reuses authentication credentials for subsequent requests to the same
4+
protection space, improving efficiency by avoiding extra authentication round trips.
5+
This behavior matches how web browsers handle digest authentication and follows
6+
:rfc:`7616#section-3.6`.
7+
8+
Preemptive authentication is enabled by default but can be disabled by passing
9+
``preemptive=False`` to the middleware constructor.

CHANGES/11129.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
11128.feature.rst

aiohttp/client_middleware_digest_auth.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ class DigestAuthChallenge(TypedDict, total=False):
3838
qop: str
3939
algorithm: str
4040
opaque: str
41+
domain: str
42+
stale: str
4143

4244

4345
DigestFunctions: Dict[str, Callable[[bytes], "hashlib._Hash"]] = {
@@ -81,13 +83,17 @@ class DigestAuthChallenge(TypedDict, total=False):
8183

8284
# RFC 7616: Challenge parameters to extract
8385
CHALLENGE_FIELDS: Final[
84-
Tuple[Literal["realm", "nonce", "qop", "algorithm", "opaque"], ...]
86+
Tuple[
87+
Literal["realm", "nonce", "qop", "algorithm", "opaque", "domain", "stale"], ...
88+
]
8589
] = (
8690
"realm",
8791
"nonce",
8892
"qop",
8993
"algorithm",
9094
"opaque",
95+
"domain",
96+
"stale",
9197
)
9298

9399
# Supported digest authentication algorithms
@@ -159,6 +165,7 @@ class DigestAuthMiddleware:
159165
- Supports 'auth' and 'auth-int' quality of protection modes
160166
- Properly handles quoted strings and parameter parsing
161167
- Includes replay attack protection with client nonce count tracking
168+
- Supports preemptive authentication per RFC 7616 Section 3.6
162169
163170
Standards compliance:
164171
- RFC 7616: HTTP Digest Access Authentication (primary reference)
@@ -175,6 +182,7 @@ def __init__(
175182
self,
176183
login: str,
177184
password: str,
185+
preemptive: bool = True,
178186
) -> None:
179187
if login is None:
180188
raise ValueError("None is not allowed as login value")
@@ -192,6 +200,9 @@ def __init__(
192200
self._last_nonce_bytes = b""
193201
self._nonce_count = 0
194202
self._challenge: DigestAuthChallenge = {}
203+
self._preemptive: bool = preemptive
204+
# Set of URLs defining the protection space
205+
self._protection_space: List[str] = []
195206

196207
async def _encode(
197208
self, method: str, url: URL, body: Union[Payload, Literal[b""]]
@@ -354,6 +365,26 @@ def KD(s: bytes, d: bytes) -> bytes:
354365

355366
return f"Digest {', '.join(pairs)}"
356367

368+
def _in_protection_space(self, url: URL) -> bool:
369+
"""
370+
Check if the given URL is within the current protection space.
371+
372+
According to RFC 7616, a URI is in the protection space if any URI
373+
in the protection space is a prefix of it (after both have been made absolute).
374+
"""
375+
request_str = str(url)
376+
for space_str in self._protection_space:
377+
# Check if request starts with space URL
378+
if not request_str.startswith(space_str):
379+
continue
380+
# Exact match or space ends with / (proper directory prefix)
381+
if len(request_str) == len(space_str) or space_str[-1] == "/":
382+
return True
383+
# Check next char is / to ensure proper path boundary
384+
if request_str[len(space_str)] == "/":
385+
return True
386+
return False
387+
357388
def _authenticate(self, response: ClientResponse) -> bool:
358389
"""
359390
Takes the given response and tries digest-auth, if needed.
@@ -391,6 +422,25 @@ def _authenticate(self, response: ClientResponse) -> bool:
391422
if value := header_pairs.get(field):
392423
self._challenge[field] = value
393424

425+
# Update protection space based on domain parameter or default to origin
426+
origin = response.url.origin()
427+
428+
if domain := self._challenge.get("domain"):
429+
# Parse space-separated list of URIs
430+
self._protection_space = []
431+
for uri in domain.split():
432+
# Remove quotes if present
433+
uri = uri.strip('"')
434+
if uri.startswith("/"):
435+
# Path-absolute, relative to origin
436+
self._protection_space.append(str(origin.join(URL(uri))))
437+
else:
438+
# Absolute URI
439+
self._protection_space.append(str(URL(uri)))
440+
else:
441+
# No domain specified, protection space is entire origin
442+
self._protection_space = [str(origin)]
443+
394444
# Return True only if we found at least one challenge parameter
395445
return bool(self._challenge)
396446

@@ -400,8 +450,14 @@ async def __call__(
400450
"""Run the digest auth middleware."""
401451
response = None
402452
for retry_count in range(2):
403-
# Apply authorization header if we have a challenge (on second attempt)
404-
if retry_count > 0:
453+
# Apply authorization header if:
454+
# 1. This is a retry after 401 (retry_count > 0), OR
455+
# 2. Preemptive auth is enabled AND we have a challenge AND the URL is in protection space
456+
if retry_count > 0 or (
457+
self._preemptive
458+
and self._challenge
459+
and self._in_protection_space(request.url)
460+
):
405461
request.headers[hdrs.AUTHORIZATION] = await self._encode(
406462
request.method, request.url, request.body
407463
)

docs/client_reference.rst

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2300,12 +2300,13 @@ Utilities
23002300
:return: encoded authentication data, :class:`str`.
23012301

23022302

2303-
.. class:: DigestAuthMiddleware(login, password)
2303+
.. class:: DigestAuthMiddleware(login, password, *, preemptive=True)
23042304

23052305
HTTP digest authentication client middleware.
23062306

23072307
:param str login: login
23082308
:param str password: password
2309+
:param bool preemptive: Enable preemptive authentication (default: ``True``)
23092310

23102311
This middleware supports HTTP digest authentication with both `auth` and
23112312
`auth-int` quality of protection (qop) modes, and a variety of hashing algorithms.
@@ -2315,6 +2316,31 @@ Utilities
23152316
- Parsing 401 Unauthorized responses with `WWW-Authenticate: Digest` headers
23162317
- Generating appropriate `Authorization: Digest` headers on retry
23172318
- Maintaining nonce counts and challenge data per request
2319+
- When ``preemptive=True``, reusing authentication credentials for subsequent
2320+
requests to the same protection space (following RFC 7616 Section 3.6)
2321+
2322+
**Preemptive Authentication**
2323+
2324+
By default (``preemptive=True``), the middleware remembers successful authentication
2325+
challenges and automatically includes the Authorization header in subsequent requests
2326+
to the same protection space. This behavior:
2327+
2328+
- Improves server efficiency by avoiding extra round trips
2329+
- Matches how modern web browsers handle digest authentication
2330+
- Follows the recommendation in RFC 7616 Section 3.6
2331+
2332+
The server may still respond with a 401 status and ``stale=true`` if the nonce
2333+
has expired, in which case the middleware will automatically retry with the new nonce.
2334+
2335+
To disable preemptive authentication and require a 401 challenge for every request,
2336+
set ``preemptive=False``::
2337+
2338+
# Default behavior - preemptive auth enabled
2339+
digest_auth_middleware = DigestAuthMiddleware(login="user", password="pass")
2340+
2341+
# Disable preemptive auth - always wait for 401 challenge
2342+
digest_auth_middleware = DigestAuthMiddleware(login="user", password="pass",
2343+
preemptive=False)
23182344

23192345
Usage::
23202346

@@ -2324,7 +2350,13 @@ Utilities
23242350
# The middleware automatically handles the digest auth handshake
23252351
assert resp.status == 200
23262352

2353+
# Subsequent requests include auth header preemptively
2354+
async with session.get("http://protected.example.com/other") as resp:
2355+
assert resp.status == 200 # No 401 round trip needed
2356+
23272357
.. versionadded:: 3.12
2358+
.. versionchanged:: 3.12.8
2359+
Added ``preemptive`` parameter to enable/disable preemptive authentication.
23282360

23292361

23302362
.. class:: CookieJar(*, unsafe=False, quote_cookie=True, treat_as_secure_origin = [])

0 commit comments

Comments
 (0)