Following my comment in rednafi's answer: to make the browser prompt the input of credentials in HTTP Digest Auth, two conditions have to be met.
Unfortunately headers={"WWW-Authenticate": "Digest"} is not considered "proper" enough (at least by my Edge chromium), probably because it doesn't tell the browser which algorithm for digest to use. My testing shows at least nonce and realm are required.
One can refer to the wiki for HTTP Digest Auth for details of how the digest algorithm works. I didn't find any material about using Base64 encoding in the digest as in the example test-suites posted by rednafi. MDN says the algorithm should be MD5 or one of SHA family.
Of course you are fine with it if you manually specify the Autherization headers in every request. But if you want to use the browser's prompt to input credentials, here is an working example:
from pydantic import BaseModel, ValidationError
from ..config import AppSettings, get_config
from fastapi.security import (
HTTPDigest,
HTTPAuthorizationCredentials,
)
from fastapi import Depends, HTTPException, Request, Security
from fastapi.responses import JSONResponse
import secrets
import base64
from hashlib import md5
from typing import Annotated
security = HTTPDigest(auto_error=False)
class HTTPDigestCredentials(BaseModel):
username: str
realm: str
nonce: str
uri: str
response: str
@classmethod
def from_digest_line(cls, digest_line: str):
"""Parse the digest line and return a dict of fields"""
cred_dict = {}
try:
cred_fields = [s.strip() for s in digest_line.split(",")]
for field, value in [s.split("=", maxsplit=1) for s in cred_fields]:
# remove quotes
cred_dict[field] = value.strip('"')
cred_obj = cls.model_validate(cred_dict)
except (ValueError, ValidationError):
return None
return cred_obj
async def auth_admin(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials, Security(security)],
config: AppSettings = Depends(get_config),
):
""" """
# http digest headers
digest_params = {
"realm": "admin-panel",
# "qop": "auth",
# "algorithm": "SHA-256",
"nonce": secrets.token_hex(8),
# "opaque": secrets.token_hex(8),
}
digest_line = ",".join(
f'{key}="{value}"' for key, value in digest_params.items()
)
login_fail_exception = HTTPException(
401,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": f'Digest {digest_line}'},
)
# if no Authorization header is present, credentials will be None
if credentials is None:
raise login_fail_exception
# we use pydantic to validate the requested credential strings
current_cred = HTTPDigestCredentials.from_digest_line(credentials.credentials)
if current_cred is None:
raise login_fail_exception
# caluculate response
# https://en.wikipedia.org/wiki/Digest_access_authentication#Overview
admin_config = config.ADMIN_PANEL
# We don't store plain passwords but HA1 instead
# HA1 = md5(f"{admin_config.username}:{admin_config.realm}:{admin_config.password}".encode()).hexdigest()
HA1 = admin_config.token
HA2 = md5(f"{request.method}:{current_cred.uri}".encode()).hexdigest()
expected_response = md5(f"{HA1}:{current_cred.nonce}:{HA2}".encode()).hexdigest()
correct_token = secrets.compare_digest(current_cred.response, expected_response)
if not correct_token:
raise login_fail_exception
return JSONResponse({"user": admin_config.username})
In this example, I used the default algorithm and qop, which is based on MD5. The first part MD5(username:realm:password) contains all credentials (you probably want to store this hash instead of plain username/password), and the second part {method}:{uri} depends on request. nonce is dynamically generated to prevent replay attacks.
The credentials extracted by HTTPAuthorizationCredentials is actually a strings with format xx="xxx", yy=yyy. I ended up processing it on my own, but I really think this should be part of the fastapi project, perhaps with a separate HTTPDigestCredentials class or similar.