Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGES/11128.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Added preemptive digest authentication to :class:`~aiohttp.DigestAuthMiddleware` -- by :user:`bdraco`.

The middleware now reuses authentication credentials for subsequent requests to the same
protection space, improving efficiency by avoiding extra authentication round trips.
This behavior matches how web browsers handle digest authentication and follows
:rfc:`7616#section-3.6`.

Preemptive authentication is enabled by default but can be disabled by passing
``preemptive=False`` to the middleware constructor.
1 change: 1 addition & 0 deletions CHANGES/11129.feature.rst
62 changes: 59 additions & 3 deletions aiohttp/client_middleware_digest_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class DigestAuthChallenge(TypedDict, total=False):
qop: str
algorithm: str
opaque: str
domain: str
stale: str


DigestFunctions: Dict[str, Callable[[bytes], "hashlib._Hash"]] = {
Expand Down Expand Up @@ -81,13 +83,17 @@ class DigestAuthChallenge(TypedDict, total=False):

# RFC 7616: Challenge parameters to extract
CHALLENGE_FIELDS: Final[
Tuple[Literal["realm", "nonce", "qop", "algorithm", "opaque"], ...]
Tuple[
Literal["realm", "nonce", "qop", "algorithm", "opaque", "domain", "stale"], ...
]
] = (
"realm",
"nonce",
"qop",
"algorithm",
"opaque",
"domain",
"stale",
)

# Supported digest authentication algorithms
Expand Down Expand Up @@ -159,6 +165,7 @@ class DigestAuthMiddleware:
- Supports 'auth' and 'auth-int' quality of protection modes
- Properly handles quoted strings and parameter parsing
- Includes replay attack protection with client nonce count tracking
- Supports preemptive authentication per RFC 7616 Section 3.6

Standards compliance:
- RFC 7616: HTTP Digest Access Authentication (primary reference)
Expand All @@ -175,6 +182,7 @@ def __init__(
self,
login: str,
password: str,
preemptive: bool = True,
) -> None:
if login is None:
raise ValueError("None is not allowed as login value")
Expand All @@ -192,6 +200,9 @@ def __init__(
self._last_nonce_bytes = b""
self._nonce_count = 0
self._challenge: DigestAuthChallenge = {}
self._preemptive: bool = preemptive
# Set of URLs defining the protection space
self._protection_space: List[str] = []

async def _encode(
self, method: str, url: URL, body: Union[Payload, Literal[b""]]
Expand Down Expand Up @@ -354,6 +365,26 @@ def KD(s: bytes, d: bytes) -> bytes:

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

def _in_protection_space(self, url: URL) -> bool:
"""
Check if the given URL is within the current protection space.

According to RFC 7616, a URI is in the protection space if any URI
in the protection space is a prefix of it (after both have been made absolute).
"""
request_str = str(url)
for space_str in self._protection_space:
# Check if request starts with space URL
if not request_str.startswith(space_str):
continue
# Exact match or space ends with / (proper directory prefix)
if len(request_str) == len(space_str) or space_str[-1] == "/":
return True
# Check next char is / to ensure proper path boundary
if request_str[len(space_str)] == "/":
return True
return False

def _authenticate(self, response: ClientResponse) -> bool:
"""
Takes the given response and tries digest-auth, if needed.
Expand Down Expand Up @@ -391,6 +422,25 @@ def _authenticate(self, response: ClientResponse) -> bool:
if value := header_pairs.get(field):
self._challenge[field] = value

# Update protection space based on domain parameter or default to origin
origin = response.url.origin()

if domain := self._challenge.get("domain"):
# Parse space-separated list of URIs
self._protection_space = []
for uri in domain.split():
# Remove quotes if present
uri = uri.strip('"')
if uri.startswith("/"):
# Path-absolute, relative to origin
self._protection_space.append(str(origin.join(URL(uri))))
else:
# Absolute URI
self._protection_space.append(str(URL(uri)))
else:
# No domain specified, protection space is entire origin
self._protection_space = [str(origin)]

# Return True only if we found at least one challenge parameter
return bool(self._challenge)

Expand All @@ -400,8 +450,14 @@ async def __call__(
"""Run the digest auth middleware."""
response = None
for retry_count in range(2):
# Apply authorization header if we have a challenge (on second attempt)
if retry_count > 0:
# Apply authorization header if:
# 1. This is a retry after 401 (retry_count > 0), OR
# 2. Preemptive auth is enabled AND we have a challenge AND the URL is in protection space
if retry_count > 0 or (
self._preemptive
and self._challenge
and self._in_protection_space(request.url)
):
request.headers[hdrs.AUTHORIZATION] = await self._encode(
request.method, request.url, request.body
)
Expand Down
34 changes: 33 additions & 1 deletion docs/client_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2300,12 +2300,13 @@ Utilities
:return: encoded authentication data, :class:`str`.


.. class:: DigestAuthMiddleware(login, password)
.. class:: DigestAuthMiddleware(login, password, *, preemptive=True)

HTTP digest authentication client middleware.

:param str login: login
:param str password: password
:param bool preemptive: Enable preemptive authentication (default: ``True``)

This middleware supports HTTP digest authentication with both `auth` and
`auth-int` quality of protection (qop) modes, and a variety of hashing algorithms.
Expand All @@ -2315,6 +2316,31 @@ Utilities
- Parsing 401 Unauthorized responses with `WWW-Authenticate: Digest` headers
- Generating appropriate `Authorization: Digest` headers on retry
- Maintaining nonce counts and challenge data per request
- When ``preemptive=True``, reusing authentication credentials for subsequent
requests to the same protection space (following RFC 7616 Section 3.6)

**Preemptive Authentication**

By default (``preemptive=True``), the middleware remembers successful authentication
challenges and automatically includes the Authorization header in subsequent requests
to the same protection space. This behavior:

- Improves server efficiency by avoiding extra round trips
- Matches how modern web browsers handle digest authentication
- Follows the recommendation in RFC 7616 Section 3.6

The server may still respond with a 401 status and ``stale=true`` if the nonce
has expired, in which case the middleware will automatically retry with the new nonce.

To disable preemptive authentication and require a 401 challenge for every request,
set ``preemptive=False``::

# Default behavior - preemptive auth enabled
digest_auth_middleware = DigestAuthMiddleware(login="user", password="pass")

# Disable preemptive auth - always wait for 401 challenge
digest_auth_middleware = DigestAuthMiddleware(login="user", password="pass",
preemptive=False)

Usage::

Expand All @@ -2324,7 +2350,13 @@ Utilities
# The middleware automatically handles the digest auth handshake
assert resp.status == 200

# Subsequent requests include auth header preemptively
async with session.get("http://protected.example.com/other") as resp:
assert resp.status == 200 # No 401 round trip needed

.. versionadded:: 3.12
.. versionchanged:: 3.12.8
Added ``preemptive`` parameter to enable/disable preemptive authentication.


.. class:: CookieJar(*, unsafe=False, quote_cookie=True, treat_as_secure_origin = [])
Expand Down
Loading
Loading