Skip to content

Conversation

@ordinary-jamie
Copy link
Contributor

@ordinary-jamie ordinary-jamie commented Jul 7, 2023

🎯 Aim

Closes #1476

🤨 Summary of changes

  • Commit 1/4 introduces a utility to create Nonces. This PR elects to use the format recommended in RFC 7616, sec 3.3 MD5(timestamp_in_ms:resource:secret). Note, the RFC recommends the header field ETAG instead of resource/URI, however since there is no mentions of ETAG in FastAPI, the PR chooses to use the more readily available request.url.path
  • Commit 2/4 is a quick fix to make the HTTPDigest error code 401 UNAUTHORISED instead of 403 FORBIDDEN, see RFC 7616, sec 3.3 'the server responds with a "401 Unauthorized" status code and a WWW-Authenticate header field with Digest scheme'
  • Commit 3/4 adds another utility to calculate the digest response field consistent with RFC 7616, sec 3.4.1
  • Commit 4/4 updates the HTTPDigest class to correctly follow the challenge-response framework laid out by RFC 7616.

📋 Implementation notes

  • Authentication via the Dependent HTTPDigestCredentials: The resolved-dependency will be of type HTTPDigestCredentials, which will have an authenticate(username: str, password: str) -> bool method. Because the Digest response is a hashed field, the server can only authenticate a user by comparing the hashed credentials. This method makes this easier for developers.
  • Stateless Nonces: A key motivation for the digest nonce is to prevent against replay attacks, "An implementation might choose not to accept a previously used nonce or a previously used digest, in order to protect against a replay attack." (RFC 7616, sec 3.3), however this would require the server to store some state to track already seen nonces. To avoid this, this PR creates a naive stateless nonce within which only the timestamp is checked.

Not implemented

  • Domain directive: This is optional, since RFC 7616, sec 3.3 states "If this parameter is omitted or its value is empty, the client SHOULD assume that the protection space consists of all URIs on the web-origin."
  • QOP=auth-int: RCC 7616, sec 3.4.3 allows for integrity protection by setting the QOP directive to auth-int. However, this requires hashing the entity-body of the request for which we do not have readily available. "the hash of the entity body, not the message body -- it is computed before any transfer encoding is applied by the sender and after it has been removed by the recipient. Note that this includes multipart boundaries and embedded header fields in each part of any multipart content-type."
  • Nonce counter checks: RFC 7616, sec 3.4 specifies a nc field to allow servers to detect request replays by maintaining its own copy of this count. However, because FastAPI is primarily for RESTful APIs, this PR chooses to not implement this as it would require stateful servers.
  • Unquoted usernames: RFC 7616, sec 3.4.5 allows for non-quoted, non-hashed usernames that are not allowed in ABNF quoted-string production via a username* digest field. This PR does not support this.

✅ Testing: Manually with CURL

We can validate this implementation is consistent with the RFC by using curl --digest.

First we create a simple server:

# srvr.py
from typing import Annotated

import uvicorn

from fastapi import Depends, FastAPI
from fastapi.exceptions import HTTPException
from fastapi.security import HTTPDigest, HTTPDigestCredentials

app = FastAPI()

security = HTTPDigest(realm="[email protected]")


@app.get("/")
async def read_root(digest: Annotated[HTTPDigestCredentials, Depends(security)]):
    if not await digest.authenticate("[email protected]", "password1"):
        raise HTTPException(status_code=401, detail="Invalid user or password")


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=51122)

Then CURL the running server

python srvr.py &

curl -v http://localhost:51122/ --digest -u "[email protected]:password1"
# INFO:     127.0.0.1:64092 - "GET / HTTP/1.1" 401 Unauthorized
# INFO:     127.0.0.1:64092 - "GET / HTTP/1.1" 200 OK

# digest -u "[email protected]:password1"

