authchallenge 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ """Parse and build HTTP authentication headers: challenges, credentials, Authentication-Info."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._basic import decode_basic, encode_basic
6
+ from ._build import build_auth_info, build_authorization, build_challenge
7
+ from ._extvalue import decode_ext_value, encode_ext_value
8
+ from ._helpers import build_bearer_challenge, select_challenge
9
+ from ._model import AuthError, Challenge, Credentials
10
+ from ._parse import parse_auth_info, parse_authorization, parse_challenges
11
+
12
+ __version__ = "0.2.0"
13
+
14
+ __all__ = [
15
+ "AuthError",
16
+ "Challenge",
17
+ "Credentials",
18
+ "build_auth_info",
19
+ "build_authorization",
20
+ "build_bearer_challenge",
21
+ "build_challenge",
22
+ "decode_basic",
23
+ "decode_ext_value",
24
+ "encode_basic",
25
+ "encode_ext_value",
26
+ "parse_auth_info",
27
+ "parse_authorization",
28
+ "parse_challenges",
29
+ "select_challenge",
30
+ ]
@@ -0,0 +1,9 @@
1
+ """Allow `python -m authchallenge`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ sys.exit(main())
@@ -0,0 +1,81 @@
1
+ """Basic credentials (RFC 7617): user-pass encoding and decoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import codecs
8
+
9
+ from ._grammar import is_token68
10
+ from ._model import AuthError, Credentials
11
+
12
+
13
+ def _has_control(text: str) -> bool:
14
+ # RFC 7617 refers to CTL of RFC 5234: the ASCII controls and DEL (see erratum 9174).
15
+ return any(ord(char) < 0x20 or ord(char) == 0x7F for char in text)
16
+
17
+
18
+ def _codec(charset: str) -> str:
19
+ # RFC 7617 needs an encoding that maps US-ASCII to single octets: UTF-8 or ISO-8859-1.
20
+ try:
21
+ name = codecs.lookup(charset).name
22
+ except LookupError:
23
+ name = ""
24
+ if name not in ("utf-8", "iso8859-1"):
25
+ raise AuthError(f"unsupported Basic charset {charset!r}: use 'utf-8' or 'latin-1'")
26
+ return name
27
+
28
+
29
+ def split_user_pass(token68: str, charset: str) -> tuple[str, str]:
30
+ """Decode a Basic token68 into (user-id, password), raising AuthError when malformed."""
31
+ if not is_token68(token68):
32
+ raise AuthError(f"invalid token68: {token68!r}")
33
+ try:
34
+ raw = base64.b64decode(token68, validate=True)
35
+ except (binascii.Error, ValueError) as error:
36
+ raise AuthError("Basic credentials are not valid base64") from error
37
+ try:
38
+ text = raw.decode(_codec(charset))
39
+ except UnicodeDecodeError as error:
40
+ raise AuthError(f"Basic credentials are not valid {charset}") from error
41
+ user_id, colon, password = text.partition(":")
42
+ if not colon:
43
+ raise AuthError("Basic credentials lack the ':' between user-id and password")
44
+ if _has_control(text):
45
+ raise AuthError("Basic user-id and password must not contain control characters")
46
+ return user_id, password
47
+
48
+
49
+ def encode_basic(user_id: str, password: str, *, charset: str = "utf-8") -> str:
50
+ """Build `Basic <base64>` credentials for an Authorization header (RFC 7617).
51
+
52
+ The user-id may not contain a colon, and neither part may contain control characters.
53
+ UTF-8 is what a server asks for with `charset="UTF-8"`; pass "latin-1" for servers
54
+ that expect ISO-8859-1; other charsets raise AuthError. The text is encoded as
55
+ given, without Unicode normalisation.
56
+ """
57
+ if ":" in user_id:
58
+ raise AuthError("a Basic user-id cannot contain ':'")
59
+ if _has_control(user_id) or _has_control(password):
60
+ raise AuthError("Basic user-id and password must not contain control characters")
61
+ try:
62
+ raw = f"{user_id}:{password}".encode(_codec(charset))
63
+ except UnicodeEncodeError as error:
64
+ raise AuthError(f"the credentials cannot be encoded as {charset}") from error
65
+ return "Basic " + base64.b64encode(raw).decode("ascii")
66
+
67
+
68
+ def decode_basic(credentials: str | Credentials, *, charset: str = "utf-8") -> tuple[str, str]:
69
+ """Return (user-id, password) from Basic credentials, raising AuthError if malformed.
70
+
71
+ Accepts the Authorization field value (`Basic QWxh...`) or a parsed `Credentials`.
72
+ The text after the first colon is the password, as RFC 7617 section 2 says.
73
+ """
74
+ if isinstance(credentials, str):
75
+ scheme, _, token68 = credentials.strip(" \t").replace("\t", " ").partition(" ")
76
+ token68 = token68.strip(" ")
77
+ else:
78
+ scheme, token68 = credentials.scheme, credentials.token68 or ""
79
+ if scheme.lower() != "basic":
80
+ raise AuthError(f"not Basic credentials: {scheme!r}")
81
+ return split_user_pass(token68, charset)
@@ -0,0 +1,100 @@
1
+ """Build challenge, credentials and Authentication-Info field values safely (RFC 9110)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from ._grammar import is_field_text, is_token, is_token68, quote
8
+ from ._model import AuthError
9
+ from ._scan import Param
10
+ from ._schemes import DIGEST_BARE, DIGEST_QUOTED, check
11
+
12
+
13
+ def _must_quote(scheme: str, kind: str, name: str, value: str) -> bool:
14
+ key = scheme.lower()
15
+ if key == "digest" and name in DIGEST_BARE[kind]:
16
+ if not is_token(value):
17
+ raise AuthError(f"the Digest {name} parameter must be a token: {value!r}")
18
+ return False
19
+ if name == "realm" or key in ("basic", "bearer"):
20
+ return True
21
+ if key == "digest" and name in DIGEST_QUOTED[kind]:
22
+ return True
23
+ return not is_token(value)
24
+
25
+
26
+ def _render_params(scheme: str, kind: str, params: Mapping[str, str]) -> tuple[str, list[Param]]:
27
+ rendered: list[str] = []
28
+ read: list[Param] = []
29
+ seen: set[str] = set()
30
+ for key, value in params.items():
31
+ if not isinstance(key, str) or not is_token(key):
32
+ raise AuthError(f"invalid auth-param name: {key!r}")
33
+ name = key.lower()
34
+ if name in seen:
35
+ raise AuthError(f"duplicate auth-param name: {key!r}")
36
+ if not isinstance(value, str) or not is_field_text(value):
37
+ raise AuthError(
38
+ f"the value of {key!r} may hold only tab, space and visible ASCII; "
39
+ "send other text as an RFC 8187 ext-value (see encode_ext_value)"
40
+ )
41
+ quoted = _must_quote(scheme, kind, name, value)
42
+ rendered.append(f"{key}={quote(value) if quoted else value}")
43
+ read.append(Param(name, value, quoted, 0))
44
+ seen.add(name)
45
+ return ", ".join(rendered), read
46
+
47
+
48
+ def render(scheme: str, params: Mapping[str, str] | None, token68: str | None, kind: str) -> str:
49
+ """Render a challenge or credentials, refusing anything strict parsing would refuse."""
50
+ if not isinstance(scheme, str) or not is_token(scheme):
51
+ raise AuthError(f"invalid auth-scheme: {scheme!r}")
52
+ if token68 is not None and params:
53
+ raise AuthError("use either token68 or params, not both")
54
+ if token68 is not None:
55
+ if not isinstance(token68, str) or not is_token68(token68):
56
+ raise AuthError(f"invalid token68: {token68!r}")
57
+ check(scheme, kind, token68, [])
58
+ return f"{scheme} {token68}"
59
+ text, read = _render_params(scheme, kind, params or {})
60
+ check(scheme, kind, None, read)
61
+ return f"{scheme} {text}" if text else scheme
62
+
63
+
64
+ def build_challenge(
65
+ scheme: str,
66
+ params: Mapping[str, str] | None = None,
67
+ token68: str | None = None,
68
+ ) -> str:
69
+ """Build one challenge for a WWW-Authenticate or Proxy-Authenticate field.
70
+
71
+ Values are quoted when they are not tokens and whenever the scheme's sender rules
72
+ say so (realm always, every Basic and Bearer param as their RFC examples do, the
73
+ Digest lists of RFC 7616). Raises
74
+ `AuthError` for invalid names or values and for what Basic, Bearer or Digest forbid,
75
+ so the result always passes `parse_challenges(..., strict=True)`.
76
+ """
77
+ return render(scheme, params, token68, "challenge")
78
+
79
+
80
+ def build_authorization(
81
+ scheme: str,
82
+ params: Mapping[str, str] | None = None,
83
+ token68: str | None = None,
84
+ ) -> str:
85
+ """Build an Authorization or Proxy-Authorization field value, under the same rules."""
86
+ return render(scheme, params, token68, "credentials")
87
+
88
+
89
+ def build_auth_info(params: Mapping[str, str], *, scheme: str | None = None) -> str:
90
+ """Build an Authentication-Info or Proxy-Authentication-Info field value.
91
+
92
+ Give the `scheme` of the request's credentials to apply its rules (Digest quotes
93
+ nextnonce, rspauth and cnonce, writes qop and nc bare, and checks nc).
94
+ """
95
+ text, read = _render_params(scheme or "", "info", params)
96
+ if not text:
97
+ raise AuthError("Authentication-Info needs at least one auth-param")
98
+ if scheme is not None:
99
+ check(scheme, "info", None, read)
100
+ return text
authchallenge/_cli.py ADDED
@@ -0,0 +1,94 @@
1
+ """Command line: print parsed authentication fields as JSON, or lint them with --strict."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from typing import Any
11
+
12
+ from ._model import AuthError, Challenge, Credentials
13
+ from ._parse import parse_auth_info, parse_authorization, parse_challenges
14
+
15
+ _KINDS = {
16
+ "www-authenticate": "challenges",
17
+ "proxy-authenticate": "challenges",
18
+ "authorization": "credentials",
19
+ "proxy-authorization": "credentials",
20
+ "authentication-info": "info",
21
+ "proxy-authentication-info": "info",
22
+ }
23
+ _FIELD_LINE = re.compile(r"([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*(.*)")
24
+
25
+
26
+ def _fields(text: str, default: str) -> list[tuple[str, str]]:
27
+ """Return (field name, value) pairs from a header dump, or one per plain line.
28
+
29
+ The first non-empty line decides: a status line or a `Name: value` line makes the
30
+ whole input a header dump, of which only the authentication fields are read.
31
+ """
32
+ lines = [line.rstrip("\r") for line in text.splitlines()]
33
+ first = next((line for line in lines if line.strip()), "")
34
+ dump = first.startswith("HTTP/") or _FIELD_LINE.fullmatch(first) is not None
35
+ if not dump:
36
+ return [(default, line.strip()) for line in lines if line.strip()]
37
+ pairs: list[tuple[str, str]] = []
38
+ for line in lines:
39
+ match = _FIELD_LINE.fullmatch(line)
40
+ if match is not None:
41
+ pairs.append((match.group(1), match.group(2)))
42
+ elif line[:1] in (" ", "\t") and pairs:
43
+ pairs[-1] = (pairs[-1][0], pairs[-1][1] + "\r\n" + line)
44
+ return [(name, value) for name, value in pairs if name.lower() in _KINDS]
45
+
46
+
47
+ def _plain(found: Challenge | Credentials) -> dict[str, Any]:
48
+ return {"scheme": found.scheme, "token68": found.token68, "params": found.params}
49
+
50
+
51
+ def _report(name: str, value: str, strict: bool) -> dict[str, Any]:
52
+ kind = _KINDS[name.lower()]
53
+ entry: dict[str, Any] = {"field": name}
54
+ try:
55
+ if kind == "challenges":
56
+ entry[kind] = [_plain(c) for c in parse_challenges(value, strict=strict)]
57
+ elif kind == "credentials":
58
+ entry[kind] = _plain(parse_authorization(value, strict=strict))
59
+ else:
60
+ entry[kind] = parse_auth_info(value, strict=strict)
61
+ except AuthError as error:
62
+ entry["error"] = str(error)
63
+ return entry
64
+
65
+
66
+ def main(argv: Sequence[str] | None = None) -> int:
67
+ """Run the command line; return 0, or 1 when strict mode found a problem."""
68
+ parser = argparse.ArgumentParser(
69
+ prog="authchallenge",
70
+ description="Parse WWW-Authenticate, Authorization and Authentication-Info values. "
71
+ "Values come as arguments or one per stdin line; a header dump such as "
72
+ "`curl -sI` output is also read from stdin.",
73
+ )
74
+ parser.add_argument("values", nargs="*", help="field values (default: read stdin)")
75
+ group = parser.add_mutually_exclusive_group()
76
+ group.add_argument("--authorization", action="store_true", help="values are credentials")
77
+ group.add_argument("--info", action="store_true", help="values are Authentication-Info")
78
+ parser.add_argument("--strict", action="store_true", help="exit 1 on the first broken rule")
79
+ args = parser.parse_args(argv)
80
+ default = "WWW-Authenticate"
81
+ if args.authorization or args.info:
82
+ default = "Authorization" if args.authorization else "Authentication-Info"
83
+ if args.values:
84
+ fields = [(default, value) for value in args.values]
85
+ else:
86
+ fields = _fields(sys.stdin.read(), default)
87
+ if not fields:
88
+ parser.error("no field values given")
89
+ report = [_report(name, value, args.strict) for name, value in fields]
90
+ print(json.dumps(report, indent=2))
91
+ errors = [entry for entry in report if "error" in entry]
92
+ for entry in errors:
93
+ print(f"authchallenge: strict: {entry['field']}: {entry['error']}", file=sys.stderr)
94
+ return 1 if errors else 0
@@ -0,0 +1,42 @@
1
+ """RFC 8187 ext-value, the encoding of parameters such as Digest `username*`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from urllib.parse import quote, unquote_to_bytes
7
+
8
+ from ._model import AuthError
9
+
10
+ # attr-char of RFC 8187 section 3.2.1: token characters except "*", "'" and "%".
11
+ _ATTR_CHAR = "!#$&+-.^_`|~"
12
+ _EXT_VALUE = re.compile(
13
+ r"(?P<charset>[A-Za-z0-9!#$%&+\-^_`{}~]+)'(?P<language>[A-Za-z0-9-]*)'"
14
+ r"(?P<value>(?:%[0-9A-Fa-f]{2}|[A-Za-z0-9!#$&+\-.^_`|~])*)"
15
+ )
16
+
17
+
18
+ def encode_ext_value(text: str) -> str:
19
+ """Encode text as an RFC 8187 ext-value: `UTF-8''` and percent-encoded UTF-8."""
20
+ return "UTF-8''" + quote(text, safe=_ATTR_CHAR, encoding="utf-8")
21
+
22
+
23
+ def decode_ext_value(value: str) -> str:
24
+ """Decode an RFC 8187 ext-value, raising AuthError when malformed or not UTF-8.
25
+
26
+ RFC 8187 makes UTF-8 the only charset producers may use; ISO-8859-1 is also read,
27
+ since RFC 5987 (which it replaces) allowed it. Control characters (C0, DEL, C1) in
28
+ the decoded text are refused, so a decoded name cannot carry a line break.
29
+ """
30
+ match = _EXT_VALUE.fullmatch(value)
31
+ if match is None:
32
+ raise AuthError(f"invalid ext-value: {value!r}")
33
+ charset = match.group("charset").lower()
34
+ if charset not in ("utf-8", "iso-8859-1"):
35
+ raise AuthError(f"unsupported ext-value charset: {match.group('charset')!r}")
36
+ try:
37
+ text = unquote_to_bytes(match.group("value")).decode(charset)
38
+ except UnicodeDecodeError as error:
39
+ raise AuthError(f"ext-value is not valid {charset}") from error
40
+ if any(ord(char) < 0x20 or 0x7F <= ord(char) < 0xA0 for char in text):
41
+ raise AuthError("ext-value decodes to control characters")
42
+ return text
@@ -0,0 +1,63 @@
1
+ """Character classes and quoting of RFC 9110: token, token68, quoted-string, field text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # tchar of RFC 9110 section 5.6.2: auth-scheme and auth-param names are tokens.
8
+ TCHAR = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
9
+ # token68 of RFC 9110 section 11.2, without its trailing "=" padding.
10
+ TOKEN68_CHARS = "-._~+/0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
11
+
12
+ _TCHAR_CLASS = "[" + re.escape(TCHAR) + "]"
13
+ TOKEN_RE = _TCHAR_CLASS + "+"
14
+ TOKEN68_RE = "[" + re.escape(TOKEN68_CHARS) + "]+=*"
15
+ QUOTED_RE = r'"(?:[^"\\]|\\.)*"'
16
+ _TOKEN = re.compile(TOKEN_RE)
17
+ _TOKEN68 = re.compile(TOKEN68_RE)
18
+ _QUOTED_PAIR = re.compile(r"\\(.)", re.DOTALL)
19
+
20
+
21
+ def is_token(value: str) -> bool:
22
+ """Whether the value is a non-empty RFC 9110 token."""
23
+ return _TOKEN.fullmatch(value) is not None
24
+
25
+
26
+ def is_token68(value: str) -> bool:
27
+ """Whether the value is an RFC 9110 token68 (letters, digits, -._~+/ then padding)."""
28
+ return _TOKEN68.fullmatch(value) is not None
29
+
30
+
31
+ def is_field_text(value: str) -> bool:
32
+ """Whether a sender may write the value: HTAB, SP and visible US-ASCII only.
33
+
34
+ RFC 9110 section 5.5 asks new fields to stay within VCHAR, SP and HTAB. Anything else,
35
+ CR and LF above all, would let a value break out of the header line.
36
+ """
37
+ return all(char == "\t" or " " <= char <= "~" for char in value)
38
+
39
+
40
+ def bad_octet(value: str) -> int | None:
41
+ """Offset of the first character a field value cannot hold, or None.
42
+
43
+ Field values are octets: HTAB, SP, VCHAR and obs-text (0x80 to 0xFF). Controls, DEL
44
+ and code points above 0xFF are refused.
45
+ """
46
+ for index, char in enumerate(value):
47
+ code = ord(char)
48
+ if (code < 0x20 and char != "\t") or code == 0x7F or code > 0xFF:
49
+ return index
50
+ return None
51
+
52
+
53
+ def quote(value: str) -> str:
54
+ """Wrap the value in a quoted-string, escaping only backslash and double quote."""
55
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
56
+ return f'"{escaped}"'
57
+
58
+
59
+ def unquote(value: str) -> str:
60
+ """Strip the quotes of a quoted-string and resolve its quoted-pairs."""
61
+ if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
62
+ return _QUOTED_PAIR.sub(r"\1", value[1:-1])
63
+ return value
@@ -0,0 +1,53 @@
1
+ """Conveniences on top of the codec: Bearer challenges and challenge selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from ._build import build_challenge
8
+ from ._model import AuthError, Challenge
9
+
10
+
11
+ def build_bearer_challenge(
12
+ *,
13
+ realm: str | None = None,
14
+ scope: str | Iterable[str] | None = None,
15
+ error: str | None = None,
16
+ error_description: str | None = None,
17
+ error_uri: str | None = None,
18
+ ) -> str:
19
+ """Build a Bearer challenge (RFC 6750 section 3) with its attributes quoted.
20
+
21
+ `scope` is a space-delimited string or an iterable of scope tokens. Each attribute is
22
+ checked against the character set RFC 6750 allows; `error` is usually one of
23
+ invalid_request, invalid_token or insufficient_scope (section 3.1). At least one
24
+ attribute is required.
25
+ """
26
+ if scope is not None and not isinstance(scope, str):
27
+ tokens = list(scope)
28
+ if not tokens or not all(isinstance(t, str) and t and " " not in t for t in tokens):
29
+ raise AuthError("scope tokens are non-empty strings without spaces")
30
+ scope = " ".join(tokens)
31
+ attributes = {
32
+ "realm": realm,
33
+ "scope": scope,
34
+ "error": error,
35
+ "error_description": error_description,
36
+ "error_uri": error_uri,
37
+ }
38
+ params = {name: value for name, value in attributes.items() if value is not None}
39
+ return build_challenge("Bearer", params)
40
+
41
+
42
+ def select_challenge(challenges: Iterable[Challenge], *schemes: str) -> Challenge | None:
43
+ """Return the first challenge of the most preferred scheme, or None.
44
+
45
+ `schemes` lists the schemes the caller supports, best first; they are compared
46
+ case-insensitively, as RFC 9110 section 11.1 requires.
47
+ """
48
+ offered = list(challenges)
49
+ for wanted in schemes:
50
+ for challenge in offered:
51
+ if challenge.scheme.lower() == wanted.lower():
52
+ return challenge
53
+ return None
@@ -0,0 +1,31 @@
1
+ """Typed result objects for parsed challenges and credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ class AuthError(ValueError):
9
+ """Raised when a header cannot be built, or fails strict parsing, with the reason."""
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Challenge:
14
+ """One authentication challenge: a scheme with either a token68 or auth-params.
15
+
16
+ The scheme keeps the case it was written in (compare it case-insensitively); param
17
+ names are lower-cased, and values have their quoting removed.
18
+ """
19
+
20
+ scheme: str
21
+ token68: str | None = None
22
+ params: dict[str, str] = field(default_factory=dict)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Credentials:
27
+ """Authorization credentials: a scheme with either a token68 or auth-params."""
28
+
29
+ scheme: str
30
+ token68: str | None = None
31
+ params: dict[str, str] = field(default_factory=dict)
@@ -0,0 +1,167 @@
1
+ """Parse WWW-Authenticate, Authorization and Authentication-Info field values (RFC 9110)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable
6
+ from dataclasses import dataclass, field
7
+ from typing import TypeVar
8
+
9
+ from ._model import AuthError, Challenge, Credentials
10
+ from ._scan import Item, Param, classify, fail, prepare, split
11
+ from ._schemes import check
12
+
13
+ _T = TypeVar("_T")
14
+
15
+
16
+ @dataclass
17
+ class _Draft:
18
+ scheme: str
19
+ offset: int
20
+ token68: str | None
21
+ open: bool
22
+ params: dict[str, Param] = field(default_factory=dict)
23
+
24
+ def values(self) -> dict[str, str]:
25
+ return {name: param.value for name, param in self.params.items()}
26
+
27
+
28
+ def _items(value: str, strict: bool) -> list[list[Item]]:
29
+ """Items of each line. Strict mode refuses line breaks; tolerant mode reads a bare one
30
+ (not obs-fold) as the end of a field line, so the next line keeps its challenges."""
31
+ text = prepare(value, strict)
32
+ lines = [text] if strict else text.replace("\r\n", "\n").split("\n")
33
+ result: list[list[Item]] = []
34
+ for line in lines:
35
+ parts, open_quote = split(line)
36
+ if strict and open_quote is not None:
37
+ raise fail("unterminated quoted-string", open_quote)
38
+ items = [classify(offset, raw, strict) for offset, raw in parts]
39
+ if strict:
40
+ for item in items:
41
+ if item.error:
42
+ raise fail(item.error, item.error_at)
43
+ result.append(items)
44
+ return result
45
+
46
+
47
+ def _add(params: dict[str, Param], param: Param, strict: bool) -> None:
48
+ if param.name in params:
49
+ if strict:
50
+ raise fail(f"duplicate auth-param {param.name!r}", param.offset)
51
+ return # the first one wins, as most user agents do
52
+ params[param.name] = param
53
+
54
+
55
+ def _step(drafts: list[_Draft], start: int, item: Item, strict: bool) -> None:
56
+ if item.kind == "challenge":
57
+ drafts.append(_Draft(item.scheme, item.offset, item.token68, item.open))
58
+ if item.param is not None:
59
+ _add(drafts[-1].params, item.param, strict)
60
+ elif item.kind == "param" and item.param is not None:
61
+ current = drafts[-1] if len(drafts) > start else None
62
+ if current is None or current.token68 is not None or not current.open:
63
+ if strict:
64
+ raise fail(f"auth-param {item.param.name!r} outside a challenge", item.offset)
65
+ if current is None or current.token68 is not None:
66
+ return
67
+ current.open = True
68
+ _add(current.params, item.param, strict)
69
+
70
+
71
+ def _drafts(value: str, strict: bool) -> list[_Draft]:
72
+ drafts: list[_Draft] = []
73
+ for line in _items(value, strict):
74
+ start = len(drafts) # a challenge never continues on the next line
75
+ for item in line:
76
+ _step(drafts, start, item, strict)
77
+ return drafts
78
+
79
+
80
+ def _checked(draft: _Draft, kind: str, strict: bool) -> _Draft:
81
+ if strict:
82
+ try:
83
+ check(draft.scheme, kind, draft.token68, list(draft.params.values()))
84
+ except AuthError as error:
85
+ raise AuthError(f"{error}, in the {kind} at offset {draft.offset}") from None
86
+ return draft
87
+
88
+
89
+ def _each_line(value: str | Iterable[str], read: Callable[[str], _T]) -> list[_T]:
90
+ lines = [value] if isinstance(value, str) else list(value)
91
+ results: list[_T] = []
92
+ for number, line in enumerate(lines, 1):
93
+ try:
94
+ results.append(read(line))
95
+ except AuthError as error:
96
+ if len(lines) == 1:
97
+ raise
98
+ raise AuthError(f"{error} (field line {number})") from None
99
+ return results
100
+
101
+
102
+ def parse_challenges(value: str | Iterable[str], *, strict: bool = False) -> list[Challenge]:
103
+ """Parse WWW-Authenticate or Proxy-Authenticate field values into their challenges.
104
+
105
+ Pass one field value or an iterable of them (the field may occur several times).
106
+ By default parsing is tolerant: malformed parts are skipped and the first of two
107
+ params with the same name wins. With `strict=True` anything RFC 9110, RFC 7617,
108
+ RFC 7616 or RFC 6750 forbids raises `AuthError` with the reason and offset.
109
+ """
110
+
111
+ def read(line: str) -> list[Challenge]:
112
+ drafts = [_checked(d, "challenge", strict) for d in _drafts(line, strict)]
113
+ return [Challenge(d.scheme, d.token68, d.values()) for d in drafts]
114
+
115
+ challenges = [c for found in _each_line(value, read) for c in found]
116
+ if strict and not challenges:
117
+ raise AuthError("the field holds no challenge")
118
+ return challenges
119
+
120
+
121
+ def parse_authorization(value: str, *, strict: bool = False) -> Credentials:
122
+ """Parse an Authorization or Proxy-Authorization field value.
123
+
124
+ Tolerant mode returns the first credentials found, or `Credentials("")` if none.
125
+ Strict mode raises `AuthError` unless the value is exactly one valid credentials.
126
+ """
127
+ drafts = _drafts(value, strict)
128
+ if strict:
129
+ if not drafts or value.strip(" \t")[: len(drafts[0].scheme)] != drafts[0].scheme:
130
+ raise AuthError("the field must start with an auth-scheme")
131
+ if len(drafts) > 1:
132
+ raise fail("only one credentials is allowed", drafts[1].offset)
133
+ token68 = drafts[0].token68
134
+ if token68 is not None and not value.rstrip(" \t").endswith(token68):
135
+ raise AuthError("nothing may follow the token68 of credentials")
136
+ if not drafts:
137
+ return Credentials("")
138
+ draft = _checked(drafts[0], "credentials", strict)
139
+ return Credentials(draft.scheme, draft.token68, draft.values())
140
+
141
+
142
+ def parse_auth_info(
143
+ value: str | Iterable[str], *, strict: bool = False, scheme: str | None = None
144
+ ) -> dict[str, str]:
145
+ """Parse Authentication-Info or Proxy-Authentication-Info, a list of auth-params.
146
+
147
+ Names are lower-cased and the first of two equal names wins. Strict mode refuses
148
+ anything but auth-params and, given the `scheme` of the request's credentials,
149
+ applies its rules (Digest: quoting, `nc`, and rspauth, cnonce and nc with qop).
150
+ """
151
+ params: dict[str, Param] = {}
152
+
153
+ def read(line: str) -> None:
154
+ for items in _items(line, strict):
155
+ for item in items:
156
+ if item.kind == "param" and item.param is not None:
157
+ _add(params, item.param, strict)
158
+ elif item.kind != "empty" and strict:
159
+ raise fail("expected an auth-param", item.offset)
160
+
161
+ _each_line(value, read)
162
+ if strict and scheme is not None:
163
+ try:
164
+ check(scheme, "info", None, list(params.values()))
165
+ except AuthError as error:
166
+ raise AuthError(f"{error}, in the {scheme} Authentication-Info") from None
167
+ return {name: param.value for name, param in params.items()}
authchallenge/_scan.py ADDED
@@ -0,0 +1,133 @@
1
+ """Split a field value into list elements and classify each one (RFC 9110 sections 5.6, 11)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from ._grammar import QUOTED_RE, TOKEN_RE, bad_octet, is_token68, unquote
9
+ from ._model import AuthError
10
+
11
+ _PARAM = re.compile(rf"({TOKEN_RE})[ \t]*=[ \t]*({QUOTED_RE}|{TOKEN_RE})", re.DOTALL)
12
+ _SCHEME = re.compile(TOKEN_RE)
13
+ # obs-fold of RFC 9112 section 5.2: a line break followed by whitespace. The OWS before
14
+ # the break stays in place; matching it too would make long runs of spaces quadratic.
15
+ _OBS_FOLD = re.compile(r"\r?\n[ \t]+")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Param:
20
+ """One auth-param as read: lower-cased name, unquoted value, how it was written."""
21
+
22
+ name: str
23
+ value: str
24
+ quoted: bool
25
+ offset: int
26
+
27
+
28
+ @dataclass
29
+ class Item:
30
+ """One list element: empty, an auth-param, the start of a challenge, or junk."""
31
+
32
+ offset: int
33
+ kind: str
34
+ scheme: str = ""
35
+ token68: str | None = None
36
+ param: Param | None = None
37
+ open: bool = False
38
+ error: str = ""
39
+ error_at: int = 0
40
+
41
+
42
+ def fail(reason: str, offset: int) -> AuthError:
43
+ """Build the error strict parsing raises, pointing at the offending offset."""
44
+ return AuthError(f"{reason} at offset {offset}")
45
+
46
+
47
+ def prepare(value: str, strict: bool) -> str:
48
+ """Unfold obs-fold and, in strict mode, refuse it and any character a field cannot hold."""
49
+ if strict:
50
+ fold = _OBS_FOLD.search(value)
51
+ if fold is not None:
52
+ raise fail("obsolete line folding", fold.start())
53
+ bad = bad_octet(value)
54
+ if bad is not None:
55
+ raise fail(f"character {value[bad]!r} is not allowed in a field value", bad)
56
+ return value
57
+ return _OBS_FOLD.sub(" ", value)
58
+
59
+
60
+ def split(text: str) -> tuple[list[tuple[int, str]], int | None]:
61
+ """Split on commas outside quoted-strings; also return where an open quote started."""
62
+ parts: list[tuple[int, str]] = []
63
+ start = 0
64
+ quote_at: int | None = None
65
+ escaped = False
66
+ for index, char in enumerate(text):
67
+ if escaped:
68
+ escaped = False
69
+ elif quote_at is not None and char == "\\":
70
+ escaped = True
71
+ elif char == '"':
72
+ quote_at = index if quote_at is None else None
73
+ elif char == "," and quote_at is None:
74
+ parts.append((start, text[start:index]))
75
+ start = index + 1
76
+ parts.append((start, text[start:]))
77
+ return parts, quote_at
78
+
79
+
80
+ def _param(match: re.Match[str], offset: int) -> Param:
81
+ raw = match.group(2)
82
+ return Param(match.group(1).lower(), unquote(raw), raw.startswith('"'), offset)
83
+
84
+
85
+ def _read_param(item: Item, text: str, offset: int) -> bool:
86
+ """Fill item.param from text; record junk after the value. False if no param starts."""
87
+ match = _PARAM.match(text)
88
+ if match is None:
89
+ return False
90
+ param = _param(match, offset)
91
+ item.param = param
92
+ rest = text[match.end() :]
93
+ if rest:
94
+ item.error = f"unexpected text after the value of {param.name!r}"
95
+ item.error_at = offset + len(text) - len(rest.lstrip(" \t"))
96
+ if not param.quoted and rest[0] not in " \t":
97
+ item.param = None # a token cut short by a stray character is not the value
98
+ return True
99
+
100
+
101
+ def classify(offset: int, raw: str, strict: bool) -> Item:
102
+ """Classify one list element; malformed elements carry an error for strict mode."""
103
+ text = raw.strip(" \t")
104
+ at = offset + len(raw) - len(raw.lstrip(" \t"))
105
+ item = Item(at, "empty")
106
+ if not text:
107
+ return item
108
+ if _read_param(item, text, at):
109
+ item.kind = "param"
110
+ return item
111
+ scheme = _SCHEME.match(text)
112
+ after = text[scheme.end() :] if scheme else ""
113
+ if scheme is None or after.lstrip(" \t").startswith("="):
114
+ item.kind, item.error, item.error_at = "junk", "expected an auth-scheme or auth-param", at
115
+ return item
116
+ item.kind, item.scheme = "challenge", scheme.group()
117
+ gap = raw.lstrip(" \t")[scheme.end() :]
118
+ rest = after.lstrip(" \t")
119
+ space = gap[: len(gap) - len(gap.lstrip(" \t"))]
120
+ item.open = space != "" and (" " in space if strict else True)
121
+ if space == "" and after:
122
+ item.error, item.error_at = "expected a space after the auth-scheme", at + scheme.end()
123
+ return item
124
+ if not rest:
125
+ return item
126
+ rest_at = at + len(text) - len(rest)
127
+ if is_token68(rest):
128
+ item.token68 = rest
129
+ elif not _read_param(item, rest, rest_at):
130
+ item.error, item.error_at = "expected a token68 or an auth-param", rest_at
131
+ if "\t" in space:
132
+ item.error, item.error_at = "the auth-scheme must be followed by spaces", at + scheme.end()
133
+ return item
@@ -0,0 +1,121 @@
1
+ """Rules of the registered schemes: Basic (RFC 7617), Bearer (RFC 6750), Digest (RFC 7616)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Sequence
7
+
8
+ from ._basic import split_user_pass
9
+ from ._extvalue import decode_ext_value
10
+ from ._model import AuthError
11
+ from ._scan import Param
12
+
13
+ # RFC 7616 sections 3.3, 3.4 and 3.5: values a sender must quote, and values it must not.
14
+ DIGEST_QUOTED = {
15
+ "challenge": frozenset({"realm", "domain", "nonce", "opaque", "qop"}),
16
+ "credentials": frozenset({"username", "realm", "nonce", "uri", "response", "cnonce", "opaque"}),
17
+ "info": frozenset({"nextnonce", "rspauth", "cnonce"}),
18
+ }
19
+ DIGEST_BARE = {
20
+ "challenge": frozenset({"stale", "algorithm"}),
21
+ "credentials": frozenset({"algorithm", "qop", "nc"}),
22
+ "info": frozenset({"qop", "nc"}),
23
+ }
24
+ # RFC 6750 section 3: scope tokens, error and error_description, error_uri.
25
+ _SCOPE_TOKEN = re.compile(r"[\x21\x23-\x5b\x5d-\x7e]+")
26
+ _NQSCHAR = re.compile(r"[\x20\x21\x23-\x5b\x5d-\x7e]*")
27
+ _URI_CHARS = re.compile(r"[\x21\x23-\x5b\x5d-\x7e]*")
28
+ _NC = re.compile(r"[0-9A-Fa-f]{8}")
29
+
30
+
31
+ def _values(params: Sequence[Param]) -> dict[str, Param]:
32
+ return {param.name: param for param in params}
33
+
34
+
35
+ def _require(scheme: str, found: dict[str, Param], names: Sequence[str]) -> None:
36
+ for name in names:
37
+ if name not in found:
38
+ raise AuthError(f"{scheme} needs the {name!r} parameter")
39
+
40
+
41
+ def _basic(kind: str, token68: str | None, found: dict[str, Param]) -> None:
42
+ if kind == "credentials":
43
+ if token68 is None:
44
+ raise AuthError("Basic credentials are a token68")
45
+ split_user_pass(token68, "latin-1")
46
+ return
47
+ _require("Basic", found, ["realm"])
48
+ charset = found.get("charset")
49
+ if charset is not None and charset.value.lower() != "utf-8":
50
+ raise AuthError("the Basic charset parameter only allows 'UTF-8'")
51
+
52
+
53
+ def _bearer(kind: str, token68: str | None, found: dict[str, Param]) -> None:
54
+ if kind == "credentials":
55
+ if token68 is None:
56
+ raise AuthError("Bearer credentials are a b64token")
57
+ return
58
+ if not found:
59
+ raise AuthError("a Bearer challenge needs at least one auth-param")
60
+ scope = found.get("scope")
61
+ if scope is not None and not all(_SCOPE_TOKEN.fullmatch(s) for s in scope.value.split(" ")):
62
+ raise AuthError("the Bearer scope holds a character RFC 6750 does not allow")
63
+ for name, pattern in (("error", _NQSCHAR), ("error_description", _NQSCHAR)):
64
+ if name in found and not pattern.fullmatch(found[name].value):
65
+ raise AuthError(f"the Bearer {name} holds a character RFC 6750 does not allow")
66
+ if "error_uri" in found and not _URI_CHARS.fullmatch(found["error_uri"].value):
67
+ raise AuthError("the Bearer error_uri holds a character RFC 6750 does not allow")
68
+
69
+
70
+ def _flag(found: dict[str, Param], name: str) -> None:
71
+ if name in found and found[name].value.lower() not in ("true", "false"):
72
+ raise AuthError(f"the Digest {name} parameter is 'true' or 'false'")
73
+
74
+
75
+ def _digest(kind: str, token68: str | None, found: dict[str, Param]) -> None:
76
+ if token68 is not None:
77
+ raise AuthError("Digest uses auth-params, not a token68")
78
+ for name, param in found.items():
79
+ if name in DIGEST_QUOTED[kind] and not param.quoted:
80
+ raise AuthError(f"the Digest {name} parameter must be a quoted-string")
81
+ if name in DIGEST_BARE[kind] and param.quoted:
82
+ raise AuthError(f"the Digest {name} parameter must not be quoted")
83
+ if "nc" in found and not _NC.fullmatch(found["nc"].value):
84
+ raise AuthError("the Digest nc parameter is exactly 8 hexadecimal digits")
85
+ charset = found.get("charset")
86
+ if charset is not None and charset.value.lower() != "utf-8":
87
+ raise AuthError("the Digest charset parameter only allows 'UTF-8'")
88
+ _flag(found, "stale")
89
+ _flag(found, "userhash")
90
+ if kind == "challenge":
91
+ # The response of section 3.4.1 is computed from realm and nonce; qop is a MUST.
92
+ _require("Digest", found, ["realm", "nonce", "qop"])
93
+ elif kind == "credentials":
94
+ _digest_credentials(found)
95
+ elif "qop" in found:
96
+ _require("Digest", found, ["rspauth", "cnonce", "nc"])
97
+
98
+
99
+ def _digest_credentials(found: dict[str, Param]) -> None:
100
+ if ("username" in found) == ("username*" in found):
101
+ raise AuthError("Digest credentials carry exactly one of username and username*")
102
+ if "username*" in found:
103
+ if found["username*"].quoted:
104
+ raise AuthError("the Digest username* parameter is an ext-value, not quoted")
105
+ decode_ext_value(found["username*"].value)
106
+ _require("Digest", found, ["realm", "nonce", "uri", "response", "cnonce", "nc"])
107
+
108
+
109
+ def check(scheme: str, kind: str, token68: str | None, params: Sequence[Param]) -> None:
110
+ """Raise AuthError if a challenge, credentials or Authentication-Info breaks a rule.
111
+
112
+ `kind` is "challenge", "credentials" or "info". Rules of RFC 9110 apply to every
113
+ scheme; Basic, Bearer and Digest add their own. Unknown schemes only get the former.
114
+ """
115
+ found = _values(params)
116
+ realm = found.get("realm")
117
+ if kind == "challenge" and realm is not None and not realm.quoted:
118
+ raise AuthError("the realm parameter must be a quoted-string")
119
+ rules = {"basic": _basic, "bearer": _bearer, "digest": _digest}.get(scheme.lower())
120
+ if rules is not None and (kind != "info" or rules is _digest):
121
+ rules(kind, token68, found)
authchallenge/py.typed ADDED
File without changes
@@ -0,0 +1,210 @@
1
+ Metadata-Version: 2.5
2
+ Name: authchallenge
3
+ Version: 0.2.0
4
+ Summary: Parse, validate and safely build HTTP WWW-Authenticate, Authorization and Authentication-Info headers per RFC 9110, typed and zero-dependency
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/authchallenge
7
+ Project-URL: Issues, https://github.com/JaviLianes8/authchallenge/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/authchallenge/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: authentication,authentication-info,authorization,basic,bearer,digest,http,proxy-authenticate,rfc6750,rfc7235,rfc7616,rfc7617,rfc9110,www-authenticate
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Environment :: Console
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Internet :: WWW/HTTP
27
+ Classifier: Topic :: Security
28
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
29
+ Classifier: Typing :: Typed
30
+ Requires-Python: >=3.10
31
+ Provides-Extra: dev
32
+ Requires-Dist: build; extra == 'dev'
33
+ Requires-Dist: mypy; extra == 'dev'
34
+ Requires-Dist: pytest; extra == 'dev'
35
+ Requires-Dist: ruff; extra == 'dev'
36
+ Requires-Dist: twine; extra == 'dev'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # authchallenge
40
+
41
+ Parse and build HTTP authentication headers: `WWW-Authenticate`, `Proxy-Authenticate`,
42
+ `Authorization`, `Proxy-Authorization` and `Authentication-Info`, per RFC 9110 with the
43
+ rules of Basic (RFC 7617), Digest (RFC 7616) and Bearer (RFC 6750). Typed, zero
44
+ dependencies.
45
+
46
+ ## Install
47
+
48
+ ```
49
+ pip install authchallenge
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ Parse the challenges a server offers. One comma separates both the params of a challenge
55
+ and the challenges themselves; the parser tells them apart, keeps a token68 apart from
56
+ params, and accepts several field lines:
57
+
58
+ ```python
59
+ from authchallenge import parse_challenges, select_challenge
60
+
61
+ offered = parse_challenges(
62
+ ['Basic realm="simple", Newauth realm="apps", type=1', "Negotiate a87421bff012e83c=="]
63
+ )
64
+ for challenge in offered:
65
+ print(challenge)
66
+ print(select_challenge(offered, "negotiate", "basic").token68)
67
+ # Challenge(scheme='Basic', token68=None, params={'realm': 'simple'})
68
+ # Challenge(scheme='Newauth', token68=None, params={'realm': 'apps', 'type': '1'})
69
+ # Challenge(scheme='Negotiate', token68='a87421bff012e83c==', params={})
70
+ # a87421bff012e83c==
71
+ ```
72
+
73
+ Parsing is tolerant by default: malformed parts are skipped, obsolete line folding reads
74
+ as a space, and of two params with the same name the first wins. With `strict=True`
75
+ anything the RFCs forbid raises `AuthError` with the reason:
76
+
77
+ ```python
78
+ from authchallenge import AuthError, parse_challenges
79
+
80
+ print(parse_challenges('Basic realm="a", realm="b"'))
81
+ for header in ('Basic, realm="a"', "Basic realm=a", 'Basic realm="a" extra', "Bearer"):
82
+ try:
83
+ parse_challenges(header, strict=True)
84
+ except AuthError as error:
85
+ print(error)
86
+ # [Challenge(scheme='Basic', token68=None, params={'realm': 'a'})]
87
+ # auth-param 'realm' outside a challenge at offset 7
88
+ # the realm parameter must be a quoted-string, in the challenge at offset 0
89
+ # unexpected text after the value of 'realm' at offset 16
90
+ # a Bearer challenge needs at least one auth-param, in the challenge at offset 0
91
+ ```
92
+
93
+ Build headers safely. Values may hold only tab, space and visible ASCII, so a CR LF can
94
+ never split the header. Quoting follows the sender rules: realm always, every Basic and
95
+ Bearer param, and the Digest lists of RFC 7616. Whatever the builder returns passes the
96
+ strict parser:
97
+
98
+ ```python
99
+ from authchallenge import AuthError, build_bearer_challenge, build_challenge
100
+
101
+ digest = {"realm": "api", "qop": "auth", "nonce": "7ypf", "algorithm": "SHA-256"}
102
+ print(build_challenge("Digest", digest))
103
+ print(build_bearer_challenge(realm="example", error="invalid_token", scope=["read", "write"]))
104
+ try:
105
+ build_challenge("Basic", {"realm": "a\r\nSet-Cookie: x=1"})
106
+ except AuthError as error:
107
+ print(error)
108
+ # Digest realm="api", qop="auth", nonce="7ypf", algorithm=SHA-256
109
+ # Bearer realm="example", scope="read write", error="invalid_token"
110
+ # the value of 'realm' may hold only tab, space and visible ASCII; send other text as an RFC 8187 ext-value (see encode_ext_value)
111
+ ```
112
+
113
+ Credentials, with helpers for Basic and for the RFC 8187 `username*` of Digest. The
114
+ vectors come from RFC 7617 sections 2 and 2.1 (https://www.rfc-editor.org/rfc/rfc7617)
115
+ and RFC 7616 section 3.9.2 (https://www.rfc-editor.org/rfc/rfc7616):
116
+
117
+ ```python
118
+ from authchallenge import decode_basic, decode_ext_value, encode_basic, parse_authorization
119
+
120
+ print(encode_basic("Aladdin", "open sesame"))
121
+ print(decode_basic("Basic dGVzdDoxMjPCow=="))
122
+ creds = parse_authorization("Digest username*=UTF-8''J%C3%A4s%C3%B8n%20Doe, realm=\"api\"")
123
+ print(creds.scheme, decode_ext_value(creds.params["username*"]))
124
+ # Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
125
+ # ('test', '123£')
126
+ # Digest Jäsøn Doe
127
+ ```
128
+
129
+ Authentication-Info, the list of params a server sends after a successful login:
130
+
131
+ ```python
132
+ from authchallenge import build_auth_info, parse_auth_info
133
+
134
+ info = {
135
+ "nextnonce": "47364c23",
136
+ "qop": "auth",
137
+ "rspauth": "6629fae4",
138
+ "cnonce": "0a4f",
139
+ "nc": "00000001",
140
+ }
141
+ print(build_auth_info(info, scheme="Digest"))
142
+ print(parse_auth_info("qop=auth, NC=00000001, nc=2"))
143
+ # nextnonce="47364c23", qop=auth, rspauth="6629fae4", cnonce="0a4f", nc=00000001
144
+ # {'qop': 'auth', 'nc': '00000001'}
145
+ ```
146
+
147
+ ## Command line
148
+
149
+ ```
150
+ curl -sI https://example.com/private | authchallenge
151
+ authchallenge --strict 'Basic realm=foo'
152
+ authchallenge --authorization 'Bearer mF_9.B5f-4.1JqM'
153
+ ```
154
+
155
+ Values come as arguments or one per stdin line; a header dump on stdin is read for the
156
+ authentication fields only (the first non-empty line decides which). The output is JSON.
157
+ `--strict` reports the first broken rule of each value on stderr and exits 1.
158
+
159
+ ## API
160
+
161
+ | Name | Purpose |
162
+ | --- | --- |
163
+ | `parse_challenges(value, *, strict=False)` | WWW-Authenticate / Proxy-Authenticate, one value or several lines, to a list of `Challenge` |
164
+ | `parse_authorization(value, *, strict=False)` | Authorization / Proxy-Authorization to `Credentials` |
165
+ | `parse_auth_info(value, *, strict=False, scheme=None)` | Authentication-Info to a dict |
166
+ | `build_challenge(scheme, params=None, token68=None)` | One challenge, quoted and checked |
167
+ | `build_authorization(scheme, params=None, token68=None)` | Credentials, quoted and checked |
168
+ | `build_auth_info(params, *, scheme=None)` | Authentication-Info |
169
+ | `build_bearer_challenge(*, realm, scope, error, error_description, error_uri)` | A Bearer challenge per RFC 6750 |
170
+ | `encode_basic(user_id, password, *, charset="utf-8")` | `Basic <base64>` per RFC 7617 |
171
+ | `decode_basic(credentials, *, charset="utf-8")` | `(user_id, password)` |
172
+ | `encode_ext_value(text)` / `decode_ext_value(value)` | RFC 8187 ext-values such as `username*` |
173
+ | `select_challenge(challenges, *schemes)` | The first challenge of the most preferred scheme |
174
+ | `Challenge`, `Credentials` | Frozen results: `scheme`, `token68`, `params` (names lower-cased) |
175
+ | `AuthError` | A `ValueError` raised by builders and strict parsing |
176
+
177
+ Strict mode and the builders check the grammar of RFC 9110 section 11 and these scheme
178
+ rules: Basic needs a realm and allows only `charset="UTF-8"`, and its credentials must
179
+ decode to `user-id:password`; Bearer needs at least one param, limits the characters of
180
+ scope, error, error_description and error_uri, and its credentials are a token68; Digest
181
+ quotes and leaves bare the params RFC 7616 lists, needs realm, nonce and qop in challenges
182
+ and the response params in credentials, writes `nc` as 8 hex digits, and takes one of
183
+ `username` and `username*`. Other schemes get the generic rules only.
184
+
185
+ ## Why
186
+
187
+ Authentication headers are deceptively hard: one comma separates both the params within a
188
+ challenge and the challenges from each other, token68 and auth-params look alike, the
189
+ field may be repeated, and each scheme adds quoting rules of its own. The existing options
190
+ fall short: `authparser` pulls in `pyparsing`, `www-authenticate` has been unmaintained
191
+ since 2015 and only parses, and the newer `wwwauth` parses WWW-Authenticate alone. This
192
+ library parses and builds all five fields, validates them on request, refuses header
193
+ injection when building, and ships typed with no dependencies.
194
+
195
+ ## Testing against the specifications
196
+
197
+ `python scripts/fetch_upstream.py` downloads the RFCs and the greenbytes WWW-Authenticate
198
+ test cases listed in `tests/upstream/MANIFEST.json`, checking each against its sha256.
199
+ The suite then reads character sets, quoting lists and examples from them and runs every
200
+ test case in both modes; without them those tests skip.
201
+
202
+ ## License
203
+
204
+ MIT. See [LICENSE](LICENSE).
205
+
206
+ ## Links
207
+
208
+ - Website: https://lianes8studio.com
209
+ - GitHub: https://github.com/JaviLianes8
210
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,18 @@
1
+ authchallenge/__init__.py,sha256=rxhRgWuwOWwZ8xdxMs7XWCJSDhLwBHdhcc7fJgZcjj0,885
2
+ authchallenge/__main__.py,sha256=1Ln9_v2TGiVS93LGcC6LWJ3_zt9Zsm5PNyhwdXf6mZo,129
3
+ authchallenge/_basic.py,sha256=wPBLwDB_H7AdKWAFd82aIp2kdcl4i_yQWDZNMozAw7U,3428
4
+ authchallenge/_build.py,sha256=dsC62hTKgAj-If_Fo3Ti28_fNHkm6l_FCg57K_U5gLU,4084
5
+ authchallenge/_cli.py,sha256=JqgFbAgqIpaNWUeOWb3AGRhx5X35jjgz2ylWqKCu2y0,3975
6
+ authchallenge/_extvalue.py,sha256=36-psFIdYsSzU9gzKsUm86CQkaRXzqdoTMvwGgy-e7U,1716
7
+ authchallenge/_grammar.py,sha256=bEleh-5eqbeRITiCV3ZgIZBDmq4NkIjCdQYijQgforg,2310
8
+ authchallenge/_helpers.py,sha256=VlVQm4mL3noSaoMGn6cgk93bQCYKirXfv_OJawm1W1o,1954
9
+ authchallenge/_model.py,sha256=OsYC7RbfIqz5bT2HcrvhYXd1O8Iel0KxcEfD3q0Q2hQ,893
10
+ authchallenge/_parse.py,sha256=ewuX7pWsgEFveMkR5Azh1foq-pPbWq09yInH2NFA_k4,6731
11
+ authchallenge/_scan.py,sha256=uHZ-txOok6IAW-Dkp8ZdU7c3eAyGdor4Uni8LYI71fU,4726
12
+ authchallenge/_schemes.py,sha256=ilk7KRCEfa2Tf2Yz_26uvwWktJq1zULEdX7lgyqAKoQ,5503
13
+ authchallenge/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ authchallenge-0.2.0.dist-info/METADATA,sha256=u3LBgslH0x4ptvzSKFmg5gWJs7RpJdSStcKAHBFLQBQ,9450
15
+ authchallenge-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
16
+ authchallenge-0.2.0.dist-info/entry_points.txt,sha256=qS6NcrbIz-4fElHE1SuSxnPTRd_aeCBZLjNLODShMOg,58
17
+ authchallenge-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
18
+ authchallenge-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ authchallenge = authchallenge._cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.