authchallenge 0.2.0__tar.gz

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.
Files changed (41) hide show
  1. authchallenge-0.2.0/.gitattributes +1 -0
  2. authchallenge-0.2.0/.gitignore +16 -0
  3. authchallenge-0.2.0/CHANGELOG.md +50 -0
  4. authchallenge-0.2.0/CONTRIBUTING.md +19 -0
  5. authchallenge-0.2.0/LICENSE +21 -0
  6. authchallenge-0.2.0/PKG-INFO +210 -0
  7. authchallenge-0.2.0/README.md +172 -0
  8. authchallenge-0.2.0/pyproject.toml +74 -0
  9. authchallenge-0.2.0/requirements-dev.txt +3 -0
  10. authchallenge-0.2.0/scripts/check.ps1 +17 -0
  11. authchallenge-0.2.0/scripts/fetch_upstream.py +39 -0
  12. authchallenge-0.2.0/scripts/release.ps1 +28 -0
  13. authchallenge-0.2.0/src/authchallenge/__init__.py +30 -0
  14. authchallenge-0.2.0/src/authchallenge/__main__.py +9 -0
  15. authchallenge-0.2.0/src/authchallenge/_basic.py +81 -0
  16. authchallenge-0.2.0/src/authchallenge/_build.py +100 -0
  17. authchallenge-0.2.0/src/authchallenge/_cli.py +94 -0
  18. authchallenge-0.2.0/src/authchallenge/_extvalue.py +42 -0
  19. authchallenge-0.2.0/src/authchallenge/_grammar.py +63 -0
  20. authchallenge-0.2.0/src/authchallenge/_helpers.py +53 -0
  21. authchallenge-0.2.0/src/authchallenge/_model.py +31 -0
  22. authchallenge-0.2.0/src/authchallenge/_parse.py +167 -0
  23. authchallenge-0.2.0/src/authchallenge/_scan.py +133 -0
  24. authchallenge-0.2.0/src/authchallenge/_schemes.py +121 -0
  25. authchallenge-0.2.0/src/authchallenge/py.typed +0 -0
  26. authchallenge-0.2.0/tests/conftest.py +92 -0
  27. authchallenge-0.2.0/tests/greenbytes_cases.py +79 -0
  28. authchallenge-0.2.0/tests/test_build.py +129 -0
  29. authchallenge-0.2.0/tests/test_cli.py +85 -0
  30. authchallenge-0.2.0/tests/test_contract.py +57 -0
  31. authchallenge-0.2.0/tests/test_docs.py +73 -0
  32. authchallenge-0.2.0/tests/test_e2e_wheel.py +117 -0
  33. authchallenge-0.2.0/tests/test_greenbytes.py +58 -0
  34. authchallenge-0.2.0/tests/test_helpers.py +133 -0
  35. authchallenge-0.2.0/tests/test_parse.py +96 -0
  36. authchallenge-0.2.0/tests/test_review_fixes.py +166 -0
  37. authchallenge-0.2.0/tests/test_schemes.py +134 -0
  38. authchallenge-0.2.0/tests/test_sources.py +160 -0
  39. authchallenge-0.2.0/tests/test_strict.py +88 -0
  40. authchallenge-0.2.0/tests/test_tolerant.py +103 -0
  41. authchallenge-0.2.0/tests/upstream/MANIFEST.json +74 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Third-party reference files, fetched locally by scripts/fetch_upstream.py; only the manifest is tracked.
