from __future__ import annotations
import base64
import hashlib
import os
import re
import stat
import typing
from pathlib import Path
from eggfetch.compat.httpx._request import Request
if typing.TYPE_CHECKING:
from collections.abc import AsyncGenerator, Generator
from eggfetch.compat.httpx._response import Response
class Auth:
def auth_flow(self, request: Request) -> Generator[Request, Response, None]:
raise NotImplementedError()
def sync_auth_flow(
self, request: Request
) -> Generator[Request, Response, None]:
yield from self.auth_flow(request)
async def async_auth_flow(
self, request: Request
) -> AsyncGenerator[Request, Response]:
gen = self.auth_flow(request)
request = next(gen)
while True:
response = yield request
try:
request = gen.send(response)
except StopIteration:
break
def __repr__(self) -> str:
return f"<{type(self).__name__}>"
class BasicAuth(Auth):
def __init__(
self,
username: str | None = None,
password: str | None = None,
*,
encoding: str = "latin-1",
) -> None:
self._username = username or ""
self._password = password or ""
self._encoding = encoding
@property
def username(self) -> str:
return self._username
@property
def password(self) -> str:
return self._password
@property
def encoding(self) -> str:
return self._encoding
def _build_auth_header(self) -> str:
credentials = f"{self._username}:{self._password}"
encoded = base64.b64encode(credentials.encode(self._encoding)).decode("ascii")
return f"Basic {encoded}"
def auth_flow(self, request: Request) -> Generator[Request, Response, None]:
request.headers["authorization"] = self._build_auth_header()
yield request
def __repr__(self) -> str:
return f"BasicAuth(username={self._username!r})"
def _parse_challenge(header_value: str) -> dict[str, str]:
match = re.match(r'^Digest\s+(.+)', header_value, re.IGNORECASE)
if not match:
raise ValueError(f"Not a Digest challenge: {header_value!r}")
result: dict[str, str] = {}
remaining = match.group(1)
pattern = re.compile(
r'(\w+)\s*=\s*'
r'(?:'
r'"([^"]*)"' r'|'
r'([^,]+)' r')'
)
for m in pattern.finditer(remaining):
key = m.group(1)
value = m.group(2) if m.group(2) is not None else m.group(3).strip()
result[key] = value
return result
def _digest_hash(algorithm: str) -> typing.Callable[..., str]:
algo_upper = algorithm.upper()
if algo_upper == "MD5":
return lambda data: hashlib.md5(data).hexdigest()
if algo_upper in ("SHA-256", "SHA256"):
return lambda data: hashlib.sha256(data).hexdigest()
return lambda data: hashlib.md5(data).hexdigest()
class DigestAuth(Auth):
def __init__(self, username: str, password: str) -> None:
self._username = username
self._password = password
self._nonce_count = 0
@property
def username(self) -> str:
return self._username
@property
def password(self) -> str:
return self._password
def _build_digest_response(
self,
method: str,
uri: str,
challenge: dict[str, str],
body: bytes | None = None,
) -> str:
realm = challenge.get("realm", "")
nonce = challenge.get("nonce", "")
algorithm = challenge.get("algorithm", "MD5")
opaque = challenge.get("opaque", "")
raw_qop = challenge.get("qop", "")
qop = ""
if raw_qop:
for candidate in raw_qop.split(","):
candidate = candidate.strip().strip('"')
if candidate in ("auth", "auth-int"):
qop = candidate
break
hash_fn = _digest_hash(algorithm)
ha1 = hash_fn(f"{self._username}:{realm}:{self._password}".encode("utf-8"))
if qop == "auth-int" and body is not None:
entity_body_hash = hash_fn(body)
ha2 = hash_fn(f"{method}:{uri}:{entity_body_hash}".encode("utf-8"))
else:
ha2 = hash_fn(f"{method}:{uri}".encode("utf-8"))
self._nonce_count += 1
nc = f"{self._nonce_count:08x}"
import secrets
cnonce = secrets.token_hex(16)
if qop in ("auth", "auth-int"):
response = hash_fn(
f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode("utf-8")
)
else:
response = hash_fn(f"{ha1}:{nonce}:{ha2}".encode("utf-8"))
parts = [
f'username="{self._username}"',
f'realm="{realm}"',
f'nonce="{nonce}"',
f'uri="{uri}"',
f'response="{response}"',
]
if qop in ("auth", "auth-int"):
parts.append(f'qop={qop}')
parts.append(f"nc={nc}")
parts.append(f'cnonce="{cnonce}"')
if opaque:
parts.append(f'opaque="{opaque}"')
if algorithm and algorithm.upper() != "MD5":
parts.append(f"algorithm={algorithm}")
return ", ".join(parts)
def auth_flow(self, request: Request) -> Generator[Request, Response, None]:
response = yield request
if response.status_code != 401:
return
www_auth = response.headers.get("www-authenticate", "")
if not www_auth.lower().startswith("digest"):
return
try:
challenge = _parse_challenge(www_auth)
except ValueError:
return
if challenge.get("stale", "").lower() == "true":
self._nonce_count = 0
url = request.url
uri = url.path
if url.query:
query = url.query.decode("utf-8") if isinstance(url.query, bytes) else url.query
uri = f"{uri}?{query}"
method = request.method
body = request.content
if body is None:
body = b""
digest_header = self._build_digest_response(
method=method,
uri=uri,
challenge=challenge,
body=body,
)
auth_request = Request(
method=request.method,
url=request.url,
headers=request.headers,
content=request.content,
)
auth_request.headers["authorization"] = f"Digest {digest_header}"
yield auth_request
def __repr__(self) -> str:
return f"DigestAuth(username={self._username!r})"
class NetRCAuth(Auth):
def __init__(self, file: str | None = None, *, auth_file: str | None = None) -> None:
path = file if file is not None else auth_file
if path is None:
self._auth_file = Path.home() / ".netrc"
else:
self._auth_file = Path(path)
@property
def auth_file(self) -> Path:
return self._auth_file
@staticmethod
def _parse_netrc(path: Path) -> dict[str, dict[str, str]]:
if not path.is_file():
return {}
if os.name != "nt":
try:
mode = stat.S_IMODE(os.stat(path).st_mode)
if mode & 0o077:
return {}
except OSError:
return {}
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return {}
hosts: dict[str, dict[str, str]] = {}
current_host: str | None = None
current_entry: dict[str, str] = {}
in_macdef = False
for raw_line in content.splitlines():
stripped = raw_line.strip()
if in_macdef:
if not stripped:
in_macdef = False
continue
line = stripped
if not line or line.startswith("#"):
continue
tokens = line.split()
i = 0
while i < len(tokens):
token = tokens[i]
if token == "machine":
if current_host is not None:
hosts[current_host] = current_entry
i += 1
if i < len(tokens):
current_host = tokens[i]
current_entry = {}
elif token == "default":
if current_host is not None:
hosts[current_host] = current_entry
current_host = "default"
current_entry = {}
elif token == "login":
i += 1
if i < len(tokens):
current_entry["login"] = tokens[i]
elif token == "password":
i += 1
if i < len(tokens):
current_entry["password"] = tokens[i]
elif token == "account":
i += 1
if i < len(tokens):
current_entry["account"] = tokens[i]
elif token == "macdef":
in_macdef = True
break
i += 1
if current_host is not None:
hosts[current_host] = current_entry
return hosts
def _lookup_credentials(self, host: str) -> tuple[str, str] | None:
entries = self._parse_netrc(self._auth_file)
if host in entries:
entry = entries[host]
login = entry.get("login", "")
password = entry.get("password", "")
if login:
return (login, password)
if "default" in entries:
entry = entries["default"]
login = entry.get("login", "")
password = entry.get("password", "")
if login:
return (login, password)
return None
def auth_flow(self, request: Request) -> Generator[Request, Response, None]:
host = request.url.host
if host:
creds = self._lookup_credentials(host)
if creds is not None:
login, password = creds
basic = BasicAuth(username=login, password=password)
yield from basic.auth_flow(request)
return
yield request
def __repr__(self) -> str:
return f"NetRCAuth(auth_file={str(self._auth_file)!r})"