Skip to content

Commit fa2ec74

Browse files
Merge pull request #56 from QueryaHub/dev
Release 0.4.0
2 parents c750abe + de005aa commit fa2ec74

53 files changed

Lines changed: 3554 additions & 185 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/RELEASE_NOTES_v0.4.0.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# OxyJWT 0.4.0
2+
3+
**Beta** production-hardening release — security, performance, PyJWT/JWKS parity, and typing. No intentional breaking changes to the public `__all__` API beyond stricter defaults and new warnings.
4+
5+
## Highlights
6+
7+
### Security
8+
9+
- JWKS: `max_bytes`, optional `require_https`, `algorithms` checked before HTTP, refresh-on-miss `kid`, `lifespan` TTL
10+
- Reject JWKs with `use: enc`; `PyJWKSetSkipWarning` for skipped keys
11+
- `InsecureDecodeWarning` for unverified decode paths
12+
- `strict_aud`, `verify_sub`, issuer list validation, fractional `leeway` alignment
13+
- Max compact JWT size (256 KiB) before parse; HMAC secret buffers zeroized after use
14+
15+
### Features & parity
16+
17+
- `PyJWKClient`: `headers`, `ssl_context`, `cache_keys` (default off), `lifespan`
18+
- RFC 7797 detached payload decode (`detached_payload` + `b64: false`)
19+
- Custom string JWT header parameters on `encode`
20+
- Public `.pyi` stubs + `py.typed`; mypy CI job
21+
22+
### Performance
23+
24+
- Single-parse verified `decode_complete`; fewer orjson round-trips on encode/decode
25+
- O(1) JWKS `kid` index; lazy `PyJWK.key`; GIL release on unverified parse
26+
27+
### Testing & docs
28+
29+
- `tests/test_security_regression.py`; RSA/EC JWK parity tests
30+
- Stricter benchmark CI gates (≥75% vs PyJWT); expanded API/security docs
31+
32+
## Install
33+
34+
```bash
35+
pip install oxyjwt==0.4.0
36+
```
37+
38+
## Upgrade from 0.3.0
39+
40+
```bash
41+
pip install -U oxyjwt
42+
```
43+
44+
Review new warnings and stricter JWKS/JWT limits. See the full [changelog](https://github.com/QueryaHub/OxyJWT/blob/main/docs-site/docs/changelog.md#040--2026-05-22).

.github/workflows/benchmarks.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# Full throughput comparison (not a hard regression gate). HS256 ratio gates run in main CI via pytest smoke.
12
name: Benchmarks
23

34
on:
@@ -33,7 +34,10 @@ jobs:
3334
- name: Build and install OxyJWT
3435
run: .venv/bin/maturin develop --release
3536

36-
- name: Run comparison script
37+
- name: HS256 smoke regression (same gate as main CI)
38+
run: .venv/bin/python -m pytest tests/test_benchmark_jwt_libraries.py::test_benchmark_hs256_smoke_vs_competitors -q
39+
40+
- name: Run full comparison script
3741
run: |
3842
mkdir -p benchmark-results
3943
.venv/bin/python scripts/compare_jwt_libraries.py \

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,22 @@ jobs:
5353

5454
- name: Documentation build
5555
run: .venv/bin/mkdocs build --strict -f docs-site/mkdocs.yml
56+
57+
typing:
58+
name: Type check (mypy)
59+
runs-on: ubuntu-latest
60+
steps:
61+
- uses: actions/checkout@v4
62+
63+
- uses: actions/setup-python@v5
64+
with:
65+
python-version: "3.12"
66+
67+
- name: Create virtualenv
68+
run: python -m venv .venv
69+
70+
- name: Install typing dependencies
71+
run: .venv/bin/python -m pip install -U pip mypy orjson
72+
73+
- name: Mypy (public stubs + smoke)
74+
run: .venv/bin/python -m mypy python/oxyjwt tests/typing/test_public_api.py

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PyPI: configure Trusted Publishing in https://pypi.org
22
# and add a GitHub Environment named "pypi" in this repository.
3-
# Release: push an annotated or lightweight tag "v*". Example: git tag -a v0.3.0 -m "Release 0.3.0" && git push origin v0.3.0
3+
# Release: push an annotated or lightweight tag "v*". Example: git tag -a v0.4.0 -m "Release 0.4.0" && git push origin v0.4.0
44
name: Release
55

66
on:

CONTRIBUTING.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ The Python package lives under `python/`. Pytest is configured to prefer that tr
2323
- `pytest` (with optional `pytest --cov=oxyjwt`)
2424
- `mkdocs build --strict -f docs-site/mkdocs.yml` if you change documentation
2525

26+
## Branching (0.4.0)
27+
28+
Integration branch is **`dev`**. Feature work uses one branch per issue from `dev`, PRs target `dev`.
29+
30+
Always sync before branching: `git fetch --all --prune` then `git pull --ff-only origin dev`. Run the full Rust + pytest suite before opening a PR.
31+
32+
Use `gh` to set **labels** (`p0`/`p1`/`p2`, `security`, …) and milestone **0.4.0** on issues and PRs. See [docs/GITFLOW.md](docs/GITFLOW.md) and [milestone 0.4.0](https://github.com/QueryaHub/OxyJWT/milestone/1).
33+
2634
## Pull requests
2735

2836
- Keep changes focused and match existing style (formatting, typing, minimal comments).

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
OxyJWT is a Python JWT/JWS library backed by a Rust core. The public API follows **PyJWT** for `encode`, `decode`, `decode_complete`, JWK/JWKS helpers, and the `PyJWKClient`. When **signature verification** is enabled (the default), you must pass an `algorithms` allow-list, matching common PyJWT usage. Unverified decode is available only when you explicitly set `options["verify_signature"]` to `False` (treat the payload as untrusted).
44

5-
This project is **beta** software on the `0.3.x` line; see the [changelog](docs-site/docs/changelog.md) for **0.2.0** breaking changes (exception hierarchy) and **0.3.0** notes.
5+
This project is **beta** software on the `0.4.x` line; see the [changelog](docs-site/docs/changelog.md) for **0.2.0** breaking changes (exception hierarchy) and **0.4.0** production-hardening notes.
66

77
## Documentation
88

RELEASING.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Releasing OxyJWT
22

3-
This checklist is for maintainers publishing **0.3.x** (and later) to PyPI via the GitHub Actions [Release workflow](.github/workflows/release.yml).
3+
This checklist is for maintainers publishing **0.4.x** (and later) to PyPI via the GitHub Actions [Release workflow](.github/workflows/release.yml).
44

55
## Before tagging
66

@@ -35,16 +35,16 @@ This checklist is for maintainers publishing **0.3.x** (and later) to PyPI via t
3535
## Publish
3636

3737
1. Commit all release-prep changes on `main`.
38-
2. Create and push an annotated tag (example for **0.3.0**):
38+
2. Merge `dev``main`, then create and push an annotated tag (example for **0.4.0**):
3939

4040
```bash
41-
git tag -a v0.3.0 -m "Release 0.3.0"
42-
git push origin v0.3.0
41+
git tag -a v0.4.0 -m "Release 0.4.0"
42+
git push origin v0.4.0
4343
```
4444

4545
3. The **Release** workflow builds wheels (Linux x86_64/aarch64, macOS, Windows) + sdist and publishes to PyPI (requires the `pypi` environment and [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)).
4646

47-
4. On GitHub, create a **Release** from the tag. Use the **0.3.0** section in `docs-site/docs/changelog.md` as the release notes body.
47+
4. On GitHub, create a **Release** from the tag. Use [`.github/RELEASE_NOTES_v0.4.0.md`](.github/RELEASE_NOTES_v0.4.0.md) or the **0.4.0** section in `docs-site/docs/changelog.md` as the release notes body.
4848

4949
## After release
5050

SECURITY.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44

55
| Version line | Support |
66
| --- | --- |
7-
| **0.3.x** (current) | Security fixes and patch releases |
8-
| **0.2.x** | Best-effort backports only for critical issues |
9-
| **0.1.x** and older | Unsupported |
7+
| **0.4.x** (current) | Security fixes and patch releases |
8+
| **0.3.x** | Best-effort backports only for critical issues |
9+
| **0.2.x** and older | Unsupported |
1010

11-
Security fixes target the latest **0.3.x** release. See the [changelog](docs-site/docs/changelog.md) for release history.
11+
Security fixes target the latest **0.4.x** release. See the [changelog](docs-site/docs/changelog.md) for release history.
1212

1313
## Reporting a vulnerability
1414

@@ -31,3 +31,14 @@ This policy covers the `oxyjwt` Python package and its Rust extension (`_oxyjwt`
3131
## JWT usage
3232

3333
OxyJWT implements JWT/JWS signing and verification. Correct use still depends on application choices (algorithm allow-lists, audience/issuer checks, secret management). Read the [Security](docs-site/docs/security.md) documentation before deploying to production.
34+
35+
### JWKS (`PyJWKClient`)
36+
37+
When loading keys from a JWKS URL: use HTTPS (`require_https=True` in production), cap response size (`max_bytes`), pass an explicit `algorithms` list to `get_signing_key_from_jwt` (checked before HTTP), and always verify tokens with `decode` after resolving the key. See [Security — JWKS](docs-site/docs/security.md#jwks-pyjwkclient) and the [API reference](docs-site/docs/api-reference.md#pyjwkclient).
38+
39+
### `verify_signature=False`
40+
41+
Decoding with `options["verify_signature"] = False` skips cryptographic verification. OxyJWT emits `InsecureDecodeWarning` in that case. Do not use unverified payloads for authorization.
42+
43+
- The `subject` parameter is ignored unless `verify_sub` is explicitly `True` (it defaults to `False` when the signature is not verified).
44+
- `require` only checks claim **presence**, not that the token was signed by your trust anchor.

docs-site/docs/api-reference.md

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Parameters:
2020
- `payload`: JSON-compatible mapping to place in the token claims.
2121
- `key`: raw HMAC secret or an `EncodingKey`.
2222
- `algorithm`: signing algorithm. Defaults to `HS256`.
23-
- `headers`: optional JWT header values such as `kid` and `typ`.
23+
- `headers`: optional JWT header values such as `kid`, `typ`, and custom string parameters (for example `X-Request-Id`). See [Encoding — Headers](usage/encoding.md#headers).
2424

2525
Raises:
2626

@@ -40,7 +40,7 @@ def decode(
4040
detached_payload: bytes | None = None,
4141
audience: str | Iterable[str] | None = None,
4242
subject: str | None = None,
43-
issuer: str | None = None,
43+
issuer: str | Iterable[str] | None = None,
4444
leeway: float | timedelta = 0,
4545
) -> dict[str, Any]: ...
4646
```
@@ -56,15 +56,15 @@ Parameters:
5656
- `algorithms`: required server-side allow-list when signature verification is on.
5757
- `audience`: expected `aud` value or values.
5858
- `subject`: expected `sub` value (passed to the native decoder when signature verification is on).
59-
- `issuer`: expected `iss` value.
59+
- `issuer`: expected `iss` value, or an iterable of allowed issuers (token `iss` must match one).
6060
- `leeway`: clock tolerance in seconds or as a `timedelta`.
6161
- `options`: validation switches (see below). Values from a `PyJWT(..., options=...)` instance are merged with per-call `options`.
62-
- `detached_payload`: not supported; raises `NotImplementedError` if set.
62+
- `detached_payload`: required when the protected header sets `b64` to `false` (RFC 7797 detached content). Pass the raw payload bytes used when the JWS was signed.
6363

6464
Supported `options` keys (booleans unless noted):
6565

6666
- `verify_signature` — verify the JWS signature (default `True`).
67-
- `verify_exp`, `verify_nbf`, `verify_iat`, `verify_aud`, `verify_iss`
67+
- `verify_exp`, `verify_nbf`, `verify_iat`, `verify_aud`, `verify_iss`, `verify_sub`, `strict_aud`
6868
- `require_exp` — require an `exp` claim in the token
6969
- `require` — list of claim names that must be present (may also be passed on the `PyJWT` instance)
7070

@@ -145,6 +145,134 @@ class DecodingKey:
145145

146146
Use `DecodingKey` for verifying tokens.
147147

148+
## `PyJWKClient`
149+
150+
HTTP client for fetching a [JWKS](https://datatracker.ietf.org/doc/html/rfc7517) document and resolving signing keys by `kid`. Uses the Python standard library (`urllib`) only — no extra HTTP dependencies.
151+
152+
Typical flow with an identity provider that publishes rotating RSA/EC keys:
153+
154+
```python
155+
import oxyjwt
156+
157+
client = oxyjwt.PyJWKClient(
158+
"https://auth.example.com/.well-known/jwks.json",
159+
require_https=True,
160+
headers={"Authorization": "Bearer <service-token>"},
161+
)
162+
163+
token = "<compact-jwt-from-client>"
164+
signing_key = client.get_signing_key_from_jwt(
165+
token,
166+
algorithms=["RS256"], # server-side allow-list; checked before HTTP
167+
)
168+
claims = oxyjwt.decode(
169+
token,
170+
signing_key.key,
171+
algorithms=["RS256"],
172+
audience="api",
173+
issuer="https://auth.example.com",
174+
)
175+
```
176+
177+
See also the [FastAPI cookbook](cookbooks/fastapi-jwt.md) and [Security — JWKS](security.md#jwks-pyjwkclient).
178+
179+
### Two-tier caching
180+
181+
| Tier | Option | Default | Behavior |
182+
|------|--------|---------|----------|
183+
| **1 — JWK Set** | `cache_jwk_set`, `lifespan` | on, 300s | Caches the parsed JWKS JSON. Refetch when TTL expires or `get_jwk_set(refresh=True)`. |
184+
| **2 — signing keys** | `cache_keys`, `max_cached_keys` | off, 16 | LRU of `PyJWK` objects by `kid` (no time expiry). Opt-in; matches PyJWT default. |
185+
186+
On `get_signing_key`, if `kid` is missing from the current set, the client refetches JWKS **once** and retries (key rotation). A second miss raises `KeyError` (no further HTTP retries in that call).
187+
188+
### Constructor
189+
190+
```python
191+
class PyJWKClient:
192+
def __init__(
193+
self,
194+
uri: str,
195+
*,
196+
cache_jwk_set: bool = True,
197+
cache_keys: bool = False,
198+
max_cached_keys: int = 16,
199+
timeout: float = 30.0,
200+
max_bytes: int = 262_144,
201+
require_https: bool = False,
202+
headers: Mapping[str, Any] | None = None,
203+
ssl_context: ssl.SSLContext | None = None,
204+
lifespan: float = 300.0,
205+
) -> None: ...
206+
```
207+
208+
| Parameter | Description |
209+
|-----------|-------------|
210+
| `uri` | JWKS endpoint URL (`http://` or `https://`). |
211+
| `cache_jwk_set` | Cache the fetched JWKS document (tier 1). |
212+
| `lifespan` | Seconds before tier-1 cache expires; must be &gt; 0 when `cache_jwk_set=True`. |
213+
| `cache_keys` | Enable per-`kid` signing-key LRU (tier 2). Default `False` (PyJWT parity). |
214+
| `max_cached_keys` | Max LRU entries when `cache_keys=True`. |
215+
| `timeout` | HTTP GET timeout in seconds. |
216+
| `max_bytes` | Max JWKS response size (default 256 KiB). Larger bodies raise `PyJWKClientError`. |
217+
| `require_https` | When `True`, reject non-HTTPS `uri` values. |
218+
| `headers` | Extra request headers merged with `User-Agent` and `Accept: application/json`. |
219+
| `ssl_context` | `ssl.SSLContext` for HTTPS (default `ssl.create_default_context()`). |
220+
221+
Raises `ValueError` for invalid `uri` / `max_bytes`; `TypeError` for invalid `ssl_context`; `PyJWKClientError` when `require_https` blocks the URI or `lifespan` is invalid.
222+
223+
### `get_jwk_set`
224+
225+
```python
226+
def get_jwk_set(self, refresh: bool = False) -> PyJWKSet: ...
227+
```
228+
229+
Returns a `PyJWKSet` parsed from the endpoint. When `refresh=False` and tier-1 cache is valid, returns the cached set without HTTP. When `refresh=True`, always fetches a new document and updates the cache (clears tier-2 LRU entries).
230+
231+
Raises `PyJWKClientConnectionError` on network/timeout failures; `PyJWKClientError` on oversized body or invalid JSON shape.
232+
233+
### `get_signing_key`
234+
235+
```python
236+
def get_signing_key(self, kid: str) -> PyJWK: ...
237+
```
238+
239+
Returns the `PyJWK` for `kid`. Uses tier-1 cache, then tier-2 LRU if enabled. Refetches JWKS once on `KeyError` (rotation). Empty `kid` raises `PyJWKClientError`.
240+
241+
### `get_signing_key_from_jwt`
242+
243+
```python
244+
def get_signing_key_from_jwt(
245+
self,
246+
jwt: str | bytes,
247+
algorithms: list[str] | None = None,
248+
) -> PyJWK: ...
249+
```
250+
251+
Reads `kid` (and optionally `alg`) from the token header via `get_unverified_header`, then calls `get_signing_key`.
252+
253+
When `algorithms` is provided:
254+
255+
- empty list → `InvalidAlgorithmError`;
256+
- header `alg` not in the list → `InvalidAlgorithmError` **before** any JWKS HTTP request;
257+
- `none``InvalidAlgorithmError`.
258+
259+
Missing or empty `kid``PyJWKClientError`.
260+
261+
### Security notes
262+
263+
- Pass your server-side **`algorithms`** allow-list to `get_signing_key_from_jwt` and again to `decode` — do not trust the header `alg` alone. See [Security](security.md).
264+
- Prefer **`require_https=True`** in production.
265+
- Use **`max_bytes`** to limit denial-of-service from huge JWKS responses.
266+
- `get_unverified_header` inside `get_signing_key_from_jwt` does not verify the signature; only use the returned `PyJWK.key` with verified `decode`. Details: [SECURITY.md](https://github.com/QueryaHub/OxyJWT/blob/main/SECURITY.md) and [Security — unverified helpers](security.md#treat-unverified-helpers-as-inspection-only).
267+
- Encryption keys (`use: enc`) in the JWKS are rejected when building `PyJWK`; skipped entries emit `PyJWKSetSkipWarning`.
268+
269+
### Related exceptions
270+
271+
- `PyJWKClientError` — invalid client configuration, JWKS shape, size limits, missing `kid`.
272+
- `PyJWKClientConnectionError` — HTTP/URL errors (subclass of `PyJWKClientError`).
273+
- `KeyError``kid` not found after one refresh attempt.
274+
- `InvalidAlgorithmError` — disallowed `alg` when `algorithms` is passed to `get_signing_key_from_jwt`.
275+
148276
## Exceptions
149277

150278
All OxyJWT exceptions inherit from `OxyJWTError`. The layout matches PyJWT: `InvalidTokenError` is the common base for most decode-time errors; `DecodeError` and `InvalidSignatureError` nest under it.

docs-site/docs/benchmarks.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

33
OxyJWT is optimized for throughput on typical JWT workloads. Numbers depend on CPU, compiler flags, Python version, and the cryptographic backend (see the root [README](https://github.com/QueryaHub/OxyJWT/blob/main/README.md) for the default `aws_lc_rs` backend and the `rust_crypto` alternative on some Linux aarch64 wheels).
44

5+
## Three levels of benchmarking
6+
7+
| Level | How to run | Purpose |
8+
|-------|------------|---------|
9+
| **Smoke** | Default `pytest` (`tests/test_benchmark_jwt_libraries.py::test_benchmark_hs256_smoke_vs_competitors`) | Fast HS256 regression gate in every CI run; OxyJWT must stay ≥75% of PyJWT encode/decode throughput (was 25% / 4× slack). |
10+
| **Extended** | `OXYJWT_BENCHMARK=1 pytest -m benchmark` | HS256, RS256, EdDSA with moderate iterations; optional locally or before release. |
11+
| **Full** | Weekly [Benchmarks workflow](https://github.com/QueryaHub/OxyJWT/blob/main/.github/workflows/benchmarks.yml) or the script below | Higher iteration counts; Markdown artifact for trend spotting, not a hard gate. |
12+
13+
## Reference ratios (HS256 smoke parameters)
14+
15+
Measured on a typical Linux dev machine with `maturin develop --release`, 50 iterations, 1 round, warmup 8 (same as CI smoke):
16+
17+
| Operation | OxyJWT (ops/s) | PyJWT (ops/s) | OxyJWT / PyJWT |
18+
|-----------|----------------|---------------|----------------|
19+
| encode | ~400k+ | ~130k+ | ~|
20+
| decode | ~160k+ | ~115k+ | ~1.4× |
21+
22+
CI asserts **≥75%** of PyJWT for both operations so large regressions fail without requiring absolute ops/s parity across runners.
23+
524
## Running comparisons locally
625

726
The repository includes [`scripts/compare_jwt_libraries.py`](https://github.com/QueryaHub/OxyJWT/blob/main/scripts/compare_jwt_libraries.py), which benchmarks OxyJWT against PyJWT, Authlib, and python-jose where supported.
@@ -22,7 +41,9 @@ Raw JSON/Markdown outputs are gitignored; keep them local or attach them to rele
2241

2342
## CI artifacts
2443

25-
The [Benchmarks workflow](https://github.com/QueryaHub/OxyJWT/blob/main/.github/workflows/benchmarks.yml) runs on a weekly schedule and on manual dispatch. It uploads a Markdown summary as a workflow artifact (smaller iteration counts than a full local run). Use it to spot large regressions over time, not as absolute performance guarantees.
44+
The [Benchmarks workflow](https://github.com/QueryaHub/OxyJWT/blob/main/.github/workflows/benchmarks.yml) runs on a weekly schedule and on `workflow_dispatch`. It uploads `benchmark-results/ci-bench.md` (HS256, RS256, EdDSA; 200 iterations × 2 rounds). Use it to spot large regressions over time, not as absolute performance guarantees.
45+
46+
Main [CI](https://github.com/QueryaHub/OxyJWT/blob/main/.github/workflows/ci.yml) enforces the HS256 smoke ratios on every push/PR.
2647

2748
## Methodology
2849

0 commit comments

Comments
 (0)