@@ -38,6 +38,8 @@ class DigestAuthChallenge(TypedDict, total=False):
38
38
qop : str
39
39
algorithm : str
40
40
opaque : str
41
+ domain : str
42
+ stale : str
41
43
42
44
43
45
DigestFunctions : Dict [str , Callable [[bytes ], "hashlib._Hash" ]] = {
@@ -81,13 +83,17 @@ class DigestAuthChallenge(TypedDict, total=False):
81
83
82
84
# RFC 7616: Challenge parameters to extract
83
85
CHALLENGE_FIELDS : Final [
84
- Tuple [Literal ["realm" , "nonce" , "qop" , "algorithm" , "opaque" ], ...]
86
+ Tuple [
87
+ Literal ["realm" , "nonce" , "qop" , "algorithm" , "opaque" , "domain" , "stale" ], ...
88
+ ]
85
89
] = (
86
90
"realm" ,
87
91
"nonce" ,
88
92
"qop" ,
89
93
"algorithm" ,
90
94
"opaque" ,
95
+ "domain" ,
96
+ "stale" ,
91
97
)
92
98
93
99
# Supported digest authentication algorithms
@@ -159,6 +165,7 @@ class DigestAuthMiddleware:
159
165
- Supports 'auth' and 'auth-int' quality of protection modes
160
166
- Properly handles quoted strings and parameter parsing
161
167
- Includes replay attack protection with client nonce count tracking
168
+ - Supports preemptive authentication per RFC 7616 Section 3.6
162
169
163
170
Standards compliance:
164
171
- RFC 7616: HTTP Digest Access Authentication (primary reference)
@@ -175,6 +182,7 @@ def __init__(
175
182
self ,
176
183
login : str ,
177
184
password : str ,
185
+ preemptive : bool = True ,
178
186
) -> None :
179
187
if login is None :
180
188
raise ValueError ("None is not allowed as login value" )
@@ -192,6 +200,9 @@ def __init__(
192
200
self ._last_nonce_bytes = b""
193
201
self ._nonce_count = 0
194
202
self ._challenge : DigestAuthChallenge = {}
203
+ self ._preemptive : bool = preemptive
204
+ # Set of URLs defining the protection space
205
+ self ._protection_space : List [str ] = []
195
206
196
207
async def _encode (
197
208
self , method : str , url : URL , body : Union [Payload , Literal [b"" ]]
@@ -354,6 +365,26 @@ def KD(s: bytes, d: bytes) -> bytes:
354
365
355
366
return f"Digest { ', ' .join (pairs )} "
356
367
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
+
357
388
def _authenticate (self , response : ClientResponse ) -> bool :
358
389
"""
359
390
Takes the given response and tries digest-auth, if needed.
@@ -391,6 +422,25 @@ def _authenticate(self, response: ClientResponse) -> bool:
391
422
if value := header_pairs .get (field ):
392
423
self ._challenge [field ] = value
393
424
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
+
394
444
# Return True only if we found at least one challenge parameter
395
445
return bool (self ._challenge )
396
446
@@ -400,8 +450,14 @@ async def __call__(
400
450
"""Run the digest auth middleware."""
401
451
response = None
402
452
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
+ ):
405
461
request .headers [hdrs .AUTHORIZATION ] = await self ._encode (
406
462
request .method , request .url , request .body
407
463
)
0 commit comments