15
+ tests/upstream/*
16
+ !tests/upstream/MANIFEST.json
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0] - unreleased
10
+
11
+ ### Security
12
+
13
+ - Builders refuse values with CR, LF, NUL, DEL or any character outside tab, space and
14
+ visible ASCII, so a value can no longer inject a header line.
15
+
16
+ ### Added
17
+
18
+ - `strict=True` on every parser: the RFC 9110 grammar (obsolete line folding, controls,
19
+ unterminated quoted-strings, trailing text, params outside a challenge, a tab after the
20
+ scheme, duplicate names, realm as a token) and the Basic, Bearer and Digest rules raise
21
+ `AuthError` with the reason and offset.
22
+ - `parse_auth_info` and `build_auth_info` for Authentication-Info and
23
+ Proxy-Authentication-Info.
24
+ - `encode_basic` and `decode_basic` (RFC 7617), `build_bearer_challenge` (RFC 6750),
25
+ `encode_ext_value` and `decode_ext_value` (RFC 8187), `select_challenge`.
26
+ - `parse_challenges` accepts several field lines.
27
+ - The `authchallenge` command and `python -m authchallenge`: JSON output, `--strict` lint.
28
+
29
+ ### Changed
30
+
31
+ - Of two params with the same name the first wins (it was the last).
32
+ - Obsolete line folding is read as a space instead of yielding nothing.
33
+ - A token68 challenge never collects params that follow it.
34
+ - Builders quote `realm` always, every Basic and Bearer param, and the Digest params RFC
35
+ 7616 lists; Digest `stale`, `algorithm`, `qop` (credentials) and `nc` are written bare.
36
+ - Tolerant parsing reads a bare line break (not obsolete folding) as the end of a field
37
+ line, and drops a token value cut short by a stray character instead of truncating it.
38
+ - `decode_ext_value` refuses text that decodes to control characters; `encode_basic` and
39
+ `decode_basic` accept only UTF-8 and ISO-8859-1.
40
+ - Builders refuse names that differ only in case and whatever strict parsing refuses,
41
+ including a Basic challenge without realm and incomplete Digest params.
42
+
43
+ ## [0.1.0]
44
+
45
+ - Parse WWW-Authenticate and Proxy-Authenticate headers into a list of challenges, with
46
+ multi-challenge comma disambiguation, token68 and quoted auth-params (RFC 7235).
47
+ - Parse Authorization and Proxy-Authorization credentials into a scheme with token68 or
48
+ auth-params.
49
+ - Build challenge and credentials strings back, quoting values only when required.
50
+ - Typed, zero dependencies, `py.typed`.
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time. This project keeps a small, predictable shape.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -r requirements-dev.txt
9
+ ```
10
+
11
+ ## Before you open a pull request
12
+
13
+ - Keep public API in `src/authchallenge/__init__.py`; internals live in `_`-prefixed modules.
14
+ - Add a test for the behaviour you change. Tests inject time, disk and network; they
15
+ never touch the real thing.
16
+ - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
17
+ - One logical change per commit, imperative subject line.
18
+
19
+ Open an issue first if the change is large or shifts the public API.
@@ -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.
@@ -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,172 @@
1
+ # authchallenge
2
+
3
+ Parse and build HTTP authentication headers: `WWW-Authenticate`, `Proxy-Authenticate`,
4
+ `Authorization`, `Proxy-Authorization` and `Authentication-Info`, per RFC 9110 with the
5
+ rules of Basic (RFC 7617), Digest (RFC 7616) and Bearer (RFC 6750). Typed, zero
6
+ dependencies.
7
+
8
+ ## Install
9
+
10
+ ```
11
+ pip install authchallenge
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ Parse the challenges a server offers. One comma separates both the params of a challenge
17
+ and the challenges themselves; the parser tells them apart, keeps a token68 apart from
18
+ params, and accepts several field lines:
19
+
20
+ ```python
21
+ from authchallenge import parse_challenges, select_challenge
22
+
23
+ offered = parse_challenges(
24
+ ['Basic realm="simple", Newauth realm="apps", type=1', "Negotiate a87421bff012e83c=="]
25
+ )
26
+ for challenge in offered:
27
+ print(challenge)
28
+ print(select_challenge(offered, "negotiate", "basic").token68)
29
+ # Challenge(scheme='Basic', token68=None, params={'realm': 'simple'})
30
+ # Challenge(scheme='Newauth', token68=None, params={'realm': 'apps', 'type': '1'})
31
+ # Challenge(scheme='Negotiate', token68='a87421bff012e83c==', params={})
32
+ # a87421bff012e83c==
33
+ ```
34
+
35
+ Parsing is tolerant by default: malformed parts are skipped, obsolete line folding reads
36
+ as a space, and of two params with the same name the first wins. With `strict=True`
37
+ anything the RFCs forbid raises `AuthError` with the reason:
38
+
39
+ ```python
40
+ from authchallenge import AuthError, parse_challenges
41
+
42
+ print(parse_challenges('Basic realm="a", realm="b"'))
43
+ for header in ('Basic, realm="a"', "Basic realm=a", 'Basic realm="a" extra', "Bearer"):
44
+ try:
45
+ parse_challenges(header, strict=True)
46
+ except AuthError as error:
47
+ print(error)
48
+ # [Challenge(scheme='Basic', token68=None, params={'realm': 'a'})]
49
+ # auth-param 'realm' outside a challenge at offset 7
50
+ # the realm parameter must be a quoted-string, in the challenge at offset 0
51
+ # unexpected text after the value of 'realm' at offset 16
52
+ # a Bearer challenge needs at least one auth-param, in the challenge at offset 0
53
+ ```
54
+
55
+ Build headers safely. Values may hold only tab, space and visible ASCII, so a CR LF can
56
+ never split the header. Quoting follows the sender rules: realm always, every Basic and
57
+ Bearer param, and the Digest lists of RFC 7616. Whatever the builder returns passes the
58
+ strict parser:
59
+
60
+ ```python
61
+ from authchallenge import AuthError, build_bearer_challenge, build_challenge
62
+
63
+ digest = {"realm": "api", "qop": "auth", "nonce": "7ypf", "algorithm": "SHA-256"}
64
+ print(build_challenge("Digest", digest))
65
+ print(build_bearer_challenge(realm="example", error="invalid_token", scope=["read", "write"]))
66
+ try:
67
+ build_challenge("Basic", {"realm": "a\r\nSet-Cookie: x=1"})
68
+ except AuthError as error:
69
+ print(error)
70
+ # Digest realm="api", qop="auth", nonce="7ypf", algorithm=SHA-256
71
+ # Bearer realm="example", scope="read write", error="invalid_token"
72
+ # 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)
73
+ ```
74
+
75
+ Credentials, with helpers for Basic and for the RFC 8187 `username*` of Digest. The
76
+ vectors come from RFC 7617 sections 2 and 2.1 (https://www.rfc-editor.org/rfc/rfc7617)
77
+ and RFC 7616 section 3.9.2 (https://www.rfc-editor.org/rfc/rfc7616):
78
+
79
+ ```python
80
+ from authchallenge import decode_basic, decode_ext_value, encode_basic, parse_authorization
81
+
82
+ print(encode_basic("Aladdin", "open sesame"))
83
+ print(decode_basic("Basic dGVzdDoxMjPCow=="))
84
+ creds = parse_authorization("Digest username*=UTF-8''J%C3%A4s%C3%B8n%20Doe, realm=\"api\"")
85
+ print(creds.scheme, decode_ext_value(creds.params["username*"]))
86
+ # Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
87
+ # ('test', '123£')
88
+ # Digest Jäsøn Doe
89
+ ```
90
+
91
+ Authentication-Info, the list of params a server sends after a successful login:
92
+
93
+ ```python
94
+ from authchallenge import build_auth_info, parse_auth_info
95
+
96
+ info = {
97
+ "nextnonce": "47364c23",
98
+ "qop": "auth",
99
+ "rspauth": "6629fae4",
100
+ "cnonce": "0a4f",
101
+ "nc": "00000001",
102
+ }
103
+ print(build_auth_info(info, scheme="Digest"))
104
+ print(parse_auth_info("qop=auth, NC=00000001, nc=2"))
105
+ # nextnonce="47364c23", qop=auth, rspauth="6629fae4", cnonce="0a4f", nc=00000001
106
+ # {'qop': 'auth', 'nc': '00000001'}
107
+ ```
108
+
109
+ ## Command line
110
+
111
+ ```
112
+ curl -sI https://example.com/private | authchallenge
113
+ authchallenge --strict 'Basic realm=foo'
114
+ authchallenge --authorization 'Bearer mF_9.B5f-4.1JqM'
115
+ ```
116
+
117
+ Values come as arguments or one per stdin line; a header dump on stdin is read for the
118
+ authentication fields only (the first non-empty line decides which). The output is JSON.
119
+ `--strict` reports the first broken rule of each value on stderr and exits 1.
120
+
121
+ ## API
122
+
123
+ | Name | Purpose |
124
+ | --- | --- |
125
+ | `parse_challenges(value, *, strict=False)` | WWW-Authenticate / Proxy-Authenticate, one value or several lines, to a list of `Challenge` |
126
+ | `parse_authorization(value, *, strict=False)` | Authorization / Proxy-Authorization to `Credentials` |
127
+ | `parse_auth_info(value, *, strict=False, scheme=None)` | Authentication-Info to a dict |
128
+ | `build_challenge(scheme, params=None, token68=None)` | One challenge, quoted and checked |
129
+ | `build_authorization(scheme, params=None, token68=None)` | Credentials, quoted and checked |
130
+ | `build_auth_info(params, *, scheme=None)` | Authentication-Info |
131
+ | `build_bearer_challenge(*, realm, scope, error, error_description, error_uri)` | A Bearer challenge per RFC 6750 |
132
+ | `encode_basic(user_id, password, *, charset="utf-8")` | `Basic <base64>` per RFC 7617 |
133
+ | `decode_basic(credentials, *, charset="utf-8")` | `(user_id, password)` |
134
+ | `encode_ext_value(text)` / `decode_ext_value(value)` | RFC 8187 ext-values such as `username*` |
135
+ | `select_challenge(challenges, *schemes)` | The first challenge of the most preferred scheme |
136
+ | `Challenge`, `Credentials` | Frozen results: `scheme`, `token68`, `params` (names lower-cased) |
137
+ | `AuthError` | A `ValueError` raised by builders and strict parsing |
138
+
139
+ Strict mode and the builders check the grammar of RFC 9110 section 11 and these scheme
140
+ rules: Basic needs a realm and allows only `charset="UTF-8"`, and its credentials must
141
+ decode to `user-id:password`; Bearer needs at least one param, limits the characters of
142
+ scope, error, error_description and error_uri, and its credentials are a token68; Digest
143
+ quotes and leaves bare the params RFC 7616 lists, needs realm, nonce and qop in challenges
144
+ and the response params in credentials, writes `nc` as 8 hex digits, and takes one of
145
+ `username` and `username*`. Other schemes get the generic rules only.
146
+
147
+ ## Why
148
+
149
+ Authentication headers are deceptively hard: one comma separates both the params within a
150
+ challenge and the challenges from each other, token68 and auth-params look alike, the
151
+ field may be repeated, and each scheme adds quoting rules of its own. The existing options
152
+ fall short: `authparser` pulls in `pyparsing`, `www-authenticate` has been unmaintained
153
+ since 2015 and only parses, and the newer `wwwauth` parses WWW-Authenticate alone. This
154
+ library parses and builds all five fields, validates them on request, refuses header
155
+ injection when building, and ships typed with no dependencies.
156
+
157
+ ## Testing against the specifications
158
+
159
+ `python scripts/fetch_upstream.py` downloads the RFCs and the greenbytes WWW-Authenticate
160
+ test cases listed in `tests/upstream/MANIFEST.json`, checking each against its sha256.
161
+ The suite then reads character sets, quoting lists and examples from them and runs every
162
+ test case in both modes; without them those tests skip.
163
+
164
+ ## License
165
+
166
+ MIT. See [LICENSE](LICENSE).
167
+
168
+ ## Links
169
+
170
+ - Website: https://lianes8studio.com
171
+ - GitHub: https://github.com/JaviLianes8
172
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,74 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "authchallenge"
7
+ version = "0.2.0"
8
+ description = "Parse, validate and safely build HTTP WWW-Authenticate, Authorization and Authentication-Info headers per RFC 9110, typed and zero-dependency"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = ["http", "authentication", "www-authenticate", "authorization", "proxy-authenticate", "authentication-info", "basic", "digest", "bearer", "rfc9110", "rfc7235", "rfc7617", "rfc7616", "rfc6750"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Operating System :: OS Independent",
26
+ "Environment :: Console",
27
+ "Topic :: Internet :: WWW/HTTP",
28
+ "Topic :: Security",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ "Typing :: Typed",
31
+ ]
32
+ dependencies = []
33
+
34
+ [project.optional-dependencies]
35
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
36
+
37
+ [project.scripts]
38
+ authchallenge = "authchallenge._cli:main"
39
+
40
+ [project.urls]
41
+ Homepage = "https://lianes8studio.com"
42
+ Repository = "https://github.com/JaviLianes8/authchallenge"
43
+ Issues = "https://github.com/JaviLianes8/authchallenge/issues"
44
+ Changelog = "https://github.com/JaviLianes8/authchallenge/blob/main/CHANGELOG.md"
45
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
46
+ Funding = "https://buymeacoffee.com/jlianesglrs"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/authchallenge"]
50
+
51
+ [tool.hatch.build.targets.sdist]
52
+ # Third-party reference files are fetched locally and never shipped; only their manifest is.
53
+ exclude = [
54
+ ".github",
55
+ "IMPROVEMENTS-round*.md",
56
+ "tests/upstream/*.txt",
57
+ "tests/upstream/*.html",
58
+ ]
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py310"
63
+
64
+ [tool.ruff.lint]
65
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
66
+
67
+ [tool.mypy]
68
+ python_version = "3.10"
69
+ strict = true
70
+
71
+ [tool.pytest.ini_options]
72
+ testpaths = ["tests"]
73
+ pythonpath = ["src"]
74
+ addopts = "-q"
@@ -0,0 +1,3 @@
1
+ # Development dependencies: the package in editable mode plus its dev extras
2
+ # (ruff, mypy, pytest, build, twine). Install with: pip install -r requirements-dev.txt
3
+ -e .[dev]
@@ -0,0 +1,17 @@
1
+ # Local quality gate. Run before every commit. No CI runs in the cloud.
2
+ # $ErrorActionPreference does NOT stop on a native command's non-zero exit, so each tool's
3
+ # exit code is checked explicitly: a red gate must fail, never pass silently.
4
+ $ErrorActionPreference = "Stop"
5
+
6
+ function Invoke-Step($label, [scriptblock]$cmd) {
7
+ Write-Host "== $label =="
8
+ & $cmd
9
+ if ($LASTEXITCODE -ne 0) { Write-Host "FAILED: $label (exit $LASTEXITCODE)"; exit $LASTEXITCODE }
10
+ }
11
+
12
+ Invoke-Step "ruff format" { ruff format --check . }
13
+ Invoke-Step "ruff lint" { ruff check . }
14
+ Invoke-Step "mypy" { mypy --strict src tests }
15
+ Invoke-Step "pytest" { pytest }
16
+ Write-Host "gate green"
17
+ Write-Host "Reference files: python scripts/fetch_upstream.py; wheel test: AUTHCHALLENGE_E2E=1 pytest tests/test_e2e_wheel.py"
@@ -0,0 +1,39 @@
1
+ """Fetch the third-party test files listed in tests/upstream/MANIFEST.json.
2
+
3
+ The files are not part of the repository. Their tests skip until this script has fetched
4
+ them; each download is checked against the size and sha256 in the manifest.
5
+
6
+ python scripts/fetch_upstream.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import sys
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ FOLDER = Path(__file__).resolve().parents[1] / "tests" / "upstream"
18
+
19
+
20
+ def main() -> int:
21
+ manifest = json.loads((FOLDER / "MANIFEST.json").read_text(encoding="utf-8"))
22
+ failed = 0
23
+ for name, entry in manifest.items():
24
+ with urllib.request.urlopen(entry["url"], timeout=60) as response:
25
+ body = response.read()
26
+ digest = hashlib.sha256(body).hexdigest()
27
+ if len(body) != entry["size"] or digest != entry["sha256"]:
28
+ print(f"{name}: got {len(body)} bytes, sha256 {digest}; manifest differs")
29
+ failed += 1
30
+ continue
31
+ target = FOLDER / name
32
+ target.parent.mkdir(parents=True, exist_ok=True)
33
+ target.write_bytes(body)
34
+ print(f"{name}: ok")
35
+ return 1 if failed else 0
36
+
37
+
38
+ if __name__ == "__main__":
39
+ sys.exit(main())
@@ -0,0 +1,28 @@
1
+ # Build and upload to PyPI. Run scripts/check.ps1 first.
2
+ # The name must not already exist on PyPI; the token is read from a secret store, never git.
3
+ $ErrorActionPreference = "Stop"
4
+
5
+ # Works on both Windows PowerShell 5.1 and pwsh 7 (generic catch, no PS7-only types).
6
+ $taken = $true
7
+ try {
8
+ Invoke-WebRequest -UseBasicParsing "https://pypi.org/pypi/authchallenge/json" | Out-Null
9
+ } catch {
10
+ if ($_.Exception.Response.StatusCode.value__ -eq 404) { $taken = $false } else { throw }
11
+ }
12
+ if ($taken) { throw "The name 'authchallenge' already exists on PyPI. Choose another before publishing." }
13
+
14
+ $token = $env:PYPI_TOKEN
15
+ if (-not $token -and (Test-Path "C:\secrets\pypi-token.txt")) {
16
+ $token = (Get-Content "C:\secrets\pypi-token.txt" -Raw).Trim()
17
+ }
18
+ if (-not $token) {
19
+ throw "No PyPI token. Set `$env:PYPI_TOKEN or save it to C:\secrets\pypi-token.txt (never commit it)."
20
+ }
21
+
22
+ Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue
23
+ python -m build
24
+ python -m twine check dist/*
25
+
26
+ $env:TWINE_USERNAME = "__token__"
27
+ $env:TWINE_PASSWORD = $token
28
+ try { python -m twine upload dist/* } finally { Remove-Item Env:\TWINE_PASSWORD -ErrorAction SilentlyContinue }