|
9 | 9 |
|
10 | 10 | import logging |
11 | 11 | from abc import ABC, abstractmethod |
12 | | -from collections.abc import Mapping |
| 12 | +from collections.abc import Callable, Mapping |
13 | 13 | from datetime import datetime |
14 | 14 | from typing import TYPE_CHECKING, Any, NoReturn |
| 15 | +from urllib.parse import urlparse |
15 | 16 |
|
16 | 17 | from lxml import etree |
17 | 18 |
|
@@ -99,6 +100,11 @@ class BaseDAVClient(ABC): |
99 | 100 | features: FeatureSet | None = None |
100 | 101 | url: Any = None # URL object, set by subclasses |
101 | 102 |
|
| 103 | + # Set once _build_unprompted_basic_auth() has guessed Basic for a bare 401 |
| 104 | + # (see _should_attempt_unprompted_basic); never cleared, so the guess is |
| 105 | + # only ever made once per client. |
| 106 | + _unprompted_basic_tried: bool = False |
| 107 | + |
102 | 108 | def calendar(self, **kwargs): |
103 | 109 | """Returns a calendar object. |
104 | 110 |
|
@@ -247,8 +253,94 @@ def _build_auth_from_401(self, www_authenticate: str) -> None: |
247 | 253 | "supported authentication methods: basic, digest, bearer" |
248 | 254 | ) |
249 | 255 |
|
| 256 | + def _response_scheme(self, response: Any, url_obj: URL) -> str: |
| 257 | + """Scheme the response actually came back over, falling back to the |
| 258 | + requested URL and then the client's base URL. |
| 259 | +
|
| 260 | + Both niquests/requests and httpx set ``response.url`` to the final |
| 261 | + URL after following any redirects - checking that instead of the |
| 262 | + originally requested ``url_obj`` means a 401 that arrived after an |
| 263 | + https-to-http redirect is judged by where it actually landed, not by |
| 264 | + where the request started. |
| 265 | + """ |
| 266 | + response_url = getattr(response, "url", None) |
| 267 | + if response_url: |
| 268 | + scheme = urlparse(str(response_url)).scheme |
| 269 | + if scheme: |
| 270 | + return scheme |
| 271 | + return url_obj.scheme or getattr(self.url, "scheme", "") |
| 272 | + |
| 273 | + def _should_attempt_unprompted_basic( |
| 274 | + self, status_code: int, headers: Any, get_scheme: Callable[[], str] |
| 275 | + ) -> bool: |
| 276 | + """Return True when a bare 401 (no WWW-Authenticate) warrants a one-shot |
| 277 | + Basic-auth guess (issue #713). |
| 278 | +
|
| 279 | + RFC 7235 section 3.1 requires a 401 to name a scheme; a server that omits |
| 280 | + it (e.g. Yahoo Calendar) leaves nothing to negotiate, so |
| 281 | + ``_should_negotiate_auth`` never fires and the credentials are never |
| 282 | + sent. Absent a declared scheme there is no way to pick the *right* |
| 283 | + one, so this guesses Basic - once, and only: |
| 284 | +
|
| 285 | + - over TLS: never send credentials unprompted towards a plaintext |
| 286 | + endpoint. |
| 287 | + - when nothing already picked a scheme: neither ``auth_type`` (which |
| 288 | + builds the auth object eagerly at init) nor a prior negotiation or |
| 289 | + guess (which would have already set ``self.auth``) took effect. |
| 290 | + - when both a username and a password are configured: there is |
| 291 | + nothing to put in the header otherwise. |
| 292 | + - only once per client: ``_unprompted_basic_tried`` stays set even |
| 293 | + after a wrong guess is unwound (see ``_raise_authorization_error``), |
| 294 | + so a failed guess is not retried on every subsequent request. |
| 295 | +
|
| 296 | + ``get_scheme`` is a callback rather than a plain string so the (lazy, |
| 297 | + occasionally fragile - see URL.__getattr__) scheme lookup only runs on |
| 298 | + the rare unchallenged-401 path, not on every request. |
| 299 | + """ |
| 300 | + if not ( |
| 301 | + status_code == 401 |
| 302 | + and "WWW-Authenticate" not in headers |
| 303 | + and not self.auth |
| 304 | + and self.auth_type is None |
| 305 | + and not self._unprompted_basic_tried |
| 306 | + and self.username is not None |
| 307 | + and self.password is not None |
| 308 | + ): |
| 309 | + return False |
| 310 | + return get_scheme() == "https" |
| 311 | + |
| 312 | + def _build_unprompted_basic_auth(self) -> None: |
| 313 | + """Build a Basic auth object without a server challenge. |
| 314 | +
|
| 315 | + See ``_should_attempt_unprompted_basic`` for the guard. Sets |
| 316 | + ``self.auth_type`` so the guess is visible on introspection, same as |
| 317 | + if the caller had passed ``auth_type="basic"`` themselves. Marks |
| 318 | + ``_unprompted_basic_tried`` so the guess is never repeated, even after |
| 319 | + ``_raise_authorization_error`` unwinds it below. |
| 320 | + """ |
| 321 | + log.warning( |
| 322 | + "Server sent 401 without a WWW-Authenticate header (RFC7235 " |
| 323 | + "violation) - retrying once with Basic auth since no auth_type " |
| 324 | + "was configured. Pass auth_type='basic' explicitly to silence " |
| 325 | + "this warning." |
| 326 | + ) |
| 327 | + self._unprompted_basic_tried = True |
| 328 | + self.auth_type = "basic" |
| 329 | + self.build_auth_object() |
| 330 | + |
250 | 331 | def _raise_authorization_error(self, url_str: str, reason_source: Any) -> NoReturn: |
251 | | - """Raise AuthorizationError, extracting reason from reason_source.reason.""" |
| 332 | + """Raise AuthorizationError, extracting reason from reason_source.reason. |
| 333 | +
|
| 334 | + If this failure is the unprompted-Basic guess coming back wrong (issue |
| 335 | + #713), unwind it first: clear ``self.auth``/``self.auth_type`` so a |
| 336 | + *later* request from the same client can still negotiate a |
| 337 | + server-declared scheme, rather than being permanently stuck sending a |
| 338 | + guess that has already proven wrong. ``_unprompted_basic_tried`` stays |
| 339 | + set, so the guess itself is not repeated. |
| 340 | + """ |
| 341 | + if self._unprompted_basic_tried and self.auth_type == "basic": |
| 342 | + self.auth = None |
| 343 | + self.auth_type = None |
252 | 344 | try: |
253 | 345 | reason = reason_source.reason |
254 | 346 | except AttributeError: |
|
0 commit comments