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.
- authchallenge/__init__.py +30 -0
- authchallenge/__main__.py +9 -0
- authchallenge/_basic.py +81 -0
- authchallenge/_build.py +100 -0
- authchallenge/_cli.py +94 -0
- authchallenge/_extvalue.py +42 -0
- authchallenge/_grammar.py +63 -0
- authchallenge/_helpers.py +53 -0
- authchallenge/_model.py +31 -0
- authchallenge/_parse.py +167 -0
- authchallenge/_scan.py +133 -0
- authchallenge/_schemes.py +121 -0
- authchallenge/py.typed +0 -0
- authchallenge-0.2.0.dist-info/METADATA +210 -0
- authchallenge-0.2.0.dist-info/RECORD +18 -0
- authchallenge-0.2.0.dist-info/WHEEL +4 -0
- authchallenge-0.2.0.dist-info/entry_points.txt +2 -0
- authchallenge-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
authchallenge/_basic.py
ADDED
|
@@ -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)
|
authchallenge/_build.py
ADDED
|
@@ -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
|
authchallenge/_model.py
ADDED
|
@@ -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)
|
authchallenge/_parse.py
ADDED
|
@@ -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,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.
|