From 778f9c83b56369357572b1299658e7fe29bc0d30 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 18 Sep 2026 09:02:14 +0200 Subject: [PATCH] feat: add the auth.www-authenticate features RFC7235 section 3.1 requires a 401 to name an authentication scheme, and a server that omits it - or names only schemes this library cannot use - leaves nothing to negotiate: the password is never transmitted and the bare 401 surfaces as an AuthorizationError indistinguishable from a rejected one. The two features record which of the two it is, so the auth_type such a server needs is documented rather than folklore. Probed by caldav-server-tester's CheckWWWAuthenticate. The grouping node derives nothing from its children, since _derive_from_subfeatures skips a child with its own default; six nodes have that shape, so it is filed rather than worked around. Prompt: https://github.com/python-caldav/caldav/issues/713 - please create a check [in the caldav-server-tester project] for this (server expecting basic auth, but not yielding any WWW-Authenticate-header) Followup-Prompt: (comment from the review process) I thought Google used Bearer and not Basic? Meaning that the solution "slap Basic if no auth_scheme given" will fail? Followup-Prompt: (comment from the review process - clarifying that a "documentation issue" found was actually a code bug) a _parent_ that carries it's own default should not derive from the children. However, a parent without a default (and without a check) should always derive from it's children (certainly including _real_ child features - those that do have a check and an explicit default value). If it's a bug, then it should be filed. Assisted-By: Claude Opus 5 Reviewed-by: Tobias Brox --- CHANGELOG.md | 6 ++++++ caldav/compatibility_hints.py | 21 +++++++++++++++++++++ docs/design/TODO_COMPATIBILITY_HINTS.md | 24 ++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75bb2a83..d0c36681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ Changelogs prior to v3.0 are pruned, but are available in the v3.1 release This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), though for pre-releases PEP 440 takes precedence. +## Unreleased + +### Added + +* `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. + ## [3.3.1] - 2026-09-16 The two main things in this release: diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 1935647b..f9cd58f9 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -212,6 +212,27 @@ class FeatureSet: ), "default": {"support": "full"}, }, + "auth": { + ## Grouping node, needed so that the dotted children below resolve; + ## it carries no verdict of its own. Note that it does not derive + ## one from those children either: _derive_from_subfeatures skips + ## every child that has an explicit default, which both of them do, + ## so is_supported("auth") answers from the type default whatever + ## the children say. Five existing nodes have the same shape. + }, + "auth.www-authenticate": { + "description": "Server includes a WWW-Authenticate header in a 401 response, as RFC7235 section 3.1 requires. Without it a client has no scheme to negotiate with: this library builds no auth object, the supplied credentials are never transmitted, and the bare 401 surfaces as an AuthorizationError indistinguishable from a rejected password. 'unsupported' means a 401 came back carrying no WWW-Authenticate at all (Yahoo Calendar); the cure is to pin auth_type so the credentials go out unprompted - which scheme to pin is not something such a server tells you. 'unknown' means no 401 could be provoked, so the question was never put. The behaviour field carries the challenge the server sent, where it sent one.", + "default": {"support": "full"}, + "links": [ + "https://datatracker.ietf.org/doc/html/rfc7235#section-3.1", + "https://github.com/python-caldav/caldav/issues/713", + ], + }, + "auth.www-authenticate.usable-scheme": { + "description": "At least one authentication scheme the server offers in its WWW-Authenticate header is one this library implements (basic, digest or bearer - see _build_auth_from_401). A server naming only Negotiate or NTLM sends a conformant challenge and is still unreachable by negotiation: the library raises NotImplementedError instead, which is the same dead end as a missing header one step later. 'unknown' means no challenge was seen to judge - either no 401 came back, or the 401 carried no header at all (see the parent feature). The behaviour field lists the schemes offered. This is a property of the server, not of the run: it does not take into account which credentials happen to be configured.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc7235#section-2.1"], + }, "well-known": { "description": "Server handles /.well-known/caldav discovery as specified in RFC 6764 section 5. A conformant server should respond with a redirect (301/302/307/308) from /.well-known/caldav to the actual CalDAV endpoint. 'full' means a redirect was observed; 'unsupported' means the server returned 404 or similar; 'unknown' means the check was skipped (e.g. localhost or request failed). Note: well-known is often provided by infrastructure (reverse proxy/hosting) rather than the CalDAV server itself, so 'unknown' is the expected default for self-hosted or test setups.", "default": {"support": "unknown"}, diff --git a/docs/design/TODO_COMPATIBILITY_HINTS.md b/docs/design/TODO_COMPATIBILITY_HINTS.md index f96c8b06..d4e728f0 100644 --- a/docs/design/TODO_COMPATIBILITY_HINTS.md +++ b/docs/design/TODO_COMPATIBILITY_HINTS.md @@ -128,6 +128,30 @@ Possibly into three files (or four, with the original compatibility_hints.py bei There are three quite different things in the file now, the database of the feature names/flags, the database of server compatibility, and the match logic. +## 8. A grouping node cannot derive from a child that has a default + +**Location**: `FeatureSet._derive_from_subfeatures`, the `continue` on +`'default' in subfeature_info` + +A parent *with* its own default rightly ignores its children: it is an +independent capability (`create-calendar` is supported even where +`create-calendar.set-displayname` is not). But the skip is applied from the +other end too — a parent *without* a default, which exists only to group, still +skips every child that has one, and a child having a default is precisely what +marks it as independently probed. So a grouping node whose children are all +real, probed features derives nothing, falls through to `_default()`, and +answers `full` whatever its children say. + +Six nodes have that shape today: `http`, `non-existing-raises-not-found`, +`save-load.event.recurrences.exception`, `save-load.icalendar`, +`url.encode-at` and `auth`. `is_supported("non-existing-raises-not-found")` +returns `full` on a server where both children are `unsupported`. + +**Fix**: skip a child with its own default only when the *parent* has one too. +A grouping node should derive from all its children, real ones included. The +existing OR-semantics note on `principal-search` suggests the derivation rules +want a second look at the same time. + ## Ordering / dependencies Items 3 (old_flags) and 6 (rename) are independent and safe to do first. Items 1, 2,