Coverage for /pythoncovmergedfiles/medio/medio/src/aiohttp/aiohttp/client_middleware_digest_auth.py: 19%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

185 statements  

1""" 

2Digest authentication middleware for aiohttp client. 

3 

4This middleware implements HTTP Digest Authentication according to RFC 7616, 

5providing a more secure alternative to Basic Authentication. It supports all 

6standard hash algorithms including MD5, SHA, SHA-256, SHA-512 and their session 

7variants, as well as both 'auth' and 'auth-int' quality of protection (qop) options. 

8""" 

9 

10import hashlib 

11import os 

12import re 

13import sys 

14import time 

15from collections.abc import Callable 

16from typing import Final, Literal, TypedDict 

17 

18from yarl import URL 

19 

20from . import hdrs 

21from .client_exceptions import ClientError 

22from .client_middlewares import ClientHandlerType 

23from .client_reqrep import ClientRequest, ClientResponse 

24from .payload import Payload 

25 

26 

27class DigestAuthChallenge(TypedDict, total=False): 

28 realm: str 

29 nonce: str 

30 qop: str 

31 algorithm: str 

32 opaque: str 

33 domain: str 

34 stale: str 

35 

36 

37DigestFunctions: dict[str, Callable[[bytes], "hashlib._Hash"]] = { 

38 "MD5": hashlib.md5, 

39 "MD5-SESS": hashlib.md5, 

40 "SHA": hashlib.sha1, 

41 "SHA-SESS": hashlib.sha1, 

42 "SHA256": hashlib.sha256, 

43 "SHA256-SESS": hashlib.sha256, 

44 "SHA-256": hashlib.sha256, 

45 "SHA-256-SESS": hashlib.sha256, 

46 "SHA512": hashlib.sha512, 

47 "SHA512-SESS": hashlib.sha512, 

48 "SHA-512": hashlib.sha512, 

49 "SHA-512-SESS": hashlib.sha512, 

50} 

51 

52 

53# Compile the regex pattern once at module level for performance 

54_HEADER_PAIRS_PATTERN = re.compile( 

55 r'(?:^|\s|,\s*)(\w+)(?:\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+)))?' 

56 if sys.version_info < (3, 11) 

57 else r'(?:^|\s|,\s*)((?>\w+))(?:\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+)))?' 

58 # +------------|--------|--|--||--|--|----|------|---|---||-----|-> Match valid start/sep 

59 # +--------|--|--||--|--|----|------|---|---||-----|-> alphanumeric key (atomic group reduces backtracking) 

60 # +--|--||--|--|----|------|---|---||-----|-> optional value; absent => bare auth-scheme token 

61 # +--||--|--|----|------|---|---||-----|-> maybe whitespace 

62 # +|--|--|----|------|---|---||-----|-> = (delimiter) 

63 # +--|--|----|------|---|---||-----|-> maybe whitespace 

64 # +--|----|------|---|---||-----|-> group quoted or unquoted 

65 # +----|------|---|---||-----|-> if quoted... 

66 # +------|---|---||-----|-> anything but " or \ 

67 # +---|---||-----|-> escaped characters allowed 

68 # +---||-----|-> or can be empty string 

69 # +|-----|-> if unquoted... 

70 # +-----|-> anything but , or <space> 

71 # +-> at least one char req'd 

72) 

73 

74 

75# RFC 7616: Challenge parameters to extract 

76CHALLENGE_FIELDS: Final[ 

77 tuple[ 

78 Literal["realm", "nonce", "qop", "algorithm", "opaque", "domain", "stale"], ... 

79 ] 

80] = ( 

81 "realm", 

82 "nonce", 

83 "qop", 

84 "algorithm", 

85 "opaque", 

86 "domain", 

87 "stale", 

88) 

89 

90# Supported digest authentication algorithms 

91# Use a tuple of sorted keys for predictable documentation and error messages 

92SUPPORTED_ALGORITHMS: Final[tuple[str, ...]] = tuple(sorted(DigestFunctions.keys())) 

93 

94# RFC 7616: Fields that require quoting in the Digest auth header 

