Skip to content

Commit d686a91

Browse files
tobixenclaude
andcommitted
fix: guess basic auth once on a bare 401 with no WWW-Authenticate
RFC 7235 section 3.1 requires a 401 to name an authentication scheme. Yahoo Calendar omits it entirely, so `_should_negotiate_auth` never fires, `build_auth_object()` is never called, the credentials are never sent, and the bare 401 surfaces as `AuthorizationError` indistinguishable from a genuinely rejected password. This is a general fallback rather than a feature-flag-gated one, since a first-time connection to an unrecognised server has no compatibility hints to consult yet. Added `_should_attempt_unprompted_basic`/`_build_unprompted_basic_auth` in base_client.py, wired into both the sync and async request loops. The guess fires once, only over TLS, and only when neither `auth_type` nor `auth` was already configured (either would already have taken effect before this code path is reached). #715 documents the same server behaviour via compatibility_hints for known servers; this is the general, unconditional counterpart for servers with no profile at all. Prompt: Look into issue #713. Is it still relevant for the 3.3-series? [#713] Followup-Prompt: I think we may want a general workaround that can work without features specified (but the check and feature-flag for it was created in a parallell branch/pull request). "if no www-auth header given, no auth_type set, and TLS in place, try out basic auth". (sic) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Reviewed-by: Tobias Brox <tobias@redpill-linpro.com>
1 parent 17258c6 commit d686a91

6 files changed

Lines changed: 349 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
1818

1919
* `compatibility_hints`: `auth.www-authenticate` records whether the server sends the `WWW-Authenticate` header RFC7235 section 3.1 requires on a 401, and `auth.www-authenticate.usable-scheme` whether the schemes it offers include one this library implements. A server failing either one never receives your password, and the 401 looks like a rejected one - so it needs `auth_type` pinned in the configuration, and a profile can now say which. Probed by caldav-server-tester. See https://github.com/python-caldav/caldav/issues/713.
2020

21+
### Fixed
22+
23+
* A `401` response with no `WWW-Authenticate` header (RFC 7235 §3.1 requires one, but e.g. Yahoo Calendar omits it) left nothing to negotiate with: `build_auth_object()` was never reached, the credentials were never sent, and the bare 401 surfaced as `AuthorizationError` - indistinguishable from a genuinely rejected password. Over TLS, with no `auth_type`/`auth` already configured, the client now guesses `basic` once before giving up. See https://github.com/python-caldav/caldav/issues/713.
24+
2125
## [3.3.1] - 2026-09-16
2226

2327
The two main things in this release:

‎caldav/async_davclient.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,11 @@ def __init__(
157157
username: Username for authentication.
158158
password: Password for authentication.
159159
auth: Custom auth object (httpx.Auth or niquests AuthBase).
160-
auth_type: Auth type ('bearer', 'digest', or 'basic').
160+
auth_type: Auth type ('bearer', 'digest', or 'basic'). If left unset and a 401
161+
comes back with no WWW-Authenticate header at all (a server bug per RFC 7235 -
162+
seen on Yahoo Calendar), the client guesses 'basic' once, but only over TLS;
163+
pin auth_type explicitly to avoid the guess or to reach such a server over
164+
plain HTTP.
161165
timeout: Request timeout in seconds.
162166
ssl_verify_cert: SSL certificate verification (bool or CA bundle path).
163167
ssl_cert: Client SSL certificate (path or (cert, key) tuple).
@@ -504,6 +508,15 @@ async def _async_request(
504508
self.features.set_feature("http.multiplexing", False)
505509
return await self._async_request(str(url_obj), method, body, headers)
506510

511+
# Handle a 401 with no WWW-Authenticate at all (issue #713): guess Basic once
512+
elif self._should_attempt_unprompted_basic(
513+
r.status_code,
514+
r.headers,
515+
lambda: self._response_scheme(r, url_obj),
516+
):
517+
self._build_unprompted_basic_auth()
518+
return await self._async_request(url, method, body, headers)
519+
507520
# Raise AuthorizationError for 401/403 responses
508521
if response.status in (401, 403):
509522
self._raise_authorization_error(str(url_obj), response)

‎caldav/base_client.py‎

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99

1010
import logging
1111
from abc import ABC, abstractmethod
12-
from collections.abc import Mapping
12+
from collections.abc import Callable, Mapping
1313
from datetime import datetime
1414
from typing import TYPE_CHECKING, Any, NoReturn
15+
from urllib.parse import urlparse
1516

1617
from lxml import etree
1718

@@ -99,6 +100,11 @@ class BaseDAVClient(ABC):
99100
features: FeatureSet | None = None
100101
url: Any = None # URL object, set by subclasses
101102

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+
102108
def calendar(self, **kwargs):
103109
"""Returns a calendar object.
104110
@@ -247,8 +253,94 @@ def _build_auth_from_401(self, www_authenticate: str) -> None:
247253
"supported authentication methods: basic, digest, bearer"
248254
)
249255

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+
250331
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
252344
try:
253345
reason = reason_source.reason
254346
except AttributeError:

‎caldav/davclient.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ def __init__(
204204
auth: A niquests.auth.AuthBase or requests.auth.AuthBase object, may be passed instead of username/password. username and password should be passed as arguments or in the URL
205205
timeout and ssl_verify_cert are passed to niquests.request.
206206
if auth_type is given, the auth-object will be auto-created. Auth_type can be ``bearer``, ``digest`` or ``basic``. Things are likely to work without ``auth_type`` set, but if nothing else the number of requests to the server will be reduced, and some servers may require this to squelch warnings of unexpected HTML delivered from the
207-
server etc.
207+
server etc. If left unset and a 401 comes back with no WWW-Authenticate header at all (a server bug per RFC 7235 - seen on Yahoo Calendar), the client guesses ``basic`` once, but only over TLS; pin auth_type explicitly to avoid the guess or to reach such a server over plain HTTP.
208208
ssl_verify_cert can be the path of a CA-bundle or False.
209209
huge_tree: boolean, enable XMLParser huge_tree to handle big events, beware of security issues, see : https://lxml.de/api/lxml.etree.XMLParser-class.html
210210
features: The default, None, will in version 2.x enable all existing workarounds in the code for backward compability. Otherwise it will expect a FeatureSet or a dict as defined in `caldav.compatibility_hints` and use that to figure out what workarounds are needed.
@@ -852,6 +852,15 @@ def _sync_request(
852852
self._build_auth_from_401(r_headers["WWW-Authenticate"])
853853
return self._sync_request(url, method, body, headers)
854854

855+
# Handle a 401 with no WWW-Authenticate at all (issue #713): guess Basic once
856+
if self._should_attempt_unprompted_basic(
857+
r.status_code,
858+
r_headers,
859+
lambda: self._response_scheme(r, url_obj),
860+
):
861+
self._build_unprompted_basic_auth()
862+
return self._sync_request(url, method, body, headers)
863+
855864
# Raise AuthorizationError for 401/403 after auth attempt
856865
if r.status_code in (401, 403):
857866
self._raise_authorization_error(str(url_obj), r)

‎tests/test_async_davclient.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,6 +1159,113 @@ async def test_rate_limit_max_sleep_stops_adaptive_retries(self):
11591159
await client.request("/")
11601160

11611161

1162+
class TestAsyncUnpromptedBasicAuth:
1163+
"""Issue #713, async side. Mirrors TestUnpromptedBasicAuth in test_caldav_unit.py."""
1164+
1165+
def _make_response(self, status_code, headers=None):
1166+
resp = MagicMock()
1167+
resp.status_code = status_code
1168+
resp.headers = headers or {}
1169+
resp.reason = "Unauthorized" if status_code == 401 else "OK"
1170+
resp.reason_phrase = resp.reason
1171+
return resp
1172+
1173+
@pytest.mark.asyncio
1174+
async def test_401_without_www_authenticate_retries_with_basic_over_tls(self):
1175+
client = AsyncDAVClient(url="https://cal.example.com/", username="user", password="pass")
1176+
client.session.request = AsyncMock(
1177+
side_effect=[self._make_response(401), self._make_response(200)]
1178+
)
1179+
response = await client.request("/")
1180+
assert response.status == 200
1181+
assert client.session.request.call_count == 2
1182+
assert client.auth_type == "basic"
1183+
second_call_kwargs = client.session.request.call_args_list[1].kwargs
1184+
assert second_call_kwargs["auth"] is not None
1185+
1186+
@pytest.mark.asyncio
1187+
async def test_401_without_www_authenticate_over_plain_http_does_not_guess(self):
1188+
client = AsyncDAVClient(url="http://cal.example.com/", username="user", password="pass")
1189+
client.session.request = AsyncMock(return_value=self._make_response(401))
1190+
with pytest.raises(error.AuthorizationError):
1191+
await client.request("/")
1192+
assert client.session.request.call_count == 1
1193+
assert client.auth is None
1194+
1195+
@pytest.mark.asyncio
1196+
async def test_401_without_www_authenticate_does_not_loop_forever(self):
1197+
client = AsyncDAVClient(url="https://cal.example.com/", username="user", password="pass")
1198+
client.session.request = AsyncMock(return_value=self._make_response(401))
1199+
with pytest.raises(error.AuthorizationError):
1200+
await client.request("/")
1201+
assert client.session.request.call_count == 2
1202+
1203+
@pytest.mark.asyncio
1204+
async def test_explicit_auth_type_is_not_overridden(self):
1205+
client = AsyncDAVClient(
1206+
url="https://cal.example.com/",
1207+
username="user",
1208+
password="pass",
1209+
auth_type="digest",
1210+
)
1211+
client.session.request = AsyncMock(return_value=self._make_response(401))
1212+
with pytest.raises(error.AuthorizationError):
1213+
await client.request("/")
1214+
assert client.auth_type == "digest"
1215+
1216+
@pytest.mark.asyncio
1217+
async def test_explicit_auth_object_is_not_overridden(self):
1218+
client = AsyncDAVClient(
1219+
url="https://cal.example.com/",
1220+
username="user",
1221+
password="pass",
1222+
auth=object(),
1223+
)
1224+
client.session.request = AsyncMock(return_value=self._make_response(401))
1225+
with pytest.raises(error.AuthorizationError):
1226+
await client.request("/")
1227+
assert client.session.request.call_count == 1
1228+
assert client.auth_type is None
1229+
1230+
@pytest.mark.asyncio
1231+
async def test_401_with_www_authenticate_still_negotiates_normally(self):
1232+
client = AsyncDAVClient(url="https://cal.example.com/", username="user", password="pass")
1233+
client.session.request = AsyncMock(
1234+
side_effect=[
1235+
self._make_response(401, {"WWW-Authenticate": 'Basic realm="x"'}),
1236+
self._make_response(200),
1237+
]
1238+
)
1239+
response = await client.request("/")
1240+
assert response.status == 200
1241+
assert client.session.request.call_count == 2
1242+
assert client.auth is not None
1243+
assert client.auth_type is None # negotiation doesn't set auth_type, only auth
1244+
1245+
@pytest.mark.asyncio
1246+
async def test_failed_guess_does_not_stick_and_does_not_retry(self):
1247+
client = AsyncDAVClient(url="https://cal.example.com/", username="user", password="pass")
1248+
client.session.request = AsyncMock(return_value=self._make_response(401))
1249+
with pytest.raises(error.AuthorizationError):
1250+
await client.request("/")
1251+
assert client.session.request.call_count == 2
1252+
assert client.auth is None
1253+
assert client.auth_type is None
1254+
assert client._unprompted_basic_tried is True
1255+
1256+
# A later request on the same client, now facing a real challenge, still negotiates.
1257+
client.session.request = AsyncMock(
1258+
side_effect=[
1259+
self._make_response(401, {"WWW-Authenticate": 'Digest realm="x"'}),
1260+
self._make_response(200),
1261+
]
1262+
)
1263+
response = await client.request("/")
1264+
assert response.status == 200
1265+
assert client.auth is not None
1266+
assert client.auth_type is None
1267+
1268+
11621269
class TestAsyncPrincipalCalendar:
11631270
"""``principal.calendar()`` must work with async clients.
11641271

0 commit comments

Comments
 (0)