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.
- authchallenge-0.2.0/.gitattributes +1 -0
- authchallenge-0.2.0/.gitignore +16 -0
- authchallenge-0.2.0/CHANGELOG.md +50 -0
- authchallenge-0.2.0/CONTRIBUTING.md +19 -0
- authchallenge-0.2.0/LICENSE +21 -0
- authchallenge-0.2.0/PKG-INFO +210 -0
- authchallenge-0.2.0/README.md +172 -0
- authchallenge-0.2.0/pyproject.toml +74 -0
- authchallenge-0.2.0/requirements-dev.txt +3 -0
- authchallenge-0.2.0/scripts/check.ps1 +17 -0
- authchallenge-0.2.0/scripts/fetch_upstream.py +39 -0
- authchallenge-0.2.0/scripts/release.ps1 +28 -0
- authchallenge-0.2.0/src/authchallenge/__init__.py +30 -0
- authchallenge-0.2.0/src/authchallenge/__main__.py +9 -0
- authchallenge-0.2.0/src/authchallenge/_basic.py +81 -0
- authchallenge-0.2.0/src/authchallenge/_build.py +100 -0
- authchallenge-0.2.0/src/authchallenge/_cli.py +94 -0
- authchallenge-0.2.0/src/authchallenge/_extvalue.py +42 -0
- authchallenge-0.2.0/src/authchallenge/_grammar.py +63 -0
- authchallenge-0.2.0/src/authchallenge/_helpers.py +53 -0
- authchallenge-0.2.0/src/authchallenge/_model.py +31 -0
- authchallenge-0.2.0/src/authchallenge/_parse.py +167 -0
- authchallenge-0.2.0/src/authchallenge/_scan.py +133 -0
- authchallenge-0.2.0/src/authchallenge/_schemes.py +121 -0
- authchallenge-0.2.0/src/authchallenge/py.typed +0 -0
- authchallenge-0.2.0/tests/conftest.py +92 -0
- authchallenge-0.2.0/tests/greenbytes_cases.py +79 -0
- authchallenge-0.2.0/tests/test_build.py +129 -0
- authchallenge-0.2.0/tests/test_cli.py +85 -0
- authchallenge-0.2.0/tests/test_contract.py +57 -0
- authchallenge-0.2.0/tests/test_docs.py +73 -0
- authchallenge-0.2.0/tests/test_e2e_wheel.py +117 -0
- authchallenge-0.2.0/tests/test_greenbytes.py +58 -0
- authchallenge-0.2.0/tests/test_helpers.py +133 -0
- authchallenge-0.2.0/tests/test_parse.py +96 -0
- authchallenge-0.2.0/tests/test_review_fixes.py +166 -0
- authchallenge-0.2.0/tests/test_schemes.py +134 -0
- authchallenge-0.2.0/tests/test_sources.py +160 -0
- authchallenge-0.2.0/tests/test_strict.py +88 -0
- authchallenge-0.2.0/tests/test_tolerant.py +103 -0
- 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,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 }
|