95# These fields must be enclosed in double quotes in the Authorization header. 

96# Algorithm, qop, and nc are never quoted per RFC specifications. 

97# This frozen set is used by the template-based header construction to 

98# automatically determine which fields need quotes. 

99QUOTED_AUTH_FIELDS: Final[frozenset[str]] = frozenset( 

100 {"username", "realm", "nonce", "uri", "response", "opaque", "cnonce"} 

101) 

102 

103 

104def escape_quotes(value: str) -> str: 

105 """Escape backslashes and double quotes for HTTP quoted-strings.""" 

106 return value.replace("\\", "\\\\").replace('"', '\\"') 

107 

108 

109def unescape_quotes(value: str) -> str: 

110 """Unescape backslashes and double quotes in HTTP quoted-strings.""" 

111 return value.replace('\\"', '"').replace("\\\\", "\\") 

112 

113 

114def parse_header_pairs(header: str) -> dict[str, str]: 

115 """ 

116 Parse key-value pairs from the first challenge of a WWW-Authenticate header. 

117 

118 This function handles the complex format of WWW-Authenticate header values, 

119 supporting both quoted and unquoted values, proper handling of commas in 

120 quoted values, and whitespace variations per RFC 7616. 

121 

122 A single header may carry several challenges 

123 (https://www.rfc-editor.org/rfc/rfc7235#section-4.1). Parsing 

124 stops at the next auth-scheme token so a later challenge's parameters cannot 

125 overwrite the first challenge's values; a leading scheme token is skipped. 

126 

127 Examples of supported formats: 

128 - key1="value1", key2=value2 

129 - key1 = "value1" , key2="value, with, commas" 

130 - key1=value1,key2="value2" 

131 - realm="example.com", nonce="12345", qop="auth" 

132 

133 Args: 

134 header: The header value string to parse 

135 

136 Returns: 

137 Dictionary mapping parameter names to their values 

138 """ 

139 pairs: dict[str, str] = {} 

140 for match in _HEADER_PAIRS_PATTERN.finditer(header): 

141 key = match.group(1) 

142 quoted_val, unquoted_val = match.group(2), match.group(3) 

143 if quoted_val is None and unquoted_val is None: 

144 # Bare token with no "=value": an auth-scheme name, not a parameter. 

145 # Skip a leading scheme; once parameters exist, a new scheme marks 

146 # the start of the next challenge, so stop here. 

147 if pairs: 

148 break 

149 continue 

150 pairs[key] = ( 

151 unescape_quotes(quoted_val) if quoted_val is not None else unquoted_val 

152 ) 

153 return pairs 

154 

155 

156class DigestAuthMiddleware: 

157 """ 

158 HTTP digest authentication middleware for aiohttp client. 

159 

160 This middleware intercepts 401 Unauthorized responses containing a Digest 

161 authentication challenge, calculates the appropriate digest credentials, 

162 and automatically retries the request with the proper Authorization header. 

163 

164 Features: 

165 - Handles all aspects of Digest authentication handshake automatically 

166 - Supports all standard hash algorithms: 

167 - MD5, MD5-SESS 

168 - SHA, SHA-SESS 

169 - SHA256, SHA256-SESS, SHA-256, SHA-256-SESS 

170 - SHA512, SHA512-SESS, SHA-512, SHA-512-SESS 

171 - Supports 'auth' and 'auth-int' quality of protection modes 

172 - Properly handles quoted strings and parameter parsing 

173 - Includes replay attack protection with client nonce count tracking 

174 - Supports preemptive authentication per RFC 7616 Section 3.6 

175 

176 Origin scoping: 

177 The credentials are scoped to the origin of the first request the 

178 middleware handles. A request to a different origin is passed through 

179 untouched, so it never receives a digest response computed from those 

180 credentials, unless that origin falls within a protection space the 

181 anchor origin advertised through the RFC 7616 ``domain`` directive. Make 

182 the first request through the middleware against the intended origin, as 

183 the anchor is pinned to it and not reset for the life of the instance. 

184 

185 Standards compliance: 

186 - RFC 7616: HTTP Digest Access Authentication (primary reference) 

187 - RFC 2617: HTTP Authentication (deprecated by RFC 7616) 

188 - RFC 1945: Section 11.1 (username restrictions) 

189 

190 Implementation notes: 

191 The core digest calculation is inspired by the implementation in 

192 https://github.com/requests/requests/blob/v2.18.4/requests/auth.py 

193 with added support for modern digest auth features and error handling. 

194 """ 

195 

196 def __init__( 

197 self, 

198 login: str, 

199 password: str, 

200 preemptive: bool = True, 

201 ) -> None: 

202 if login is None: 

203 raise ValueError("None is not allowed as login value") 

204 

205 if password is None: 

206 raise ValueError("None is not allowed as password value") 

207 

208 if ":" in login: 

209 raise ValueError('A ":" is not allowed in username (RFC 1945#section-11.1)') 

210 

211 self._login_str: Final[str] = login 

212 self._login_bytes: Final[bytes] = login.encode("utf-8") 

213 self._password_bytes: Final[bytes] = password.encode("utf-8") 

214 

215 self._last_nonce_bytes = b"" 

216 self._nonce_count = 0 

217 self._challenge: DigestAuthChallenge = {} 

218 self._preemptive: bool = preemptive 

219 # Set of URLs defining the protection space 

220 self._protection_space: list[str] = [] 

221 # Origin the credentials are scoped to; set on the first request. 

222 self._origin: URL | None = None 

223 

224 async def _encode(self, method: str, url: URL, body: Payload | Literal[b""]) -> str: 

225 """ 

226 Build digest authorization header for the current challenge. 

227 

228 Args: 

229 method: The HTTP method (GET, POST, etc.) 

230 url: The request URL 

231 body: The request body (used for qop=auth-int) 

232 

233 Returns: 

234 A fully formatted Digest authorization header string 

235 

236 Raises: 

237 ClientError: If the challenge is missing required parameters or 

238 contains unsupported values 

239 

240 """ 

241 challenge = self._challenge 

242 if "realm" not in challenge: 

243 raise ClientError( 

244 "Malformed Digest auth challenge: Missing 'realm' parameter" 

245 ) 

246 

247 if "nonce" not in challenge: 

248 raise ClientError( 

249 "Malformed Digest auth challenge: Missing 'nonce' parameter" 

250 ) 

251 

252 # Empty realm values are allowed per RFC 7616 (SHOULD, not MUST, contain host name) 

253 realm = challenge["realm"] 

254 nonce = challenge["nonce"] 

255 

256 # Empty nonce values are not allowed as they are security-critical for replay protection 

257 if not nonce: 

258 raise ClientError( 

259 "Security issue: Digest auth challenge contains empty 'nonce' value" 

260 ) 

261 

262 qop_raw = challenge.get("qop", "") 

263 # Preserve original algorithm case for response while using uppercase for processing 

264 algorithm_original = challenge.get("algorithm", "MD5") 

265 algorithm = algorithm_original.upper() 

266 opaque = challenge.get("opaque", "") 

267 

268 # Convert string values to bytes once 

269 nonce_bytes = nonce.encode("utf-8") 

270 realm_bytes = realm.encode("utf-8") 

271 # Use the encoded request-target (raw_path_qs) since that is what is 

272 # transmitted on the wire and what the server signs against. Using the 

273 # decoded form would cause digest verification to fail when the path 

274 # or query string contains percent-encoded reserved characters. 

275 path = URL(url).raw_path_qs 

276 

277 # Process QoP 

278 qop = "" 

279 qop_bytes = b"" 

280 if qop_raw: 

281 valid_qops = {"auth", "auth-int"}.intersection( 

282 {q.strip() for q in qop_raw.split(",") if q.strip()} 

283 ) 

284 if not valid_qops: 

285 raise ClientError( 

286 f"Digest auth error: Unsupported Quality of Protection (qop) value(s): {qop_raw}" 

287 ) 

288 

289 qop = "auth-int" if "auth-int" in valid_qops else "auth" 

