From d8e963504a34f6314e005739410fff63d10a8d13 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:17:50 +0300 Subject: [PATCH 01/18] perf(decode): gate RFC 7797 header peek on empty payload segment (#57) Avoid parsing the protected header on every verified decode; only inspect when the compact JWT payload segment is empty (detached JWS form). --- python/oxyjwt/api_jwt.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/python/oxyjwt/api_jwt.py b/python/oxyjwt/api_jwt.py index f82c620..9864cc2 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() @@ -209,13 +229,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): From 9c678d9bf68e091782b6294148ea12056e0de251 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:18:04 +0300 Subject: [PATCH 02/18] perf(decode): single-parse unverified decode path (#58) Use one jws_parse_compact call for verify_signature=False instead of get_unverified_header, jws_parse_compact, and decode_unverified. --- python/oxyjwt/api_jwt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/oxyjwt/api_jwt.py b/python/oxyjwt/api_jwt.py index f82c620..8b8dd5d 100644 --- a/python/oxyjwt/api_jwt.py +++ b/python/oxyjwt/api_jwt.py @@ -255,12 +255,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 ) From 860ac7b94ced9c616aed84d970b2d1bc8bc315bd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:18:26 +0300 Subject: [PATCH 03/18] security(api): cap detached_payload size at 256 KiB (#59) Reject oversized RFC 7797 external payloads before copy/JSON parse, matching the compact JWT size limit. --- docs-site/docs/security.md | 2 +- rust/src/api.rs | 6 ++++++ tests/test_security_regression.py | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) 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/rust/src/api.rs b/rust/src/api.rs index a198111..3d8dae2 100644 --- a/rust/src/api.rs +++ b/rust/src/api.rs @@ -153,6 +153,12 @@ 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(); diff --git a/tests/test_security_regression.py b/tests/test_security_regression.py index aa3ad90..2da2341 100644 --- a/tests/test_security_regression.py +++ b/tests/test_security_regression.py @@ -209,6 +209,22 @@ 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, + ) + + # --- Algorithm confusion before JWKS fetch (issue #8) --- From 68aafd05e0672f86730b6e7200dd644edc963be9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:36:29 +0300 Subject: [PATCH 04/18] security(rust): strict compact JWT segment check before decode (#60) Preflight decode and decode_unverified with split_compact_segments; align extract_signature_bytes with the same strict parser. --- rust/src/api.rs | 15 +++++++++------ rust/src/jws.rs | 22 +++++++++++----------- tests/test_errors.py | 11 +++++++++++ 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/rust/src/api.rs b/rust/src/api.rs index 3d8dae2..35e4337 100644 --- a/rust/src/api.rs +++ b/rust/src/api.rs @@ -15,8 +15,11 @@ 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 +71,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 +116,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, )?; @@ -222,7 +225,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)) @@ -233,7 +236,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/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/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( From 1c0302ac5fd375d2f0d0db345961e45ac3662d9b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:36:47 +0300 Subject: [PATCH 05/18] ci: run full CI on pushes to dev (#62) Extend workflow push branches to include dev; document in GITFLOW.md. --- .github/workflows/ci.yml | 2 +- docs/GITFLOW.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/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 From 05fb67235e1103a0b9b948753ca1726193ead3d9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:37:11 +0300 Subject: [PATCH 06/18] ci: gate PyPI release workflow on full CI (#61) Call reusable CI workflow before wheel builds; document in RELEASING.md. --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 6 ++++++ RELEASING.md | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4addd58..7264cb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: pull_request: push: branches: [main] + workflow_call: jobs: test: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85b5a44..0d7a5d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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/RELEASING.md b/RELEASING.md index b9c34e2..a91ea36 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -42,7 +42,7 @@ This checklist is for maintainers publishing **0.4.x** (and later) to PyPI via t git push origin v0.4.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. From 8bf79589eff79b2adf2a2dc145a2c77661d9b7dd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:40:32 +0300 Subject: [PATCH 07/18] perf(encode): use native encode when no custom JSON encoder (#63) Avoid orjson.dumps plus encode_json re-parse on the default encode path; keep encode_json for sort_headers or json_encoder. --- python/oxyjwt/api_jwt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/oxyjwt/api_jwt.py b/python/oxyjwt/api_jwt.py index 0d6aa2f..09859ef 100644 --- a/python/oxyjwt/api_jwt.py +++ b/python/oxyjwt/api_jwt.py @@ -128,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) @@ -137,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( From 8d5c7369c08594e92e16bb7a67aec1821e7be8e4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:41:28 +0300 Subject: [PATCH 08/18] perf(rust): single detach in decode_verified_complete (#64) Run jwt_decode and signature extraction in one GIL-free block; dedupe DecodingKey algorithm validation in keys.rs. --- rust/src/api.rs | 18 +++++++++++------- rust/src/keys.rs | 13 ++++++++++++- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/rust/src/api.rs b/rust/src/api.rs index 35e4337..4d6c29b 100644 --- a/rust/src/api.rs +++ b/rust/src/api.rs @@ -132,19 +132,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())) } 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( From aba0544a47a13f615481a2ce408002e35cfb67c5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:41:41 +0300 Subject: [PATCH 09/18] perf(api): skip redundant Python aud/iss/sub after Rust (#65) When whole-second leeway and standard compact JWT, defer aud/iss/sub to Rust if call-time audience/issuer/subject were set; keep strict_aud in Python. --- python/oxyjwt/api_jwt.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/python/oxyjwt/api_jwt.py b/python/oxyjwt/api_jwt.py index 09859ef..738cbcc 100644 --- a/python/oxyjwt/api_jwt.py +++ b/python/oxyjwt/api_jwt.py @@ -323,6 +323,7 @@ def decode_complete( subject, lwf, rust_time_claims=whole_leeway and detached_payload is None, + rust_standard_claims=whole_leeway and detached_payload is None, ) return { "payload": pl_out, @@ -340,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() @@ -356,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 not ( + rust_standard_claims and issuer is not None + ): + 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 From 10cbb9c70e0a497fe89d3321c71ad7626cc9e2a7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:50:45 +0300 Subject: [PATCH 10/18] perf(rust): validate RFC 7797 detached claims in Rust (#66) Apply jsonwebtoken-style claim checks after detached signature verify; skip redundant Python exp/nbf when leeway is whole seconds. --- python/oxyjwt/api_jwt.py | 2 +- rust/src/api.rs | 22 +++++ rust/src/claims_validate.rs | 157 ++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 1 + tests/test_detached_jws.py | 10 ++- 5 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 rust/src/claims_validate.rs diff --git a/python/oxyjwt/api_jwt.py b/python/oxyjwt/api_jwt.py index 738cbcc..4fe86e3 100644 --- a/python/oxyjwt/api_jwt.py +++ b/python/oxyjwt/api_jwt.py @@ -322,7 +322,7 @@ 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 { diff --git a/rust/src/api.rs b/rust/src/api.rs index 4d6c29b..e32ad97 100644 --- a/rust/src/api.rs +++ b/rust/src/api.rs @@ -10,6 +10,7 @@ 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}; @@ -170,6 +171,7 @@ fn decode_rfc7797_verified_complete( 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)?; @@ -198,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())?; @@ -212,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); } 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/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/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) From b476fb9bcfd19e80699f9bb3730238e1a5250ae1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:51:19 +0300 Subject: [PATCH 11/18] fix(api): use InvalidIssuerError when iss missing with issuer= (#69) Align issuer validation with expected API parity; add tests for missing iss and bytes issuer rejection. --- python/oxyjwt/api_jwt.py | 2 +- tests/test_validation.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/python/oxyjwt/api_jwt.py b/python/oxyjwt/api_jwt.py index 738cbcc..249335b 100644 --- a/python/oxyjwt/api_jwt.py +++ b/python/oxyjwt/api_jwt.py @@ -444,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/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") From ffac2db15541e945eee6334fbf4b5b634696e6e4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:51:34 +0300 Subject: [PATCH 12/18] fix(api): always run issuer check when issuer= is passed (#69) Rust claim validation does not cover a missing iss claim when issuer is provided; keep Python issuer validation on that path. --- python/oxyjwt/api_jwt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/oxyjwt/api_jwt.py b/python/oxyjwt/api_jwt.py index 249335b..b353dfd 100644 --- a/python/oxyjwt/api_jwt.py +++ b/python/oxyjwt/api_jwt.py @@ -372,8 +372,8 @@ def _validate_claims( audience, strict=strict_aud, ) - if options.get("verify_iss", True) and not ( - rust_standard_claims and issuer is not None + 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 ( From 69f8715f47f8412647505134de661a454aafe146 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:52:01 +0300 Subject: [PATCH 13/18] fix(jwks): thread-safe PyJWKClient cache (#67) Guard JWKS fetch and kid LRU with an RLock; add concurrent access test. --- python/oxyjwt/jwks_client.py | 47 +++++++++++++++++++++--------------- tests/test_jwks_client.py | 26 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 19 deletions(-) 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/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 From 507e0a8d8299186ece8ef6b3765871cf771181de Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:52:32 +0300 Subject: [PATCH 14/18] perf(jwks): lazy PyJWK materialization for large JWKS (#68) Store raw JWK dicts until kid lookup or .keys access; keep _by_kid compat. --- python/oxyjwt/jwk.py | 82 +++++++++++++++++++++++++++++++------------- tests/test_jwk.py | 10 +++--- 2 files changed, 65 insertions(+), 27 deletions(-) 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/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: From e3c96af2c622b136dfdf258a8268e0946bf2f05d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:57:06 +0300 Subject: [PATCH 15/18] test: expand security regression contract for 0.5.0 (#72) Add strict segments, issuer bytes, and require_https cases; refresh module docstring. --- tests/test_security_regression.py | 41 ++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/test_security_regression.py b/tests/test_security_regression.py index 2da2341..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 @@ -225,6 +226,40 @@ def test_security_oversized_detached_payload_rejected() -> None: ) +# --- 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) --- From 7a970d9f39ef470936a3a1e6abb958d7377a448d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:57:17 +0300 Subject: [PATCH 16/18] ci(bench): stabilize HS256 smoke benchmark (#71) Use 3 rounds and median ops/s for PyJWT ratio gates; drop absolute 500 ops/s floor. Document optional extended benchmark in RELEASING.md. --- RELEASING.md | 6 +++ docs-site/docs/benchmarks.md | 23 +++++++++-- tests/test_benchmark_jwt_libraries.py | 56 ++++++++++++++++++++------- 3 files changed, 67 insertions(+), 18 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index a91ea36..e74908f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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 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/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", ) From ca191acb3867b8e452f1efbb5bdc98b6b41aba05 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 15:57:29 +0300 Subject: [PATCH 17/18] docs(bench): document PEM vs cached competitor key modes (#70) Clarify benchmark fairness in README and script help; upload cached-mode weekly benchmark artifact alongside default pem run. --- .github/workflows/benchmarks.yml | 14 ++++++++++++-- README.md | 27 ++++++++++++++------------- scripts/compare_jwt_libraries.py | 6 +++++- 3 files changed, 31 insertions(+), 16 deletions(-) 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/README.md b/README.md index 6428c45..a4b6624 100644 --- a/README.md +++ b/README.md @@ -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/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", From 192c588295506b7e87bb3449925962f78f9d01e2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 22 May 2026 16:12:26 +0300 Subject: [PATCH 18/18] chore(release): prepare 0.5.0 on dev Bump Python/Rust package version to 0.5.0, add changelog and release notes, and update security/support docs for the 0.5.x line. --- .github/RELEASE_NOTES_v0.5.0.md | 48 +++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 2 +- README.md | 2 +- RELEASING.md | 10 +++---- SECURITY.md | 5 ++-- docs-site/docs/changelog.md | 43 +++++++++++++++++++++++++++++ docs-site/docs/index.md | 2 +- docs-site/docs/versioning.md | 2 +- pyproject.toml | 2 +- python/oxyjwt/__init__.py | 2 +- rust/Cargo.lock | 2 +- rust/Cargo.toml | 2 +- 12 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 .github/RELEASE_NOTES_v0.5.0.md 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/release.yml b/.github/workflows/release.yml index 0d7a5d8..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: diff --git a/README.md b/README.md index a4b6624..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 diff --git a/RELEASING.md b/RELEASING.md index e74908f..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 @@ -41,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 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/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/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/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/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"