Metadata-Version: 2.5
Name: authchallenge
Version: 0.2.0
Summary: Parse, validate and safely build HTTP WWW-Authenticate, Authorization and Authentication-Info headers per RFC 9110, typed and zero-dependency
Project-URL: Homepage, https://lianes8studio.com
Project-URL: Repository, https://github.com/JaviLianes8/authchallenge
Project-URL: Issues, https://github.com/JaviLianes8/authchallenge/issues
Project-URL: Changelog, https://github.com/JaviLianes8/authchallenge/blob/main/CHANGELOG.md
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
Author-email: Javier Lianes <javier@lianes8studio.com>
License-Expression: MIT
License-File: LICENSE
Keywords: authentication,authentication-info,authorization,basic,bearer,digest,http,proxy-authenticate,rfc6750,rfc7235,rfc7616,rfc7617,rfc9110,www-authenticate
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Description-Content-Type: text/markdown

# authchallenge

Parse and build HTTP authentication headers: `WWW-Authenticate`, `Proxy-Authenticate`,
`Authorization`, `Proxy-Authorization` and `Authentication-Info`, per RFC 9110 with the
rules of Basic (RFC 7617), Digest (RFC 7616) and Bearer (RFC 6750). Typed, zero
dependencies.

## Install

```
pip install authchallenge
```

## Usage

Parse the challenges a server offers. One comma separates both the params of a challenge
and the challenges themselves; the parser tells them apart, keeps a token68 apart from
params, and accepts several field lines:

```python
from authchallenge import parse_challenges, select_challenge

offered = parse_challenges(
    ['Basic realm="simple", Newauth realm="apps", type=1', "Negotiate a87421bff012e83c=="]
)
for challenge in offered:
    print(challenge)
print(select_challenge(offered, "negotiate", "basic").token68)
# Challenge(scheme='Basic', token68=None, params={'realm': 'simple'})
# Challenge(scheme='Newauth', token68=None, params={'realm': 'apps', 'type': '1'})
# Challenge(scheme='Negotiate', token68='a87421bff012e83c==', params={})
# a87421bff012e83c==
```

Parsing is tolerant by default: malformed parts are skipped, obsolete line folding reads
as a space, and of two params with the same name the first wins. With `strict=True`
anything the RFCs forbid raises `AuthError` with the reason:

```python
from authchallenge import AuthError, parse_challenges

print(parse_challenges('Basic realm="a", realm="b"'))
for header in ('Basic, realm="a"', "Basic realm=a", 'Basic realm="a" extra', "Bearer"):
    try:
        parse_challenges(header, strict=True)
    except AuthError as error:
        print(error)
# [Challenge(scheme='Basic', token68=None, params={'realm': 'a'})]
# auth-param 'realm' outside a challenge at offset 7
# the realm parameter must be a quoted-string, in the challenge at offset 0
# unexpected text after the value of 'realm' at offset 16
# a Bearer challenge needs at least one auth-param, in the challenge at offset 0
```

Build headers safely. Values may hold only tab, space and visible ASCII, so a CR LF can
never split the header. Quoting follows the sender rules: realm always, every Basic and
Bearer param, and the Digest lists of RFC 7616. Whatever the builder returns passes the
strict parser:

```python
from authchallenge import AuthError, build_bearer_challenge, build_challenge

digest = {"realm": "api", "qop": "auth", "nonce": "7ypf", "algorithm": "SHA-256"}
print(build_challenge("Digest", digest))
print(build_bearer_challenge(realm="example", error="invalid_token", scope=["read", "write"]))
try:
    build_challenge("Basic", {"realm": "a\r\nSet-Cookie: x=1"})
except AuthError as error:
    print(error)
# Digest realm="api", qop="auth", nonce="7ypf", algorithm=SHA-256
# Bearer realm="example", scope="read write", error="invalid_token"
# 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)
```