290 qop_bytes = qop.encode("utf-8") 

291 

292 if algorithm not in DigestFunctions: 

293 raise ClientError( 

294 f"Digest auth error: Unsupported hash algorithm: {algorithm}. " 

295 f"Supported algorithms: {', '.join(SUPPORTED_ALGORITHMS)}" 

296 ) 

297 hash_fn: Final = DigestFunctions[algorithm] 

298 

299 def H(x: bytes) -> bytes: 

300 """RFC 7616 Section 3: Hash function H(data) = hex(hash(data)).""" 

301 return hash_fn(x).hexdigest().encode() 

302 

303 def KD(s: bytes, d: bytes) -> bytes: 

304 """RFC 7616 Section 3: KD(secret, data) = H(concat(secret, ":", data)).""" 

305 return H(b":".join((s, d))) 

306 

307 # Calculate A1 and A2 

308 A1 = b":".join((self._login_bytes, realm_bytes, self._password_bytes)) 

309 A2 = f"{method.upper()}:{path}".encode() 

310 if qop == "auth-int": 

311 if isinstance(body, Payload): # will always be empty bytes unless Payload 

312 entity_bytes = await body.as_bytes() # Get bytes from Payload 

313 else: 

314 entity_bytes = body 

315 entity_hash = H(entity_bytes) 

316 A2 = b":".join((A2, entity_hash)) 

317 

318 HA1 = H(A1) 

319 HA2 = H(A2) 

320 

321 # Nonce count handling 

322 if nonce_bytes == self._last_nonce_bytes: 

323 self._nonce_count += 1 

324 else: 

325 self._nonce_count = 1 

326 

327 self._last_nonce_bytes = nonce_bytes 

328 ncvalue = f"{self._nonce_count:08x}" 

329 ncvalue_bytes = ncvalue.encode("utf-8") 

330 

331 # Generate client nonce 

332 cnonce = hashlib.sha1( 

333 b"".join( 

334 [ 

335 str(self._nonce_count).encode("utf-8"), 

336 nonce_bytes, 

337 time.ctime().encode("utf-8"), 

338 os.urandom(8), 

339 ] 

340 ) 

341 ).hexdigest()[:16] 

342 cnonce_bytes = cnonce.encode("utf-8") 

343 

344 # Special handling for session-based algorithms 

345 if algorithm.upper().endswith("-SESS"): 

346 HA1 = H(b":".join((HA1, nonce_bytes, cnonce_bytes))) 

347 

348 # Calculate the response digest 

349 if qop: 

350 noncebit = b":".join( 

351 (nonce_bytes, ncvalue_bytes, cnonce_bytes, qop_bytes, HA2) 

352 ) 

353 response_digest = KD(HA1, noncebit) 

354 else: 

355 response_digest = KD(HA1, b":".join((nonce_bytes, HA2))) 

356 

357 # Define a dict mapping of header fields to their values 

358 # Group fields into always-present, optional, and qop-dependent 

359 header_fields = { 

360 # Always present fields 

361 "username": escape_quotes(self._login_str), 

362 "realm": escape_quotes(realm), 

363 "nonce": escape_quotes(nonce), 

364 "uri": path, 

365 "response": response_digest.decode(), 

366 "algorithm": algorithm_original, 

367 } 

368 

369 # Optional fields 

370 if opaque: 

371 header_fields["opaque"] = escape_quotes(opaque) 

372 

373 # QoP-dependent fields 

374 if qop: 

375 header_fields["qop"] = qop 

376 header_fields["nc"] = ncvalue 

377 header_fields["cnonce"] = cnonce 

378 

379 # Build header using templates for each field type 

380 pairs: list[str] = [] 

381 for field, value in header_fields.items(): 

382 if field in QUOTED_AUTH_FIELDS: 

383 pairs.append(f'{field}="{value}"') 

384 else: 

385 pairs.append(f"{field}={value}") 

386 

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

388 

389 def _in_protection_space(self, url: URL) -> bool: 

