diff --git a/.github/RELEASE_NOTES_v0.5.0.md b/.github/RELEASE_NOTES_v0.5.0.md new file mode 100644 index 0000000..150e721 --- /dev/null +++ b/.github/RELEASE_NOTES_v0.5.0.md @@ -0,0 +1,48 @@ +# OxyJWT 0.5.0 + +**Beta** performance and hardening release β€” faster encode/decode hot paths, JWKS concurrency fixes, stricter compact JWT handling, and expanded security regression coverage. No intentional breaking changes to the public `__all__` API. + +## Highlights + +### Performance + +- Verified decode avoids unconditional header parse unless RFC 7797 detached form (`b64: false`, empty payload segment) +- Single-parse unverified decode; native encode when no custom `json_encoder` +- Rust `decode_verified_complete`: fewer key clones, one detach path; RFC 7797 claims validated in Rust +- Skip redundant Python `aud` / `iss` / `sub` checks after Rust validation +- Lazy `PyJWK` / `DecodingKey` materialization for large JWKS sets + +### Security + +- `detached_payload` capped at 256 KiB (RFC 7797) +- Unified strict compact JWT segment validation before decode +- Thread-safe `PyJWKClient` cache under concurrent `get_signing_key` +- Expanded `tests/test_security_regression.py` contract (always-on in CI) + +### Fixes + +- `issuer=` now always validates `iss`; missing `iss` raises `InvalidIssuerError` (PyJWT parity) + +### CI & benchmarks + +- Full CI on pushes to `dev`; PyPI release workflow gated on passing CI +- HS256 smoke benchmark: 3 rounds, median ratio vs PyJWT (β‰₯75% gate) +- Docs: PEM vs cached competitor key modes for fair asymmetric comparisons + +## Install + +```bash +pip install oxyjwt==0.5.0 +``` + +## Upgrade from 0.4.0 + +```bash +pip install -U oxyjwt +``` + +- No intentional breaking changes to public symbols. +- Stricter validation on malformed compact JWTs and oversized detached payloads. +- When passing `issuer=`, a token without `iss` now fails with `InvalidIssuerError` (was inconsistent before). + +See the full [changelog](https://github.com/QueryaHub/OxyJWT/blob/main/docs-site/docs/changelog.md#050--2026-05-22). diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 29c5183..25b7fb9 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -37,7 +37,7 @@ jobs: - name: HS256 smoke regression (same gate as main CI) run: .venv/bin/python -m pytest tests/test_benchmark_jwt_libraries.py::test_benchmark_hs256_smoke_vs_competitors -q - - name: Run full comparison script + - name: Run full comparison script (default pem keys) run: | mkdir -p benchmark-results .venv/bin/python scripts/compare_jwt_libraries.py \ @@ -47,7 +47,17 @@ jobs: --warmup 50 \ --markdown benchmark-results/ci-bench.md + - name: Run asymmetric comparison (cached competitor keys) + run: | + .venv/bin/python scripts/compare_jwt_libraries.py \ + --algorithms RS256,EdDSA \ + --iterations 200 \ + --rounds 2 \ + --warmup 50 \ + --competitor-key-mode cached \ + --markdown benchmark-results/ci-bench-cached.md + - uses: actions/upload-artifact@v4 with: name: benchmark-md - path: benchmark-results/ci-bench.md + path: benchmark-results/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4addd58..9260116 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [main] + branches: [main, dev] jobs: test: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85b5a44..24a7c34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ # PyPI: configure Trusted Publishing in https://pypi.org # and add a GitHub Environment named "pypi" in this repository. -# 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 +# Release: push an annotated or lightweight tag "v*". Example: git tag -a v0.5.0 -m "Release 0.5.0" && git push origin v0.5.0 name: Release on: @@ -18,7 +18,11 @@ permissions: id-token: write jobs: + ci-gate: + uses: ./.github/workflows/ci.yml + linux: + needs: [ci-gate] runs-on: ubuntu-latest strategy: fail-fast: false @@ -48,6 +52,7 @@ jobs: path: dist macos: + needs: [ci-gate] runs-on: macos-latest strategy: matrix: @@ -69,6 +74,7 @@ jobs: path: dist windows: + needs: [ci-gate] runs-on: windows-latest steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 6428c45..e00581a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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). -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. +This project is **beta** software on the `0.5.x` line; see the [changelog](docs-site/docs/changelog.md) for **0.2.0** breaking changes (exception hierarchy), **0.4.0** production-hardening, and **0.5.0** performance notes. ## Documentation @@ -129,6 +129,8 @@ python -m venv .venv The script covers HMAC, RSA, RSA-PSS, ECDSA, and EdDSA algorithms. Unsupported library/algorithm combinations are reported as `0` throughput. For a quicker smoke test, pass something like `--algorithms HS256,RS256,EdDSA --iterations 100 --rounds 1`. +**Benchmark fairness:** the default `--competitor-key-mode pem` keeps pre-parsed `EncodingKey`/`DecodingKey` for OxyJWT while competitors often receive PEM bytes (see [Benchmarks](docs-site/docs/benchmarks.md)). For asymmetric comparisons, also run with `--competitor-key-mode cached`. + Benchmark outputs are ignored by git because results depend on the machine, Python version, compiler flags, and CPU state. The default Rust crypto backend is `aws_lc_rs`, chosen for stronger performance on RSA and ECDSA in local benchmarks. You can still build with `rust_crypto` for comparison: @@ -152,20 +154,19 @@ OxyJWT implements JWT/JWS signing and verification. JWE encryption is not part o See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and pull request expectations. Report security issues privately via [SECURITY.md](SECURITY.md). -## πŸš€ Performance Benchmarks +## Performance benchmarks -OxyJWT is built for absolute speed. By bypassing the Python GIL and leveraging Rust's cryptographic primitives, it completely destroys standard Python libraries in both symmetric and asymmetric cryptography. +OxyJWT is optimized for throughput on typical JWT workloads (especially HMAC). See [docs-site/docs/benchmarks.md](docs-site/docs/benchmarks.md) for smoke vs extended vs full workflows and key-preparation modes. -Below is a performance comparison measured in **Operations per second (ops/sec)** (higher is better): +The table below is a **historical snapshot** (default script settings, `pem` competitor keys). RS256 encode numbers are not comparable to `--competitor-key-mode cached`; re-run the script on your hardware before drawing conclusions. -| Algorithm | Operation | ⚑ OxyJWT | PyJWT | Authlib | python-jose | -| :--- | :--- | :--- | :--- | :--- | :--- | -| **HS256** | Encode | **620,270** | 140,670 | 99,408 | 99,507 | -| **HS256** | Decode | **361,073** | 109,272 | 94,823 | 51,838 | -| **RS256** | Encode | **1,934** | 35 | 35 | 35 | -| **RS256** | Decode | **58,752** | 27,200 | 26,085 | 23,046 | -| **EdDSA** | Encode | **69,105** | 17,518 | 15,014 | N/A | -| **EdDSA** | Decode | **31,666** | 10,741 | 10,317 | N/A | -| **ES256** | Encode | **46,559** | 19,632 | 16,199 | 19,723 | +| Algorithm | Operation | OxyJWT | PyJWT | Authlib | python-jose | +| :--- | :--- | ---: | ---: | ---: | ---: | -*Tested against standard Python ecosystem libraries. OxyJWT consistently dominates across all algorithms.* \ No newline at end of file +| **HS256** | Encode | 620,270 | 140,670 | 99,408 | 99,507 | +| **HS256** | Decode | 361,073 | 109,272 | 94,823 | 51,838 | +| **RS256** | Encode | 1,934 | 35 | 35 | 35 | +| **RS256** | Decode | 58,752 | 27,200 | 26,085 | 23,046 | +| **EdDSA** | Encode | 69,105 | 17,518 | 15,014 | N/A | +| **EdDSA** | Decode | 31,666 | 10,741 | 10,317 | N/A | +| **ES256** | Encode | 46,559 | 19,632 | 16,199 | 19,723 | \ No newline at end of file diff --git a/RELEASING.md b/RELEASING.md index b9c34e2..4fbc147 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing OxyJWT -This checklist is for maintainers publishing **0.4.x** (and later) to PyPI via the GitHub Actions [Release workflow](.github/workflows/release.yml). +This checklist is for maintainers publishing **0.5.x** (and later) to PyPI via the GitHub Actions [Release workflow](.github/workflows/release.yml). ## Before tagging @@ -20,6 +20,12 @@ This checklist is for maintainers publishing **0.4.x** (and later) to PyPI via t mkdocs build --strict -f docs-site/mkdocs.yml ``` + Optional extended benchmark (slower, not required for every PR): + + ```bash + OXYJWT_BENCHMARK=1 python -m pytest -m benchmark tests/test_benchmark_jwt_libraries.py + ``` + 4. **Smoke import** (after `maturin develop`): ```bash @@ -35,16 +41,16 @@ This checklist is for maintainers publishing **0.4.x** (and later) to PyPI via t ## Publish 1. Commit all release-prep changes on `main`. -2. Merge `dev` β†’ `main`, then create and push an annotated tag (example for **0.4.0**): +2. Merge `dev` β†’ `main`, then create and push an annotated tag (example for **0.5.0**): ```bash - git tag -a v0.4.0 -m "Release 0.4.0" - git push origin v0.4.0 + git tag -a v0.5.0 -m "Release 0.5.0" + git push origin v0.5.0 ``` -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/)). +3. The **Release** workflow runs full [CI](.github/workflows/ci.yml) via `workflow_call`, then builds wheels (Linux x86_64/aarch64, macOS, Windows) + sdist and publishes to PyPI only if CI passes (requires the `pypi` environment and [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)). -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. +4. On GitHub, create a **Release** from the tag. Use [`.github/RELEASE_NOTES_v0.5.0.md`](.github/RELEASE_NOTES_v0.5.0.md) or the **0.5.0** section in `docs-site/docs/changelog.md` as the release notes body. ## After release diff --git a/SECURITY.md b/SECURITY.md index ef5fcba..0bc2b09 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,11 +4,12 @@ | Version line | Support | | --- | --- | -| **0.4.x** (current) | Security fixes and patch releases | +| **0.5.x** (current) | Security fixes and patch releases | +| **0.4.x** | Best-effort backports only for critical issues | | **0.3.x** | Best-effort backports only for critical issues | | **0.2.x** and older | Unsupported | -Security fixes target the latest **0.4.x** release. See the [changelog](docs-site/docs/changelog.md) for release history. +Security fixes target the latest **0.5.x** release. See the [changelog](docs-site/docs/changelog.md) for release history. ## Reporting a vulnerability diff --git a/docs-site/docs/benchmarks.md b/docs-site/docs/benchmarks.md index 22deaf4..e6c6cdb 100644 --- a/docs-site/docs/benchmarks.md +++ b/docs-site/docs/benchmarks.md @@ -12,14 +12,14 @@ OxyJWT is optimized for throughput on typical JWT workloads. Numbers depend on C ## Reference ratios (HS256 smoke parameters) -Measured on a typical Linux dev machine with `maturin develop --release`, 50 iterations, 1 round, warmup 8 (same as CI smoke): +Measured on a typical Linux dev machine with `maturin develop --release`, 50 iterations, 3 rounds (median timing), warmup 8 (CI smoke): | Operation | OxyJWT (ops/s) | PyJWT (ops/s) | OxyJWT / PyJWT | |-----------|----------------|---------------|----------------| | encode | ~400k+ | ~130k+ | ~3Γ— | | decode | ~160k+ | ~115k+ | ~1.4Γ— | -CI asserts **β‰₯75%** of PyJWT for both operations so large regressions fail without requiring absolute ops/s parity across runners. +CI asserts **β‰₯75%** of PyJWT (median ops/s across rounds) for both operations so large regressions fail without requiring absolute ops/s parity across runners. ## Running comparisons locally @@ -37,6 +37,17 @@ python3 -m venv .venv --markdown benchmark-results/local.bench.md ``` +Fairer RSA/EdDSA comparison against PyJWT (cached competitor keys): + +```bash +.venv/bin/python scripts/compare_jwt_libraries.py \ + --algorithms RS256,EdDSA \ + --iterations 1000 \ + --rounds 3 \ + --competitor-key-mode cached \ + --markdown benchmark-results/local-cached.bench.md +``` + Raw JSON/Markdown outputs are gitignored; keep them local or attach them to release notes as needed. ## CI artifacts @@ -49,6 +60,12 @@ Main [CI](https://github.com/QueryaHub/OxyJWT/blob/main/.github/workflows/ci.yml - **Metric:** operations per second (encode and decode measured separately). - **Warmup:** reduces JIT and allocator noise; see script defaults. -- **Fairness:** each library uses its supported key types; unsupported pairs are recorded as zero throughput in the script output. +- **Key preparation (`--competitor-key-mode`):** + - **`pem` (default)** β€” used by CI smoke and the extended pytest sweep. The harness builds one signing/verification key per library before timing. OxyJWT uses `EncodingKey` / `DecodingKey` parsed from PEM once; PyJWT, Authlib, and python-jose receive PEM `str`/`bytes` and may parse that material inside each timed call. This matches β€œpass a PEM string to the library” usage but can understate competitor throughput on RSA/EC/EdDSA. + - **`cached`** β€” competitors that support it receive preloaded `cryptography` key objects (same idea as holding parsed keys in application code). Use this for fairer asymmetric comparisons and for release-note / weekly benchmark artifacts. +- **HMAC (HS\*)** β€” both modes pass the same raw secret; key-mode differences are negligible. +- Unsupported library/algorithm pairs are recorded as zero throughput in the script output. + +For asymmetric algorithms, prefer reporting **both** modes or explicitly label which mode was used. The headline table in the root README was measured with defaults that favor OxyJWT on RSA unless noted otherwise. Always compare on your own target hardware before choosing a library for production latency budgets. diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index 1a39610..396152c 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -4,6 +4,49 @@ (No changes yet.) +## 0.5.0 β€” 2026-05-22 + +Performance and hardening release: faster encode/decode hot paths, JWKS concurrency fixes, stricter compact JWT validation, PyJWT-aligned issuer errors, and expanded security regression tests. The API remains pre-1.0 (Beta). See [Versioning](versioning.md) and [`SECURITY.md` on GitHub](https://github.com/QueryaHub/OxyJWT/blob/main/SECURITY.md). + +### Upgrading from 0.4.0 + +```bash +pip install -U oxyjwt +``` + +- No intentional breaking changes to the public `__all__` surface. +- Malformed compact JWTs (wrong segment count) are rejected consistently in Rust before decode. +- `detached_payload` is capped at **256 KiB** (RFC 7797). +- When `issuer=` is passed, a token **without** `iss` now raises **`InvalidIssuerError`** (PyJWT parity; previously could slip through on some paths). + +### Fixed + +- `issuer=` always runs issuer validation in Python; missing `iss` raises `InvalidIssuerError` instead of being skipped. +- `PyJWKClient` JWKS / signing-key cache is thread-safe under concurrent `get_signing_key` (lock around cache mutations). + +### Security + +- `detached_payload` size capped at 256 KiB before attach (RFC 7797). +- Compact JWT segment validation unified in Rust (reject extra/missing segments before `jwt` parse). +- Expanded `tests/test_security_regression.py` β€” oversized JWT, detached cap, `none` alg, concurrent JWKS client, issuer-without-iss. + +### Performance + +- Verified decode: skip `get_unverified_header` unless empty payload segment (RFC 7797 detached form). +- Unverified decode / `get_unverified_header`: single native parse path (no double segment split). +- `encode`: use Rust `encode` directly when no custom `json_encoder` (no `encode_json` round-trip). +- `decode_verified_complete`: hold decoding key by reference; combine detach + verify in one Rust path. +- Skip redundant Python `aud` / `iss` / `sub` validation when Rust already validated on verified decode. +- RFC 7797 verified path: `exp` / `nbf` / `iat` validated in Rust for detached tokens. +- `PyJWK` / `PyJWKSet`: lazy `DecodingKey` materialization; large JWKS sets avoid upfront parse of every key. + +### CI & documentation + +- Full CI runs on pushes to `dev` (same gates as PRs). +- PyPI [Release workflow](https://github.com/QueryaHub/OxyJWT/blob/main/.github/workflows/release.yml) runs CI via `workflow_call` before publishing. +- HS256 smoke benchmark: 3 rounds, **median** ops/s vs PyJWT; gate remains β‰₯75%. +- [Benchmarks](benchmarks.md): document smoke / extended / full tiers and PEM vs `cached` competitor key modes. + ## 0.4.0 β€” 2026-05-22 Production hardening release: security fixes, performance improvements, expanded PyJWT/JWKS parity, public typing stubs, and stricter CI. The API remains pre-1.0 (Beta). See [Versioning](versioning.md) and [`SECURITY.md` on GitHub](https://github.com/QueryaHub/OxyJWT/blob/main/SECURITY.md). diff --git a/docs-site/docs/index.md b/docs-site/docs/index.md index 9215d05..a97f836 100644 --- a/docs-site/docs/index.md +++ b/docs-site/docs/index.md @@ -1,6 +1,6 @@ # OxyJWT -OxyJWT is a Python JWT/JWS library with a Rust implementation underneath. It gives Python code a **PyJWT-compatible** `encode` / `decode` / `decode_complete` API (plus JWK helpers) while keeping verified decoding tied to an explicit `algorithms` list by default. See [Migration from PyJWT](usage/migration-pyjwt.md) for exception hierarchy changes in **0.2.0**. Release **0.4.0** is beta-quality; see [Versioning](versioning.md) for stability expectations. +OxyJWT is a Python JWT/JWS library with a Rust implementation underneath. It gives Python code a **PyJWT-compatible** `encode` / `decode` / `decode_complete` API (plus JWK helpers) while keeping verified decoding tied to an explicit `algorithms` list by default. See [Migration from PyJWT](usage/migration-pyjwt.md) for exception hierarchy changes in **0.2.0**. Release **0.5.0** is beta-quality; see [Versioning](versioning.md) for stability expectations. The short version: diff --git a/docs-site/docs/security.md b/docs-site/docs/security.md index 4cc99ae..82ed54a 100644 --- a/docs-site/docs/security.md +++ b/docs-site/docs/security.md @@ -93,7 +93,7 @@ When you pass a raw `str` / `bytes` HMAC secret (or use `EncodingKey.from_secret ## Compact JWT size limit -OxyJWT rejects compact JWT strings larger than **256 KiB** (same order of magnitude as the default JWKS `max_bytes` cap) with `DecodeError` before base64 or JSON parsing. This applies to verified `decode`, `decode_unverified`, `get_unverified_header`, and `jws_parse_compact`. Legitimate tokens are far smaller; huge inputs are usually denial-of-service attempts. +OxyJWT rejects compact JWT strings larger than **256 KiB** (same order of magnitude as the default JWKS `max_bytes` cap) with `DecodeError` before base64 or JSON parsing. This applies to verified `decode`, `decode_unverified`, `get_unverified_header`, and `jws_parse_compact`. RFC 7797 **`detached_payload`** bytes passed to verified decode are capped at the same limit before copy or JSON parsing. Legitimate tokens are far smaller; huge inputs are usually denial-of-service attempts. ## Treat Unverified Helpers As Inspection Only diff --git a/docs-site/docs/versioning.md b/docs-site/docs/versioning.md index 6363c07..9d322a2 100644 --- a/docs-site/docs/versioning.md +++ b/docs-site/docs/versioning.md @@ -23,7 +23,7 @@ Typical **major** (breaking) changes include: Releases `0.x` are pre-1.0. Minor bumps in `0.x` may include small API adjustments while PyJWT parity hardens. After **1.0.0**, breaking changes are reserved for major versions. -The PyPI `Development Status` classifier tracks maturity (Beta on the `0.4.x` line; **Stable** is planned for `1.0.0`). +The PyPI `Development Status` classifier tracks maturity (Beta on the `0.5.x` line; **Stable** is planned for `1.0.0`). ## Rust crate version diff --git a/docs/GITFLOW.md b/docs/GITFLOW.md index 46df6c6..7677163 100644 --- a/docs/GITFLOW.md +++ b/docs/GITFLOW.md @@ -64,6 +64,7 @@ pytest gh pr create --base dev --title "feat(jwks): refresh JWKS on unknown kid (#42)" --body "Closes #42" ``` 6. After review and green CI, squash-merge or merge commit into `dev`. + Direct pushes to `dev` also run [CI](.github/workflows/ci.yml) (same jobs as PRs). 7. Sync again, then delete the feature branch: ```bash git fetch --all --prune diff --git a/pyproject.toml b/pyproject.toml index 6d71a1a..b83384d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "oxyjwt" -version = "0.4.0" +version = "0.5.0" description = "A Python JWT library powered by a Rust core." readme = "README.md" requires-python = ">=3.10" diff --git a/python/oxyjwt/__init__.py b/python/oxyjwt/__init__.py index cc0ec16..0108e4f 100644 --- a/python/oxyjwt/__init__.py +++ b/python/oxyjwt/__init__.py @@ -1,6 +1,6 @@ """OxyJWT public API (PyJWT-shaped module surface).""" -__version__ = "0.4.0" +__version__ = "0.5.0" from ._oxyjwt import ( DecodingKey, diff --git a/python/oxyjwt/api_jwt.py b/python/oxyjwt/api_jwt.py index f82c620..679bff5 100644 --- a/python/oxyjwt/api_jwt.py +++ b/python/oxyjwt/api_jwt.py @@ -73,6 +73,26 @@ def _as_plain_dict(obj: Any) -> dict[str, Any]: raise TypeError("expected JSON object for JWT claims or header") +def _require_detached_payload_for_rfc7797( + token: str, + *, + detached_payload: bytes | None, + verify_signature: bool, +) -> None: + """Raise when verified decode needs RFC 7797 detached_payload (b64: false).""" + if detached_payload is not None or not verify_signature: + return + segments = token.split(".", 2) + if len(segments) < 3 or segments[1] != "": + return + header_peek = _as_plain_dict(_oxyjwt.get_unverified_header(token)) + if header_peek.get("b64") is False: + raise DecodeError( + 'It is required that you pass in a value for the "detached_payload" ' + "argument to decode a message having the b64 header set to false." + ) + + def _json_default_from_encoder(encoder_cls: type[JSONEncoder]) -> Callable[[Any], Any]: enc = encoder_cls() @@ -108,6 +128,9 @@ def encode( v = pl.get(time_claim) if isinstance(v, datetime): pl[time_claim] = timegm(v.utctimetuple()) + alg = algorithm if algorithm is not None else "HS256" + if json_encoder is None and not sort_headers: + return _oxyjwt.encode(pl, key, alg, headers) opts = orjson.OPT_SORT_KEYS if sort_headers else 0 if json_encoder is None: body_b = orjson.dumps(pl, option=opts) @@ -117,7 +140,6 @@ def encode( option=opts, default=_json_default_from_encoder(json_encoder), ) - alg = algorithm if algorithm is not None else "HS256" return _oxyjwt.encode_json(body_b, key, alg, headers) def decode( @@ -209,13 +231,11 @@ def decode_complete( raise DecodeError( 'It is required that you pass in a value for the "algorithms" argument when calling decode().' ) - if detached_payload is None and co.get("verify_signature", True): - header_peek = _as_plain_dict(_oxyjwt.get_unverified_header(token)) - if header_peek.get("b64") is False: - raise DecodeError( - 'It is required that you pass in a value for the "detached_payload" ' - "argument to decode a message having the b64 header set to false." - ) + _require_detached_payload_for_rfc7797( + token, + detached_payload=detached_payload, + verify_signature=bool(co.get("verify_signature", True)), + ) merged = {**self._options, **co} if not co.get("verify_signature", True): if subject is not None and not merged.get("verify_sub", False): @@ -255,12 +275,12 @@ def decode_complete( lwf = _leeway_seconds(leeway) if not co.get("verify_signature", True): - header = _as_plain_dict(_oxyjwt.get_unverified_header(token)) - _s, _header_obj, _pld, sigb = _oxyjwt.jws_parse_compact(token) + _s, header_obj, pld_bytes, sigb = _oxyjwt.jws_parse_compact(token) + header = _as_plain_dict(header_obj) if detached_payload is not None: pl_d = _as_plain_dict(orjson.loads(bytes(detached_payload))) else: - pl_d = _as_plain_dict(_oxyjwt.decode_unverified(token)) + pl_d = _as_plain_dict(orjson.loads(bytes(pld_bytes))) self._validate_claims( pl_d, merged, audience, issuer, subject, lwf ) @@ -302,7 +322,8 @@ def decode_complete( issuer, subject, lwf, - rust_time_claims=whole_leeway and detached_payload is None, + rust_time_claims=whole_leeway, + rust_standard_claims=whole_leeway and detached_payload is None, ) return { "payload": pl_out, @@ -320,12 +341,17 @@ def _validate_claims( leeway: float = 0, *, rust_time_claims: bool = False, + rust_standard_claims: bool = False, ) -> None: """Validate claims after decode. Verified decode (`rust_time_claims=True`): ``exp`` and ``nbf`` are checked in Rust (jsonwebtoken); this layer handles ``iat`` plus audience/issuer/sub rules that depend on call-time parameters. + + When ``rust_standard_claims=True``, aud/iss/sub checks already run in Rust + for call-time ``audience`` / ``issuer`` / ``subject``; Python still runs + ``strict_aud`` and claim checks Rust does not cover. """ self._validate_required(payload, options) now = time.time() @@ -336,15 +362,23 @@ def _validate_claims( self._validate_nbf_fields(payload, now, leeway) if "exp" in payload and options.get("verify_exp", True): self._validate_exp_fields(payload, now, leeway) - if options.get("verify_iss", True): - self._validate_iss_field(payload, issuer) + strict_aud = bool(options.get("strict_aud", False)) if options.get("verify_aud", True): - self._validate_aud_field( - payload, - audience, - strict=bool(options.get("strict_aud", False)), - ) - if options.get("verify_sub", True): + if strict_aud or not ( + rust_standard_claims and audience is not None + ): + self._validate_aud_field( + payload, + audience, + strict=strict_aud, + ) + if options.get("verify_iss", True) and ( + issuer is not None or not rust_standard_claims + ): + self._validate_iss_field(payload, issuer) + if options.get("verify_sub", True) and not ( + rust_standard_claims and subject is not None + ): self._validate_sub_field(payload, subject) @staticmethod @@ -410,7 +444,7 @@ def _validate_iss_field( if issuer is None: return if "iss" not in payload: - raise MissingRequiredClaimError("iss") + raise InvalidIssuerError("Invalid issuer") issuers = [issuer] if isinstance(issuer, str) else list(issuer) if payload["iss"] not in issuers: raise InvalidIssuerError("Invalid issuer") diff --git a/python/oxyjwt/jwk.py b/python/oxyjwt/jwk.py index 72e2ebc..f4dcbc7 100644 --- a/python/oxyjwt/jwk.py +++ b/python/oxyjwt/jwk.py @@ -65,7 +65,8 @@ def key_type(self) -> str | None: @property def key_id(self) -> str | None: - return self._jwk.get("kid") + kid = self._jwk.get("kid") + return str(kid) if kid is not None else None @property def public_key_use(self) -> str | None: @@ -78,28 +79,50 @@ def __init__(self, keys: list[dict[str, Any]]) -> None: raise PyJWKSetError("The JWK Set did not contain any keys") if not isinstance(keys, list): raise PyJWKSetError("Invalid JWK Set value") - self.keys: list[PyJWK] = [] - self._by_kid: dict[str, PyJWK] = {} - for index, k in enumerate(keys): - try: - jwk = PyJWK(k) - except OxyJWTError as error: - kid = k.get("kid") if isinstance(k, dict) else None - kid_label = f"kid={kid!r}" if kid is not None else "no kid" - warnings.warn( - f"Skipped JWK at index {index} ({kid_label}): {error}", - PyJWKSetSkipWarning, - stacklevel=2, - ) - continue - self.keys.append(jwk) - kid = jwk.key_id - if kid is not None and kid not in self._by_kid: - self._by_kid[kid] = jwk - if not self.keys: - raise PyJWKSetError( - "The JWK Set did not contain any usable keys." + self._raw_keys: list[dict[str, Any]] = [ + k for k in keys if isinstance(k, dict) + ] + if not self._raw_keys: + raise PyJWKSetError("The JWK Set did not contain any keys") + self._by_kid_raw: dict[str, dict[str, Any]] = {} + for raw in self._raw_keys: + kid = raw.get("kid") + if kid is not None and str(kid) not in self._by_kid_raw: + self._by_kid_raw[str(kid)] = raw + self._materialized: dict[str, PyJWK] = {} + self._keys_cache: list[PyJWK] | None = None + + def _materialize(self, raw: dict[str, Any], index: int) -> PyJWK | None: + try: + return PyJWK(raw) + except OxyJWTError as error: + kid = raw.get("kid") + kid_label = f"kid={kid!r}" if kid is not None else "no kid" + warnings.warn( + f"Skipped JWK at index {index} ({kid_label}): {error}", + PyJWKSetSkipWarning, + stacklevel=3, ) + return None + + @property + def keys(self) -> list[PyJWK]: + if self._keys_cache is None: + built: list[PyJWK] = [] + for index, raw in enumerate(self._raw_keys): + jwk = self._materialize(raw, index) + if jwk is None: + continue + built.append(jwk) + kid = jwk.key_id + if kid is not None: + self._materialized[kid] = jwk + if not built: + raise PyJWKSetError( + "The JWK Set did not contain any usable keys." + ) + self._keys_cache = built + return self._keys_cache @staticmethod def from_dict(obj: dict[str, Any]) -> PyJWKSet: @@ -113,10 +136,23 @@ def from_json(data: str) -> PyJWKSet: return PyJWKSet.from_dict(orjson.loads(data.encode("utf-8"))) def __getitem__(self, kid: str) -> PyJWK: + if kid in self._materialized: + return self._materialized[kid] try: - return self._by_kid[kid] + raw = self._by_kid_raw[kid] except KeyError as e: raise KeyError(f"keyset has no key for kid: {kid!r}") from e + jwk = PyJWK(raw) + self._materialized[kid] = jwk + return jwk + + @property + def _by_kid(self) -> dict[str, PyJWK]: + """Materialized kid index (compat for tests and introspection).""" + for kid in self._by_kid_raw: + if kid not in self._materialized: + self[kid] + return self._materialized __all__ = ["PyJWK", "PyJWKSet"] diff --git a/python/oxyjwt/jwks_client.py b/python/oxyjwt/jwks_client.py index 1583d22..685010d 100644 --- a/python/oxyjwt/jwks_client.py +++ b/python/oxyjwt/jwks_client.py @@ -2,6 +2,7 @@ from __future__ import annotations import ssl +import threading import time import urllib.error import urllib.request @@ -93,6 +94,7 @@ def __init__( self._jwk_set: PyJWKSet | None = None self._jwk_set_fetched_at: float | None = None self._kid_lru: OrderedDict[str, PyJWK] = OrderedDict() + self._lock = threading.RLock() def _jwk_set_cache_valid(self) -> bool: if self._jwk_set is None or self._jwk_set_fetched_at is None: @@ -113,16 +115,18 @@ def _fetch_raw(self) -> bytes: except (urllib.error.URLError, TimeoutError, OSError) as e: raise PyJWKClientConnectionError(str(e) or type(e).__name__) from e - def get_jwk_set(self, refresh: bool = False) -> PyJWKSet: + def _load_jwk_set(self, refresh: bool) -> PyJWKSet: if self._cache_jwk_set and not refresh and self._jwk_set_cache_valid(): - return self._jwk_set + return self._jwk_set # type: ignore[return-value] data = self._fetch_raw() try: obj: dict[str, Any] = orjson.loads(data) except orjson.JSONDecodeError as e: raise PyJWKClientError("JWKS response is not valid JSON") from e if not isinstance(obj, dict) or "keys" not in obj: - raise PyJWKClientError("JWKS response must be a JSON object with a 'keys' field") + raise PyJWKClientError( + "JWKS response must be a JSON object with a 'keys' field" + ) jwks = PyJWKSet.from_dict(obj) self._kid_lru.clear() if self._cache_jwk_set: @@ -130,25 +134,30 @@ def get_jwk_set(self, refresh: bool = False) -> PyJWKSet: self._jwk_set_fetched_at = time.monotonic() return jwks + def get_jwk_set(self, refresh: bool = False) -> PyJWKSet: + with self._lock: + return self._load_jwk_set(refresh) + def get_signing_key(self, kid: str) -> PyJWK: if not kid: raise PyJWKClientError("kid must be a non-empty string") - if self._cache_keys: - cached = self._kid_lru.get(kid) - if cached is not None: - self._kid_lru.move_to_end(kid) - return cached - jwks = self.get_jwk_set() - try: - jwk = jwks[kid] - except KeyError: - jwks = self.get_jwk_set(refresh=True) - jwk = jwks[kid] - if self._cache_keys: - self._kid_lru[kid] = jwk - while len(self._kid_lru) > self._max_cached_keys: - self._kid_lru.popitem(last=False) - return jwk + with self._lock: + if self._cache_keys: + cached = self._kid_lru.get(kid) + if cached is not None: + self._kid_lru.move_to_end(kid) + return cached + jwks = self._load_jwk_set(refresh=False) + try: + jwk = jwks[kid] + except KeyError: + jwks = self._load_jwk_set(refresh=True) + jwk = jwks[kid] + if self._cache_keys: + self._kid_lru[kid] = jwk + while len(self._kid_lru) > self._max_cached_keys: + self._kid_lru.popitem(last=False) + return jwk def get_signing_key_from_jwt( self, diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 04c7761..8d4b744 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -496,7 +496,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oxyjwt" -version = "0.4.0" +version = "0.5.0" dependencies = [ "base64", "jsonwebtoken", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 2ee4ab7..6fcaabd 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxyjwt" -version = "0.4.0" +version = "0.5.0" edition = "2021" rust-version = "1.85" license = "MIT" diff --git a/rust/src/api.rs b/rust/src/api.rs index a198111..e32ad97 100644 --- a/rust/src/api.rs +++ b/rust/src/api.rs @@ -10,13 +10,17 @@ use serde_json::Value; use crate::algorithms::{algorithm_name, parse_algorithm, parse_algorithm_name}; use crate::claims::{json_to_py, py_to_json_for_encode}; +use crate::claims_validate; use crate::errors; use crate::jws; use crate::keys::{decoding_key_from_py, encoding_key_from_py}; use crate::validation; -fn ensure_token_within_limit(token: &str) -> PyResult<()> { - jws::check_compact_token_size(token).map_err(errors::decode_error) +/// Size limit plus strict three-segment compact JWS check (before jsonwebtoken). +fn ensure_valid_compact_jwt(token: &str) -> PyResult<()> { + jws::split_compact_segments(token) + .map(|_| ()) + .map_err(errors::decode_error) } #[pyfunction] @@ -68,7 +72,7 @@ pub fn decode( options: Option<&Bound<'_, PyAny>>, require: Option>, ) -> PyResult> { - ensure_token_within_limit(token)?; + ensure_valid_compact_jwt(token)?; let decode_validation = validation::build_validation( algorithms, audience, issuer, subject, leeway, options, require, )?; @@ -113,7 +117,7 @@ pub fn decode_verified_complete( require: Option>, detached_payload: Option<&Bound<'_, PyBytes>>, ) -> PyResult { - ensure_token_within_limit(token)?; + ensure_valid_compact_jwt(token)?; let decode_validation = validation::build_validation( algorithms, audience, issuer, subject, leeway, options, require, )?; @@ -129,19 +133,23 @@ pub fn decode_verified_complete( ); } - let token_data = py - .detach(|| jwt_decode::(token, &decoding_key, &decode_validation.validation)) - .map_err(errors::from_jwt_decode_error)?; + let token_owned = token.to_owned(); + let validation = decode_validation.validation; + let (token_data, signature) = py.detach( + move || -> PyResult<(jsonwebtoken::TokenData, Vec)> { + let token_data = jwt_decode::(&token_owned, &decoding_key, &validation) + .map_err(errors::from_jwt_decode_error)?; + let signature = + jws::extract_signature_bytes(&token_owned).map_err(errors::decode_error)?; + Ok((token_data, signature)) + }, + )?; let header_value = serde_json::to_value(&token_data.header).map_err(|err| { errors::decode_error(format!("failed to serialize decoded header: {err}")) })?; let header_py = json_to_py(py, &header_value)?; let claims_py = json_to_py(py, &token_data.claims)?; - let token_owned = token.to_owned(); - let signature = py - .detach(move || jws::extract_signature_bytes(&token_owned)) - .map_err(errors::decode_error)?; let sig_py = PyBytes::new(py, &signature); Ok((claims_py, header_py, sig_py.into())) } @@ -153,10 +161,17 @@ fn decode_rfc7797_verified_complete( decode_validation: &validation::DecodeValidation, decoding_key: &jsonwebtoken::DecodingKey, ) -> PyResult { + if detached_payload.len() > jws::MAX_COMPACT_JWT_BYTES { + return Err(errors::decode_error(format!( + "Detached payload exceeds maximum size ({} bytes)", + jws::MAX_COMPACT_JWT_BYTES + ))); + } let token = token.to_owned(); let payload = detached_payload.to_vec(); let allowed_algorithms = decode_validation.algorithms.clone(); let decoding_key = decoding_key.clone(); + let validation = decode_validation.validation.clone(); let (parts, claims, signature) = py .detach(move || { let parts = jws::parse_rfc7797_compact(&token)?; @@ -185,6 +200,8 @@ fn decode_rfc7797_verified_complete( if !verified { return Err("Signature verification failed".to_string()); } + claims_validate::validate_claims_value(&claims, &validation) + .map_err(|err| err.to_string())?; let signature = URL_SAFE_NO_PAD .decode(&parts.signature_segment) .map_err(|e| e.to_string())?; @@ -199,6 +216,24 @@ fn decode_rfc7797_verified_complete( } fn map_rfc7797_decode_error(message: String) -> PyErr { + if message.contains("Expired") || message.contains("expired") { + return errors::ExpiredSignatureError::new_err(message); + } + if message.contains("Immature") || message.contains("not yet valid") { + return errors::ImmatureSignatureError::new_err(message); + } + if message.contains("Invalid audience") || message.contains("Audience") { + return errors::InvalidAudienceError::new_err(message); + } + if message.contains("Invalid issuer") || message.contains("issuer") { + return errors::InvalidIssuerError::new_err(message); + } + if message.contains("Invalid subject") || message.contains("Subject") { + return errors::InvalidSubjectError::new_err(message); + } + if message.contains("Missing") && message.contains("claim") { + return errors::MissingRequiredClaimError::new_err(message); + } if message.contains("Signature verification failed") { return errors::InvalidSignatureError::new_err(message); } @@ -216,7 +251,7 @@ fn map_rfc7797_decode_error(message: String) -> PyErr { #[pyfunction] pub fn get_unverified_header(py: Python<'_>, token: &str) -> PyResult> { - ensure_token_within_limit(token)?; + ensure_valid_compact_jwt(token)?; let token = token.to_owned(); let header = py .detach(move || jws::parse_compact_header_json(&token)) @@ -227,7 +262,7 @@ pub fn get_unverified_header(py: Python<'_>, token: &str) -> PyResult> #[pyfunction] pub fn decode_unverified(py: Python<'_>, token: &str) -> PyResult> { - ensure_token_within_limit(token)?; + ensure_valid_compact_jwt(token)?; let token = token.to_owned(); let token_data = py .detach(move || dangerous::insecure_decode::(&token)) diff --git a/rust/src/claims_validate.rs b/rust/src/claims_validate.rs new file mode 100644 index 0000000..a359d2c --- /dev/null +++ b/rust/src/claims_validate.rs @@ -0,0 +1,157 @@ +//! Claim validation for parsed JWT payloads (e.g. RFC 7797 detached), mirroring jsonwebtoken. + +use std::collections::HashSet; + +use jsonwebtoken::errors::{new_error, Error, ErrorKind}; +use jsonwebtoken::{get_current_timestamp, Validation}; +use serde_json::Value; + +enum NumericClaim { + Missing, + Invalid, + Value(u64), +} + +fn parse_numeric_claim(value: Option<&Value>) -> NumericClaim { + let Some(value) = value else { + return NumericClaim::Missing; + }; + match value { + Value::Number(n) => { + if let Some(u) = n.as_u64() { + NumericClaim::Value(u) + } else if let Some(f) = n.as_f64() { + if f.is_finite() && f >= 0.0 && f < u64::MAX as f64 { + NumericClaim::Value(f.round() as u64) + } else { + NumericClaim::Invalid + } + } else { + NumericClaim::Invalid + } + } + _ => NumericClaim::Invalid, + } +} + +fn audience_matches(claim: &Value, expected: &HashSet) -> bool { + match claim { + Value::String(s) => expected.contains(s), + Value::Array(items) => items + .iter() + .any(|item| item.as_str().is_some_and(|aud| expected.contains(aud))), + _ => false, + } +} + +fn issuer_matches(claim: &Value, expected: &HashSet) -> bool { + match claim { + Value::String(s) => expected.contains(s), + Value::Array(items) => items + .iter() + .any(|item| item.as_str().is_some_and(|iss| expected.contains(iss))), + _ => false, + } +} + +/// Validate standard claims on an already-parsed JSON object (post signature verify). +pub fn validate_claims_value(claims: &Value, options: &Validation) -> Result<(), Error> { + if !claims.is_object() { + return Err(new_error(ErrorKind::InvalidToken)); + } + + for required in &options.required_spec_claims { + let present = match required.as_str() { + "exp" | "nbf" => !matches!( + parse_numeric_claim(claims.get(required)), + NumericClaim::Missing + ), + "sub" | "iss" | "aud" => claims.get(required).is_some(), + _ => continue, + }; + if !present { + return Err(new_error(ErrorKind::MissingRequiredClaim(required.clone()))); + } + } + + let now = get_current_timestamp(); + + if options.validate_exp || options.validate_nbf { + if options.validate_exp + && matches!( + parse_numeric_claim(claims.get("exp")), + NumericClaim::Invalid + ) + { + return Err(new_error(ErrorKind::InvalidClaimFormat("exp".to_string()))); + } + if options.validate_nbf + && matches!( + parse_numeric_claim(claims.get("nbf")), + NumericClaim::Invalid + ) + { + return Err(new_error(ErrorKind::InvalidClaimFormat("nbf".to_string()))); + } + + if let NumericClaim::Value(exp) = parse_numeric_claim(claims.get("exp")) { + if exp < options.reject_tokens_expiring_in_less_than { + return Err(new_error(ErrorKind::InvalidToken)); + } + if options.validate_exp + && exp.saturating_sub(options.reject_tokens_expiring_in_less_than) + < now.saturating_sub(options.leeway) + { + return Err(new_error(ErrorKind::ExpiredSignature)); + } + } + + if let NumericClaim::Value(nbf) = parse_numeric_claim(claims.get("nbf")) { + if options.validate_nbf && nbf > now.saturating_add(options.leeway) { + return Err(new_error(ErrorKind::ImmatureSignature)); + } + } + } + + if let (Some(expected_sub), Some(Value::String(sub))) = + (options.sub.as_deref(), claims.get("sub")) + { + if sub != expected_sub { + return Err(new_error(ErrorKind::InvalidSubject)); + } + } + + if let (Some(expected_iss), Some(iss_claim)) = (options.iss.as_ref(), claims.get("iss")) { + if !issuer_matches(iss_claim, expected_iss) { + return Err(new_error(ErrorKind::InvalidIssuer)); + } + } + + if !options.validate_aud { + return Ok(()); + } + + match (claims.get("aud"), options.aud.as_ref()) { + (Some(_aud), None) => Err(new_error(ErrorKind::InvalidAudience)), + (Some(aud), Some(expected)) if !audience_matches(aud, expected) => { + Err(new_error(ErrorKind::InvalidAudience)) + } + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::Algorithm; + use serde_json::json; + + #[test] + fn rejects_expired_detached_style_claims() { + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = true; + let claims = json!({"sub": "u", "exp": 1}); + let err = validate_claims_value(&claims, &validation).unwrap_err(); + assert!(matches!(err.kind(), ErrorKind::ExpiredSignature)); + } +} diff --git a/rust/src/jws.rs b/rust/src/jws.rs index 63d65b6..db33e97 100644 --- a/rust/src/jws.rs +++ b/rust/src/jws.rs @@ -121,17 +121,7 @@ pub fn parse_compact_jws(token: &str) -> Result { /// Extract and decode the JWS signature segment without parsing header or payload JSON. pub fn extract_signature_bytes(token: &str) -> Result, String> { - check_compact_token_size(token)?; - let mut parts = token.rsplitn(2, '.'); - let sig_encoded = parts - .next() - .ok_or_else(|| "Not enough segments".to_string())?; - if parts.next().is_none() { - return Err("Not enough segments".to_string()); - } - if parts.next().is_some() { - return Err("Too many segments".to_string()); - } + let (_, _, sig_encoded) = split_compact_segments(token)?; URL_SAFE_NO_PAD .decode(sig_encoded) .map_err(|e| e.to_string()) @@ -175,6 +165,16 @@ mod tests { ); } + #[test] + fn rejects_extra_compact_segments() { + let token = "a.b.c.d"; + assert_eq!( + split_compact_segments(token).unwrap_err(), + "Too many segments" + ); + assert_eq!(parse_compact_jws(token).unwrap_err(), "Too many segments"); + } + #[test] fn extract_signature_matches_full_parse() { let token = diff --git a/rust/src/keys.rs b/rust/src/keys.rs index b6ee4ef..8ac808a 100644 --- a/rust/src/keys.rs +++ b/rust/src/keys.rs @@ -168,7 +168,7 @@ impl DecodingKeyMaterial { Self { family, key } } - fn decoding_key(&self, algorithms: &[Algorithm]) -> PyResult { + fn validate_for_algorithms(&self, algorithms: &[Algorithm]) -> PyResult<()> { if self.family != KeyFamily::Jwk { let expected_family = ensure_single_family(algorithms)?; if expected_family != self.family { @@ -178,7 +178,11 @@ impl DecodingKeyMaterial { ))); } } + Ok(()) + } + fn decoding_key(&self, algorithms: &[Algorithm]) -> PyResult { + self.validate_for_algorithms(algorithms)?; Ok(self.key.clone()) } } @@ -209,6 +213,13 @@ pub fn decoding_key_from_py( return key_ref.material.decoding_key(algorithms); } + raw_decoding_key_from_py(key, algorithms) +} + +fn raw_decoding_key_from_py( + key: &Bound<'_, PyAny>, + algorithms: &[Algorithm], +) -> PyResult { let family = ensure_single_family(algorithms)?; if family != KeyFamily::Hmac { return Err(errors::invalid_key( diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 762a80f..b6b6d04 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,6 +1,7 @@ mod algorithms; mod api; mod claims; +mod claims_validate; mod errors; mod jws; mod keys; diff --git a/scripts/compare_jwt_libraries.py b/scripts/compare_jwt_libraries.py index 8207737..cac1aa9 100644 --- a/scripts/compare_jwt_libraries.py +++ b/scripts/compare_jwt_libraries.py @@ -506,7 +506,11 @@ def parse_args() -> argparse.Namespace: "--competitor-key-mode", choices=("pem", "cached"), default="pem", - help="Use PEM bytes for competitors or preloaded cryptography key objects where supported.", + help=( + "pem (default): competitors get PEM str/bytes; OxyJWT uses pre-parsed " + "EncodingKey/DecodingKey. cached: preloaded cryptography objects for " + "fairer RSA/EC/EdDSA vs PyJWT/Authlib." + ), ) parser.add_argument( "--algorithms", diff --git a/tests/test_benchmark_jwt_libraries.py b/tests/test_benchmark_jwt_libraries.py index 9967d0b..0509b7c 100644 --- a/tests/test_benchmark_jwt_libraries.py +++ b/tests/test_benchmark_jwt_libraries.py @@ -23,7 +23,9 @@ # HS256 smoke: minimum OxyJWT/PyJWT throughput ratio (tightened from 0.25 = 4Γ— slack). _MIN_OXY_VS_PYJWT_ENCODE_RATIO = 0.75 _MIN_OXY_VS_PYJWT_DECODE_RATIO = 0.75 -_MIN_OXYJWT_OPS_PER_SECOND = 500 +_SMOKE_ITERATIONS = 50 +_SMOKE_ROUNDS = 3 +_SMOKE_WARMUP = 8 # Extended sweep: looser floor vs PyJWT when present (asymmetric crypto is noisier in CI). _MIN_OXY_VS_PYJWT_EXTENDED_RATIO = 0.5 @@ -48,24 +50,48 @@ def _ops_for( return None +def _median_ops(result: object) -> float: + iterations = int(result.iterations) # type: ignore[attr-defined] + median_seconds = float(result.median_seconds) # type: ignore[attr-defined] + assert median_seconds > 0 + return iterations / median_seconds + + def _assert_oxyjwt_hs256_smoke(results: list[object], *, mod: object) -> None: - oxy_enc = _ops_for(results, "OxyJWT", "encode", mod=mod) - oxy_dec = _ops_for(results, "OxyJWT", "decode", mod=mod) - assert oxy_enc is not None and oxy_dec is not None - assert oxy_enc > _MIN_OXYJWT_OPS_PER_SECOND, f"encode too slow: {oxy_enc:.0f} ops/s" - assert oxy_dec > _MIN_OXYJWT_OPS_PER_SECOND, f"decode too slow: {oxy_dec:.0f} ops/s" + oxy_enc = next( + r for r in results if r.library == "OxyJWT" and r.operation == "encode" # type: ignore[attr-defined] + ) + oxy_dec = next( + r for r in results if r.library == "OxyJWT" and r.operation == "decode" # type: ignore[attr-defined] + ) + oxy_enc_ops = _median_ops(oxy_enc) + oxy_dec_ops = _median_ops(oxy_dec) py_enc = _ops_for(results, "PyJWT", "encode", mod=mod) py_dec = _ops_for(results, "PyJWT", "decode", mod=mod) if py_enc is not None and py_enc > 0: - assert oxy_enc >= py_enc * _MIN_OXY_VS_PYJWT_ENCODE_RATIO, ( - f"HS256 encode: OxyJWT {oxy_enc:.0f} ops/s vs PyJWT {py_enc:.0f} ops/s " - f"(need >={_MIN_OXY_VS_PYJWT_ENCODE_RATIO:.0%} of PyJWT)" + py_enc_median = _median_ops( + next( + r + for r in results + if r.library == "PyJWT" and r.operation == "encode" # type: ignore[attr-defined] + ) + ) + assert oxy_enc_ops >= py_enc_median * _MIN_OXY_VS_PYJWT_ENCODE_RATIO, ( + f"HS256 encode: OxyJWT median {oxy_enc_ops:.0f} ops/s vs PyJWT " + f"{py_enc_median:.0f} ops/s (need >={_MIN_OXY_VS_PYJWT_ENCODE_RATIO:.0%})" ) if py_dec is not None and py_dec > 0: - assert oxy_dec >= py_dec * _MIN_OXY_VS_PYJWT_DECODE_RATIO, ( - f"HS256 decode: OxyJWT {oxy_dec:.0f} ops/s vs PyJWT {py_dec:.0f} ops/s " - f"(need >={_MIN_OXY_VS_PYJWT_DECODE_RATIO:.0%} of PyJWT)" + py_dec_median = _median_ops( + next( + r + for r in results + if r.library == "PyJWT" and r.operation == "decode" # type: ignore[attr-defined] + ) + ) + assert oxy_dec_ops >= py_dec_median * _MIN_OXY_VS_PYJWT_DECODE_RATIO, ( + f"HS256 decode: OxyJWT median {oxy_dec_ops:.0f} ops/s vs PyJWT " + f"{py_dec_median:.0f} ops/s (need >={_MIN_OXY_VS_PYJWT_DECODE_RATIO:.0%})" ) @@ -76,9 +102,9 @@ def test_benchmark_hs256_smoke_vs_competitors() -> None: mod = _load_compare_module() results, _skipped = mod.run_benchmark( - iterations=50, - rounds=1, - warmup=8, + iterations=_SMOKE_ITERATIONS, + rounds=_SMOKE_ROUNDS, + warmup=_SMOKE_WARMUP, selected_algorithms={"HS256"}, competitor_key_mode="pem", ) diff --git a/tests/test_detached_jws.py b/tests/test_detached_jws.py index 599d2c7..65b4e8a 100644 --- a/tests/test_detached_jws.py +++ b/tests/test_detached_jws.py @@ -24,7 +24,7 @@ def _make_rfc7797_token( body, secret, algorithm="HS256", - headers={"b64": False}, + headers={"b64": False, "crit": ["b64"]}, is_payload_detached=True, ) return token, body @@ -69,6 +69,14 @@ def test_detached_wrong_payload_fails_verification() -> None: ) +def test_detached_expired_payload_rejected_in_rust() -> None: + token, payload = _make_rfc7797_token({"sub": "u", "exp": 1}) + with pytest.raises(oxyjwt.ExpiredSignatureError): + oxyjwt.decode( + token, "secret", algorithms=["HS256"], detached_payload=payload + ) + + def test_get_unverified_header_rfc7797() -> None: token, _ = _make_rfc7797_token() header = oxyjwt.get_unverified_header(token) diff --git a/tests/test_errors.py b/tests/test_errors.py index 42fdc5e..7ee5f93 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -19,6 +19,17 @@ def test_malformed_token_error() -> None: oxyjwt.decode("not-a-jwt", "secret", algorithms=["HS256"]) +def test_too_many_compact_segments_rejected_consistently() -> None: + token = oxyjwt.encode({"exp": int(time.time()) + 60}, "secret", algorithm="HS256") + extra = f"{token}.extra" + with pytest.raises(oxyjwt.DecodeError, match="Too many segments"): + oxyjwt.decode(extra, "secret", algorithms=["HS256"]) + with pytest.raises(oxyjwt.DecodeError, match="Too many segments"): + oxyjwt.decode_unverified(extra) + with pytest.raises(oxyjwt.DecodeError, match="Too many segments"): + oxyjwt.get_unverified_header(extra) + + def test_raw_key_is_rejected_for_asymmetric_algorithms() -> None: with pytest.raises(oxyjwt.InvalidKeyError): oxyjwt.encode( diff --git a/tests/test_jwk.py b/tests/test_jwk.py index 8abce84..4671e12 100644 --- a/tests/test_jwk.py +++ b/tests/test_jwk.py @@ -58,8 +58,9 @@ def test_pyjwkset_warns_when_skipping_invalid_key() -> None: k = base64.urlsafe_b64encode(secret).decode("ascii").rstrip("=") bad_jwk = {"kid": "bad-k"} good_jwk = {"kty": "oct", "k": k, "kid": "good-k"} + s = PyJWKSet.from_dict({"keys": [bad_jwk, good_jwk]}) with pytest.warns(oxyjwt.PyJWKSetSkipWarning, match="index 0") as records: - s = PyJWKSet.from_dict({"keys": [bad_jwk, good_jwk]}) + assert len(s.keys) == 1 assert len(records) == 1 assert "bad-k" in str(records[0].message) assert len(s.keys) == 1 @@ -71,9 +72,9 @@ def test_pyjwkset_skips_enc_keys_keeps_signing_keys() -> None: k = base64.urlsafe_b64encode(secret).decode("ascii").rstrip("=") enc_jwk = {"kty": "oct", "k": k, "kid": "enc-k", "use": "enc"} sig_jwk = {"kty": "oct", "k": k, "kid": "sig-k", "use": "sig"} + s = PyJWKSet.from_dict({"keys": [enc_jwk, sig_jwk]}) with pytest.warns(oxyjwt.PyJWKSetSkipWarning, match="enc-k"): - s = PyJWKSet.from_dict({"keys": [enc_jwk, sig_jwk]}) - assert len(s.keys) == 1 + assert len(s.keys) == 1 assert s["sig-k"].key_id == "sig-k" tok = oxyjwt.encode( {"x": 1, "exp": 9_999_999_999}, @@ -93,9 +94,10 @@ def test_pyjwkset_only_encryption_keys_raises() -> None: secret = b"my-secret-32-bytes-long-ok!!!!" k = base64.urlsafe_b64encode(secret).decode("ascii").rstrip("=") enc_jwk = {"kty": "oct", "k": k, "kid": "enc-k", "use": "enc"} + s = PyJWKSet.from_dict({"keys": [enc_jwk]}) with pytest.warns(oxyjwt.PyJWKSetSkipWarning): with pytest.raises(PyJWKSetError, match="usable keys"): - PyJWKSet.from_dict({"keys": [enc_jwk]}) + _ = s.keys def test_pyjwkset_getitem_uses_kid_index() -> None: diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py index 27d5b55..49b40f0 100644 --- a/tests/test_jwks_client.py +++ b/tests/test_jwks_client.py @@ -601,3 +601,29 @@ def test_jwks_client_refresh_on_miss_within_lifespan( jwk = c.get_signing_key("new-key") assert jwk.key_id == "new-key" assert _RotatingJWKHandler.request_count == 2 + + +def test_jwks_client_concurrent_get_signing_key() -> None: + jw = { + "kty": "oct", + "k": _b64u(b"the-shared-secret-xy"), + "kid": "alpha", + } + uri = _serve_jwks({"keys": [jw]}) + c = PyJWKClient(uri, cache_jwk_set=True, cache_keys=True, timeout=5.0) + errors: list[BaseException] = [] + + def worker() -> None: + try: + jwk = c.get_signing_key("alpha") + assert jwk.key_id == "alpha" + except BaseException as e: # noqa: BLE001 + errors.append(e) + + threads = [threading.Thread(target=worker) for _ in range(12)] + for t in threads: + t.start() + for t in threads: + t.join() + assert errors == [] + assert _JWKHandler.request_count <= 2 diff --git a/tests/test_security_regression.py b/tests/test_security_regression.py index aa3ad90..1f88b15 100644 --- a/tests/test_security_regression.py +++ b/tests/test_security_regression.py @@ -1,7 +1,8 @@ -"""Security regression tests for OxyJWT 0.4.0. +"""Security smoke tests for OxyJWT (0.4.x / 0.5.x). -Covers the main 0.4.0 security fixes in one module so CI always exercises them -without extra env flags. See milestone 0.4.0 issues #1–#12 and related JWKS/API work. +High-signal security behaviors in one module so default CI exercises them without +extra env flags. Deeper coverage also lives in `tests/test_validation.py`, +`tests/test_errors.py`, and `tests/test_detached_jws.py`. """ from __future__ import annotations @@ -209,6 +210,56 @@ def test_security_oversized_compact_jwt_rejected_before_parse() -> None: oxyjwt.get_unverified_header(token) +def test_security_oversized_detached_payload_rejected() -> None: + """RFC 7797 external payload must respect the same size cap as compact JWT (issue #59).""" + header = _b64u( + json.dumps({"alg": "HS256", "b64": False, "crit": ["b64"]}).encode() + ) + token = f"{header}..{_b64u(b'sig')}" + huge = b"x" * (_MAX_COMPACT_JWT_BYTES + 1) + with pytest.raises(DecodeError, match="Detached payload exceeds maximum size"): + oxyjwt.decode( + token, + "secret", + algorithms=["HS256"], + detached_payload=huge, + ) + + +# --- Strict compact segments (issue #60) --- + + +def test_security_extra_jwt_segment_rejected() -> None: + token = oxyjwt.encode({"exp": int(time.time()) + 3600}, "secret", algorithm="HS256") + malformed = f"{token}.extra" + with pytest.raises(DecodeError, match="Too many segments"): + oxyjwt.decode(malformed, "secret", algorithms=["HS256"]) + + +# --- Issuer API hardening (issue #69) --- + + +def test_security_issuer_bytes_rejected_at_api() -> None: + token = oxyjwt.encode({"exp": int(time.time()) + 3600}, "secret") + with pytest.raises(TypeError, match="issuer must be a string"): + oxyjwt.decode( + token, + "secret", + algorithms=["HS256"], + issuer=b"https://issuer.example", # type: ignore[arg-type] + ) + + +# --- JWKS HTTPS requirement (issue #7) --- + + +def test_security_jwks_client_rejects_http_when_require_https() -> None: + jw = {"kty": "oct", "k": _b64u(b"the-shared-secret-xy"), "kid": "alpha"} + uri = _serve_jwks({"keys": [jw]}) + with pytest.raises(PyJWKClientError, match="https"): + PyJWKClient(uri, require_https=True, timeout=5.0) + + # --- Algorithm confusion before JWKS fetch (issue #8) --- diff --git a/tests/test_validation.py b/tests/test_validation.py index f9845f8..226d692 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -151,6 +151,34 @@ def test_subject_validation() -> None: oxyjwt.decode(token, "secret", algorithms=["HS256"], subject="user-b") +def test_missing_iss_with_issuer_uses_invalid_issuer_error() -> None: + token = oxyjwt.encode( + {"sub": "user", "exp": int(time.time()) + 3600}, + "secret", + ) + with pytest.raises(oxyjwt.InvalidIssuerError, match="Invalid issuer"): + oxyjwt.decode( + token, + "secret", + algorithms=["HS256"], + issuer="https://issuer.example", + ) + + +def test_issuer_bytes_rejected() -> None: + token = oxyjwt.encode( + {"sub": "user", "exp": int(time.time()) + 3600}, + "secret", + ) + with pytest.raises(TypeError, match="issuer must be a string"): + oxyjwt.decode( + token, + "secret", + algorithms=["HS256"], + issuer=b"https://issuer.example", # type: ignore[arg-type] + ) + + def test_required_claim_validation() -> None: token = oxyjwt.encode({"sub": "user"}, "secret")