Credentials, with helpers for Basic and for the RFC 8187 `username*` of Digest. The
vectors come from RFC 7617 sections 2 and 2.1 (https://www.rfc-editor.org/rfc/rfc7617)
and RFC 7616 section 3.9.2 (https://www.rfc-editor.org/rfc/rfc7616):

```python
from authchallenge import decode_basic, decode_ext_value, encode_basic, parse_authorization

print(encode_basic("Aladdin", "open sesame"))
print(decode_basic("Basic dGVzdDoxMjPCow=="))
creds = parse_authorization("Digest username*=UTF-8''J%C3%A4s%C3%B8n%20Doe, realm=\"api\"")
print(creds.scheme, decode_ext_value(creds.params["username*"]))
# Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
# ('test', '123£')
# Digest Jäsøn Doe
```

Authentication-Info, the list of params a server sends after a successful login:

```python
from authchallenge import build_auth_info, parse_auth_info

info = {
    "nextnonce": "47364c23",
    "qop": "auth",
    "rspauth": "6629fae4",
    "cnonce": "0a4f",
    "nc": "00000001",
}
print(build_auth_info(info, scheme="Digest"))
print(parse_auth_info("qop=auth, NC=00000001, nc=2"))
# nextnonce="47364c23", qop=auth, rspauth="6629fae4", cnonce="0a4f", nc=00000001
# {'qop': 'auth', 'nc': '00000001'}
```

## Command line

```
curl -sI https://example.com/private | authchallenge
authchallenge --strict 'Basic realm=foo'
authchallenge --authorization 'Bearer mF_9.B5f-4.1JqM'
```

Values come as arguments or one per stdin line; a header dump on stdin is read for the
authentication fields only (the first non-empty line decides which). The output is JSON.
`--strict` reports the first broken rule of each value on stderr and exits 1.

## API

| Name | Purpose |
| --- | --- |
| `parse_challenges(value, *, strict=False)` | WWW-Authenticate / Proxy-Authenticate, one value or several lines, to a list of `Challenge` |
| `parse_authorization(value, *, strict=False)` | Authorization / Proxy-Authorization to `Credentials` |
| `parse_auth_info(value, *, strict=False, scheme=None)` | Authentication-Info to a dict |
| `build_challenge(scheme, params=None, token68=None)` | One challenge, quoted and checked |
| `build_authorization(scheme, params=None, token68=None)` | Credentials, quoted and checked |
| `build_auth_info(params, *, scheme=None)` | Authentication-Info |
| `build_bearer_challenge(*, realm, scope, error, error_description, error_uri)` | A Bearer challenge per RFC 6750 |
| `encode_basic(user_id, password, *, charset="utf-8")` | `Basic <base64>` per RFC 7617 |
| `decode_basic(credentials, *, charset="utf-8")` | `(user_id, password)` |
| `encode_ext_value(text)` / `decode_ext_value(value)` | RFC 8187 ext-values such as `username*` |
| `select_challenge(challenges, *schemes)` | The first challenge of the most preferred scheme |
| `Challenge`, `Credentials` | Frozen results: `scheme`, `token68`, `params` (names lower-cased) |
| `AuthError` | A `ValueError` raised by builders and strict parsing |

Strict mode and the builders check the grammar of RFC 9110 section 11 and these scheme
rules: Basic needs a realm and allows only `charset="UTF-8"`, and its credentials must
decode to `user-id:password`; Bearer needs at least one param, limits the characters of
scope, error, error_description and error_uri, and its credentials are a token68; Digest
quotes and leaves bare the params RFC 7616 lists, needs realm, nonce and qop in challenges
and the response params in credentials, writes `nc` as 8 hex digits, and takes one of
`username` and `username*`. Other schemes get the generic rules only.

## Why

Authentication headers are deceptively hard: one comma separates both the params within a
challenge and the challenges from each other, token68 and auth-params look alike, the
field may be repeated, and each scheme adds quoting rules of its own. The existing options
fall short: `authparser` pulls in `pyparsing`, `www-authenticate` has been unmaintained
since 2015 and only parses, and the newer `wwwauth` parses WWW-Authenticate alone. This
library parses and builds all five fields, validates them on request, refuses header
injection when building, and ships typed with no dependencies.

## Testing against the specifications

`python scripts/fetch_upstream.py` downloads the RFCs and the greenbytes WWW-Authenticate
test cases listed in `tests/upstream/MANIFEST.json`, checking each against its sha256.
The suite then reads character sets, quoting lists and examples from them and runs every
test case in both modes; without them those tests skip.

## License

MIT. See [LICENSE](LICENSE).

## Links

- Website: https://lianes8studio.com
- GitHub: https://github.com/JaviLianes8
- LinkedIn: https://www.linkedin.com/in/jlianes/
