Skip to content

Commit c0449bb

Browse files
authored
Add preemptive authentication support to DigestAuthMiddleware (#11129)
1 parent e4bffe9 commit c0449bb

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
@@ -2282,12 +2282,13 @@ Utilities
22822282
:return: encoded authentication data, :class:`str`.
22832283

22842284

2285-
.. class:: DigestAuthMiddleware(login, password)
2285+
.. class:: DigestAuthMiddleware(login, password, *, preemptive=True)
22862286

22872287
HTTP digest authentication client middleware.
22882288

22892289
:param str login: login
22902290
:param str password: password
2291+
:param bool preemptive: Enable preemptive authentication (default: ``True``)
22912292

22922293
This middleware supports HTTP digest authentication with both `auth` and
22932294
`auth-int` quality of protection (qop) modes, and a variety of hashing algorithms.
@@ -2297,6 +2298,31 @@ Utilities
22972298
- Parsing 401 Unauthorized responses with `WWW-Authenticate: Digest` headers
22982299
- Generating appropriate `Authorization: Digest` headers on retry
22992300
- Maintaining nonce counts and challenge data per request
2301+
- When ``preemptive=True``, reusing authentication credentials for subsequent
2302+
requests to the same protection space (following RFC 7616 Section 3.6)
2303+
2304+
**Preemptive Authentication**
2305+
2306+
By default (``preemptive=True``), the middleware remembers successful authentication
2307+
challenges and automatically includes the Authorization header in subsequent requests
2308+
to the same protection space. This behavior:
2309+
2310+
- Improves server efficiency by avoiding extra round trips
2311+
- Matches how modern web browsers handle digest authentication
2312+
- Follows the recommendation in RFC 7616 Section 3.6
2313+
2314+
The server may still respond with a 401 status and ``stale=true`` if the nonce
2315+
has expired, in which case the middleware will automatically retry with the new nonce.
2316+
2317+
To disable preemptive authentication and require a 401 challenge for every request,
2318+
set ``preemptive=False``::
2319+
2320+
# Default behavior - preemptive auth enabled
2321+
digest_auth_middleware = DigestAuthMiddleware(login="user", password="pass")
2322+
2323+
# Disable preemptive auth - always wait for 401 challenge
2324+
digest_auth_middleware = DigestAuthMiddleware(login="user", password="pass",
2325+
preemptive=False)
23002326

23012327
Usage::
23022328

@@ -2306,7 +2332,13 @@ Utilities
23062332
# The middleware automatically handles the digest auth handshake
23072333
assert resp.status == 200
23082334

2335+
# Subsequent requests include auth header preemptively
2336+
async with session.get("http://protected.example.com/other") as resp:
2337+
assert resp.status == 200 # No 401 round trip needed
2338+
23092339
.. versionadded:: 3.12
2340+
.. versionchanged:: 3.12.8
2341+
Added ``preemptive`` parameter to enable/disable preemptive authentication.
23102342

23112343

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

0 commit comments

Comments
 (0)