# *   Trying 127.0.0.1:51122...
# * Connected to localhost (127.0.0.1) port 51122 (#0)
# * Server auth using Digest with user '[email protected]'
# > GET / HTTP/1.1
# > Host: localhost:51122
# > User-Agent: curl/7.88.1
# > Accept: */*
# > 
# < HTTP/1.1 401 Unauthorized
# < date: Fri, 07 Jul 2023 06:52:25 GMT
# < server: uvicorn
# < www-authenticate: Digest nonce="MTY4ODcxMjc0NjQzNzo2NjY2Y2Q3NmY5OmQ0MWQ4Y2Q5OGY=",opaque="17fa8f50160163eb0a955678d6f0c526",algorithm="md5",qop="auth",userhash="false",realm="[email protected]"
# < content-length: 30
# < content-type: application/json
# < 
# * Ignoring the response-body
# * Connection #0 to host localhost left intact
# * Issue another request to this URL: 'http://localhost:51122/'
# * Found bundle for host: 0x14f708b60 [serially]
# * Can not multiplex, even if we wanted to
# * Re-using existing connection #0 with host localhost
# * Server auth using Digest with user '[email protected]'
# > GET / HTTP/1.1
# > Host: localhost:51122
# > Authorization: Digest username="[email protected]", realm="[email protected]", nonce="MTY4ODcxMjc0NjQzNzo2NjY2Y2Q3NmY5OmQ0MWQ4Y2Q5OGY=", uri="/", cnonce="NWRkMDMyZWE4ZDBhZWIxMDE5ZTY3MTQ2MjA4MDNjYmU=", nc=00000001, qop=auth, response="0e08634b73edaaec3e264cdc0a908b73", opaque="17fa8f50160163eb0a955678d6f0c526", algorithm=md5
# > User-Agent: curl/7.88.1
# > Accept: */*
# > 
# < HTTP/1.1 200 OK
# < date: Fri, 07 Jul 2023 06:52:25 GMT
# < server: uvicorn
# < content-length: 4
# < content-type: application/json
# < 
# * Connection #0 to host localhost left intact
# null% 

References

HTTP digest access authentication requires the use of a nonce value to
prevent replay attacks. This commit introduces a simple stateless nonce
for that purpose, following the format recommended by RFC7616 (section
3.3) with a ':' delimited string of timestamp in miliseconds, resource,
and server secret data. Note, the nonce utility hash-and-truncates the
secret and request URL path to limit the size of the nonce to 48 chars.
Also note, the request URL path is used to describe the resource as
opposed to the recommended ETag for compatibility reasons.
RFC7616 (section 3.3) requires that "If a server receives a request for
an access-protected object, and an acceptable Authorization header field
is not sent, the server responds with a "401 Unauthorized" status
code..."
This commit introduces a utility to calculate the HTTP digest access
authentication consistent with the core elements in RFC 7616. This is
an initial implementation of the utility, as many considerations will
need to be considered for more a robust implementation, in particular
quoted-string handling.
This is an initial implementation of HTTP Digest Access Authentication
consistent to [RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616).
As the RFC only provides guidance, and does not enforce all elements of
the standard, this commit only implements the crucial aspects to ensure
the challenge-response negotiation is at least usable. Notably,
non-quoted usernames, and proxy-authentication is not yet supported by
this change. Furthermore, this commit uses a stateless-Nonce generation
as to avoid overcomplicating the design, future changes may wish to
address this to allow servers to validate the nonce is indeed one-time
usage.
@grandintegrator
Copy link

LGTM!!

self.auto_error = auto_error

async def __call__(
opqaue_data = realm.encode() if realm else os.urandom(32)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "opqaue_data" a typo for "opaque_data" ?

@YuriiMotov
Copy link
Member

Reminder for Sebastián: we discussed this on July 18th on Slack

@github-actions github-actions bot added the conflicts Automatically generated when a PR has a merge conflict label Sep 5, 2025
@github-actions
Copy link
Contributor

github-actions bot commented Sep 5, 2025

This pull request has a merge conflict that needs to be resolved.

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

Labels

conflicts Automatically generated when a PR has a merge conflict p3

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve HTTPDigest implementation

5 participants