390 """ 

391 Check if the given URL is within the current protection space. 

392 

393 According to RFC 7616, a URI is in the protection space if any URI 

394 in the protection space is a prefix of it (after both have been made absolute). 

395 """ 

396 request_str = str(url) 

397 for space_str in self._protection_space: 

398 # Check if request starts with space URL 

399 if not request_str.startswith(space_str): 

400 continue 

401 # Exact match or space ends with / (proper directory prefix) 

402 if len(request_str) == len(space_str) or space_str[-1] == "/": 

403 return True 

404 # Check next char is / to ensure proper path boundary 

405 if request_str[len(space_str)] == "/": 

406 return True 

407 return False 

408 

409 def _authenticate(self, response: ClientResponse) -> bool: 

410 """ 

411 Takes the given response and tries digest-auth, if needed. 

412 

413 Returns true if the original request must be resent. 

414 """ 

415 if response.status != 401: 

416 return False 

417 

418 auth_header = response.headers.get("www-authenticate", "") 

419 if not auth_header: 

420 return False # No authentication header present 

421 

422 method, sep, headers = auth_header.partition(" ") 

423 if not sep: 

424 # No space found in www-authenticate header 

425 return False # Malformed auth header, missing scheme separator 

426 

427 if method.lower() != "digest": 

428 # Not a digest auth challenge (could be Basic, Bearer, etc.) 

429 return False 

430 

431 if not headers: 

432 # We have a digest scheme but no parameters 

433 return False # Malformed digest header, missing parameters 

434 

435 # We have a digest auth header with content 

436 if not (header_pairs := parse_header_pairs(headers)): 

437 # Failed to parse any key-value pairs 

438 return False # Malformed digest header, no valid parameters 

439 

440 # Extract challenge parameters 

441 self._challenge = {} 

442 for field in CHALLENGE_FIELDS: 

443 if (value := header_pairs.get(field)) is not None: 

444 self._challenge[field] = value 

445 

446 # Update protection space based on domain parameter or default to origin 

447 origin = response.url.origin() 

448 self._protection_space = [] 

449 

450 if domain := self._challenge.get("domain"): 

451 # Parse space-separated list of URIs 

452 for uri in domain.split(): 

453 # Remove quotes if present 

454 uri = uri.strip('"') 

455 if not uri: 

456 continue 

457 if uri.startswith("/"): 

458 # Path-absolute, relative to origin 

459 self._protection_space.append(str(origin.join(URL(uri)))) 

460 else: 

461 # Absolute URI 

462 self._protection_space.append(str(URL(uri))) 

463 

464 if not self._protection_space: 

465 self._protection_space = [str(origin)] 

466 

467 # Return True only if we found at least one challenge parameter 

468 return bool(self._challenge) 

469 

470 async def __call__( 

471 self, request: ClientRequest, handler: ClientHandlerType 

472 ) -> ClientResponse: 

473 """Run the digest auth middleware.""" 

474 # Credentials are scoped to the first request's origin. Other origins 

475 # pass through untouched unless a challenge from the anchor origin 

476 # advertised them via RFC 7616 domain; mirrors aiohttp stripping 

477 # Authorization on cross-origin redirects. 

478 origin = request.url.origin() 

479 if self._origin is None: 

480 self._origin = origin 

481 elif origin != self._origin and not self._in_protection_space(request.url): 

482 return await handler(request) 

483 

484 response = None 

485 for retry_count in range(2): 

486 # Apply authorization header if: 

487 # 1. This is a retry after 401 (retry_count > 0), OR 

488 # 2. Preemptive auth is enabled AND we have a challenge AND the URL is in protection space 

489 if retry_count > 0 or ( 

490 self._preemptive 

491 and self._challenge 

492 and self._in_protection_space(request.url) 

493 ): 

494 request.headers[hdrs.AUTHORIZATION] = await self._encode( 

495 request.method, request.url, request.body 

496 ) 

497 

498 # Send the request 

499 response = await handler(request) 

500 

501 # Check if we need to authenticate 

502 if not self._authenticate(response): 

503 break 

504 

505 # At this point, response is guaranteed to be defined 

506 assert response is not None 

507 return response