Skip to content

Credentials are never sent to servers that omit WWW-Authenticate on 401 (e.g. Yahoo Calendar) #713

Description

@leftover-salmon

Summary

When a server responds 401 without a WWW-Authenticate header, DAVClient never
builds an auth object and never transmits the supplied credentials. The bare 401
propagates to the caller as AuthorizationError, which is indistinguishable from a
genuinely rejected password — but the password was never sent.

Yahoo Calendar's CalDAV server (caldav.calendar.yahoo.com) behaves this way. RFC 7235
§3.1 requires WWW-Authenticate on a 401, so this is a server bug, but it is a server
bug that currently makes Yahoo unreachable through this library even with valid
credentials.

Affected versions

Reproduced on 2.1.0 and 2.2.6.

Reproduction — no account or credentials required

The deliberately wrong password is the point: it demonstrates that self.auth is still
None after the request, meaning nothing was ever transmitted.

import caldav

client = caldav.DAVClient(
    url="https://caldav.calendar.yahoo.com/",
    username="anyone@yahoo.com",
    password="does-not-matter",
)
try:
    client.principal()
except Exception as exc:
    print(type(exc).__name__)        # AuthorizationError

print(client.auth)                   # None  <- credential never sent

On 2.1.0 this additionally logs a CRITICAL with an lxml.etree.XMLSyntaxError
traceback, because Yahoo's error body is JSON where XML is expected:

CRITICAL:root:Expected some valid XML from the server, but got this:
b'{"error":{"code": 4003, "message":"Invalid User credential"}}'

The exception is caught and AuthorizationError is raised as above, so this is noise
rather than a second failure — but it is alarming in downstream logs and would disappear
along with the root cause. 2.2.6 does not log it.

With auth_type="basic" the auth object is built up front and the request succeeds
(verified against a real Yahoo account: 207, principal /principals/users/<user>/,
and all calendars enumerate normally).

Server behaviour

$ curl -s -D - -o /dev/null -X PROPFIND https://caldav.calendar.yahoo.com/ -H 'Depth: 0'
HTTP/2 401
content-type: application/json
server: ATS

No WWW-Authenticate header in the response. The server is otherwise a functioning
CalDAV endpoint:

$ curl -s -D - -o /dev/null -X OPTIONS https://caldav.calendar.yahoo.com/
HTTP/2 200
dav: 1, 3, calendar-access
allow: HEAD, MKCOL, POST, PROPFIND, ACL, COPY, REPORT, OPTIONS, PUT, DELETE, MKCALENDAR, MOVE, GET, PROPPATCH

Cause

davclient.py (2.1.0, ~line 950) gates the retry-with-credentials path on the header
being present:

if (
    r.status_code == 401
    and "WWW-Authenticate" in r_headers
    and not self.auth
    and (self.username or self.password)
):
    auth_types = self.extract_auth_types(r_headers["WWW-Authenticate"])
    self.build_auth_object(auth_types)
    ...
    return self.request(url, method, body, headers)

When the header is absent the branch is skipped entirely, so build_auth_object() is
never reached and the 401 is returned as-is.

Proposed fix

When a 401 carries no WWW-Authenticate, and credentials were supplied, and no auth
object exists yet, attempt basic once rather than giving up:

elif (
    r.status_code == 401
    and "WWW-Authenticate" not in r_headers
    and not self.auth
    and self.username
    and self.password
):
    # RFC 7235 requires WWW-Authenticate on a 401, but some servers (Yahoo
    # Calendar) omit it. Without a declared scheme we cannot negotiate, so
    # try basic once before surfacing the 401.
    self.auth_type = "basic"
    self.build_auth_object()
    return self.request(url, method, body, headers)

Guarding on a retry flag would prevent a loop against a server that returns 401 to the
authenticated request as well.

Note on preemptive credentials

This does send Basic credentials to a server that never declared support for them. That
is already what auth_type="basic" does today, it only happens after an explicit 401,
and the connection is TLS. If sending unprompted credentials is considered too
aggressive as a default, an opt-in flag (allow_unprompted_basic=True) would still be a
large improvement over the current behaviour, because the failure today is silent and
misattributed.

Why this is worth fixing rather than documenting

Downstream consumers surface the resulting exception as a rejected credential. Home
Assistant's CalDAV integration reports "invalid authentication", which sends users to
regenerate app passwords, retype them, and suspect their clipboard — none of which can
work, because the credential is never transmitted. The library holds the only evidence
that distinguishes the two cases.

Workaround for anyone who lands here

caldav.DAVClient(url=..., username=..., password=..., auth_type="basic")

Home Assistant's config flow does not expose auth_type, so HA users additionally need a
small custom component wrapping DAVClient.__init__ to inject it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions