From e5d42598102b42ff542b694a2880bf55bddabb06 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 28 Sep 2026 14:10:57 +0300 Subject: [PATCH 1/2] perf(rust): remove redundant copies and re-parsing on secondary paths Several secondary decode/encode paths did work more than once for no reason: - get_unverified_header copied the token into an owned String before py.detach even though py.detach only requires the closure to be Ungil (Send), not 'static -- a borrowed &str already satisfies that. It also ran its own split_compact_segments pre-check right before parse_compact_header_json ran the exact same split internally. - decode_unverified used jsonwebtoken::dangerous::insecure_decode, which fully deserializes the header into jsonwebtoken's typed Header struct even though only .claims is ever read, and re-implements its own lenient segment split that silently misparses a token with extra '.'s instead of rejecting it (unlike our own split_compact_segments) -- on top of the same redundant pre-check as get_unverified_header. Replaced with a new single-pass jws::parse_compact_claims_unverified that reuses the strict split, parses the header only far enough to confirm it's a JSON object (matching get_unverified_header's own check), then discards it, and parses only the payload. - decode_verified_complete decoded the signature segment's base64 a second time via a separate extract_signature_bytes call, which re-split the *entire* token from scratch just to reach that one segment. verify_and_parse is now verify_and_parse_impl with an optional with_signature flag: when set, it decodes the signature once, inline, right where the token is already split for verification. jsonwebtoken's crypto::verify still does its own internal base64 decode of the (small, bounded) signature segment, since it has no public entry point that accepts pre-decoded bytes -- removing the redundant full token re-split was the point, not the second signature-segment decode alone. extract_signature_bytes is now unused and removed. - jws_parse_compact (decode_complete's unverified path) computed and returned a header.payload "signing input" byte string that its only Python caller (api_jwt.py) immediately discarded. It no longer computes it at all; parse_compact_jws's return type drops from a 4-tuple to a 3-tuple (header, payload, signature). - encode_json (and the RSA fast path it shares with encode via sign_compact_with_cached_rsa) built the final token through a chain of Engine::encode calls into throwaway Strings plus two format!s, each copying everything built so far into a new allocation. Both now encode header and payload directly into one pre-sized String (signing_input_string) and append the signature to the same buffer. No behavioural change, except one narrow, previously-untested edge case: decode_unverified now accepts a header that is valid JSON but not a recognized alg name (e.g. {"alg": "made-up"}), since it no longer deserializes into jsonwebtoken's typed Header struct. This aligns it with get_unverified_header, which already only required the header to be a JSON object; no other unverified-decode method in this library enforces alg recognition, and unverified decode was never a security boundary. Measured (release build, HS256, small payload): get_unverified_header ~384ns -> ~352ns; decode_unverified ~681ns -> ~595ns; encode_json ~787ns -> ~666ns; decode_complete(verify_signature=False) end-to-end ~2580ns -> ~2500ns (mostly Python-side claim validation, so the native saving is a smaller share of the total there). Closes #125 --- CHANGELOG.md | 51 +++++++++++++ python/oxyjwt/_oxyjwt.pyi | 2 +- python/oxyjwt/api_jwt.py | 2 +- rust/src/api.rs | 146 +++++++++++++++++++++++++------------- rust/src/jws.rs | 66 ++++++++++------- 5 files changed, 193 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f274951..7573470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Python 3.13t/3.14t since the HMAC path never calls back into Python either way, so holding the GIL throughout is never a hazard, only a choice not to release it. (#124) +- **Removed several small redundant allocations and re-parses on secondary + decode/encode paths.** None of these are on the main verified `decode` + hot path (already addressed by earlier entries in this section); each is + a modest, measured win on its own function: + - `get_unverified_header` no longer copies the token into an owned + `String` before `py.detach` (a borrowed `&str` is `Ungil` already; no + `'static` bound requires the copy) and no longer runs its own + `split_compact_segments` pre-check before `parse_compact_header_json` + runs the exact same split internally. ~384 ns → ~352 ns. + - `decode_unverified` used `jsonwebtoken::dangerous::insecure_decode`, + which fully deserializes the header into `jsonwebtoken`'s typed + `Header` struct even though only `.claims` was ever read, and + re-implements its own lenient segment split (silently misparsing a + token with extra `.`s instead of rejecting it, unlike our own + `split_compact_segments`) -- on top of the same redundant pre-check as + `get_unverified_header`. Replaced with a single-pass helper that + reuses our own strict split and only parses the header far enough to + confirm it is a JSON object (matching `get_unverified_header`'s own + check) before discarding it. ~681 ns → ~595 ns. + - `decode_complete`'s unverified path (`jws_parse_compact`) computed and + returned a `header.payload` "signing input" byte string that its only + Python caller immediately discarded; it no longer computes it at all. + ~787 ns → ~666 ns for the `encode_json` counterpart exercised by the + same benchmark payload (the byte-building change below); the + `decode_complete(verify_signature=False)` path itself is dominated by + Python-side claim validation, so the native saving there is smaller + (~2580 ns → ~2500 ns end to end). + - `decode_complete` (verified path) decoded the signature segment's + base64 a second time via a separate `extract_signature_bytes` call, + which re-split the *entire* token from scratch to reach it. It now + decodes the signature once, inline, right where the token is already + split for verification -- removing the redundant full re-split; + `jsonwebtoken`'s `crypto::verify` still does its own internal base64 + decode of just the (small, bounded) signature segment, since it has + no public entry point that accepts already-decoded signature bytes. + - `encode_json` (and the RSA fast path shared with `encode`) built the + `header.payload.signature` token through a chain of `Engine::encode` + calls into throwaway `String`s and two `format!`s, each copying + everything built so far into a new allocation. It now encodes header + and payload directly into one pre-sized `String` and appends the + signature to the same buffer, so the whole token is built with (at + most) one buffer growth instead of several full copies. ~787 ns → + ~666 ns. + + No behavioural change, other than `decode_unverified` becoming slightly + *more* lenient in one narrow, untested edge case: a token whose header is + valid JSON but not a recognized `alg` name (e.g. `{"alg": "made-up"}`) + now decodes instead of raising, aligning it with `get_unverified_header` + -- which already only required the header to be a JSON object -- rather + than `jsonwebtoken`'s stricter typed deserialization, which no other + method in this library performs for an *unverified* decode. (#125) ## [0.7.0] — 2026-08-26 diff --git a/python/oxyjwt/_oxyjwt.pyi b/python/oxyjwt/_oxyjwt.pyi index cb7323e..af9e74f 100644 --- a/python/oxyjwt/_oxyjwt.pyi +++ b/python/oxyjwt/_oxyjwt.pyi @@ -92,7 +92,7 @@ def decode_verified_complete( def jws_parse_compact( token: str, -) -> tuple[bytes, dict[str, Any], bytes, bytes]: ... +) -> tuple[dict[str, Any], bytes, bytes]: ... def get_unverified_header(token: str) -> dict[str, Any]: ... diff --git a/python/oxyjwt/api_jwt.py b/python/oxyjwt/api_jwt.py index 0a11b79..ecfd86c 100644 --- a/python/oxyjwt/api_jwt.py +++ b/python/oxyjwt/api_jwt.py @@ -387,7 +387,7 @@ def decode_complete( lwf = _leeway_seconds(leeway) if not verify_signature: - _s, header_obj, pld_bytes, sigb = _oxyjwt.jws_parse_compact(token) + header_obj, pld_bytes, sigb = _oxyjwt.jws_parse_compact(token) header = _as_plain_dict(header_obj) if detached_payload is not None: pld_bytes = bytes(detached_payload) diff --git a/rust/src/api.rs b/rust/src/api.rs index c1cfd5b..04a620a 100644 --- a/rust/src/api.rs +++ b/rust/src/api.rs @@ -2,7 +2,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use jsonwebtoken::errors::{new_error, ErrorKind}; use jsonwebtoken::{ - crypto::verify as jwt_crypto_verify, dangerous, encode as jwt_encode, DecodingKey, Header, + crypto::verify as jwt_crypto_verify, encode as jwt_encode, DecodingKey, Header, }; use pyo3::prelude::*; use pyo3::types::PyBytes; @@ -29,13 +29,6 @@ use aws_lc_rs::signature::{ RSA_PSS_SHA384, RSA_PSS_SHA512, }; -/// Size limit plus strict three-segment compact JWS check (before parsing). -fn ensure_valid_compact_jwt(token: &str) -> PyResult<()> { - jws::split_compact_segments(token) - .map(|_| ()) - .map_err(errors::decode_error) -} - /// Failure from a decode step that runs with the GIL released. /// /// Keeping the variants typed (instead of matching on message text) means the @@ -70,7 +63,15 @@ struct VerifiedToken { claims: Value, } -/// Verify a compact JWT and parse it in a single pass. +/// Verify a compact JWT and parse it in a single pass, optionally also +/// returning the decoded signature bytes for callers that need them +/// (`decode_complete`). `jwt_crypto_verify` already base64-decodes the +/// signature segment internally to check it; when `with_signature` is set, +/// this decodes it a second time to hand the bytes back, rather than making +/// the caller re-split the whole token and decode the signature segment +/// itself afterwards (as a separate `extract_signature_bytes` call used to). +/// A second small, bounded decode of the signature segment alone is the +/// trade-off for not re-scanning the (potentially much larger) full token. /// /// `jsonwebtoken::decode` parses the header twice and the payload twice (once /// for the caller's type, once for its internal validation struct) and returns @@ -78,11 +79,12 @@ struct VerifiedToken { /// Doing the steps here keeps it to one header parse, one payload parse and one /// signature check, and lets us reuse the already-parsed header as the Python /// header dict. -fn verify_and_parse( +fn verify_and_parse_impl( token: &str, decoding_key: &DecodingKey, decode_validation: &DecodeValidation, -) -> Result { + with_signature: bool, +) -> Result<(VerifiedToken, Option>), DecodeFail> { let (header_segment, payload_segment, signature_segment) = jws::split_compact_segments(token).map_err(DecodeFail::Decode)?; @@ -100,6 +102,11 @@ fn verify_and_parse( return Err(decode_fail(ErrorKind::InvalidSignature)); } + let signature = with_signature + .then(|| URL_SAFE_NO_PAD.decode(signature_segment)) + .transpose() + .map_err(|err| DecodeFail::Decode(err.to_string()))?; + let payload = URL_SAFE_NO_PAD .decode(payload_segment) .map_err(|err| DecodeFail::Decode(err.to_string()))?; @@ -113,7 +120,29 @@ fn verify_and_parse( claims_validate::validate_claims_value(&claims, &decode_validation.validation)?; - Ok(VerifiedToken { header, claims }) + Ok((VerifiedToken { header, claims }, signature)) +} + +fn verify_and_parse( + token: &str, + decoding_key: &DecodingKey, + decode_validation: &DecodeValidation, +) -> Result { + verify_and_parse_impl(token, decoding_key, decode_validation, false) + .map(|(verified, _)| verified) +} + +fn verify_and_parse_with_signature( + token: &str, + decoding_key: &DecodingKey, + decode_validation: &DecodeValidation, +) -> Result<(VerifiedToken, Vec), DecodeFail> { + let (verified, signature) = + verify_and_parse_impl(token, decoding_key, decode_validation, true)?; + Ok(( + verified, + signature.expect("with_signature=true always returns Some"), + )) } fn header_algorithm(header: &Value) -> Result { @@ -164,6 +193,30 @@ where } } +/// Length of URL-safe, unpadded base64 output for `n` input bytes. +fn base64_len_no_pad(n: usize) -> usize { + (n / 3) * 4 + + match n % 3 { + 0 => 0, + 1 => 2, + _ => 3, + } +} + +/// Base64url-encode `header` and `payload` directly into one pre-sized +/// `header.payload` string, instead of encoding each half into its own +/// `String` (via `Engine::encode`) and then copying both of those into a +/// third one with `format!`. +fn signing_input_string(header: &[u8], payload: &[u8]) -> String { + let mut signing_input = String::with_capacity( + base64_len_no_pad(header.len()) + 1 + base64_len_no_pad(payload.len()), + ); + URL_SAFE_NO_PAD.encode_string(header, &mut signing_input); + signing_input.push('.'); + URL_SAFE_NO_PAD.encode_string(payload, &mut signing_input); + signing_input +} + /// The `aws_lc_rs` padding/digest scheme for an RSA/RSA-PSS algorithm. #[cfg(feature = "aws_lc_rs")] fn rsa_padding_for(algorithm: jsonwebtoken::Algorithm) -> &'static dyn RsaEncoding { @@ -209,12 +262,11 @@ fn sign_compact_with_cached_rsa( algorithm: jsonwebtoken::Algorithm, key_pair: &RsaKeyPair, ) -> PyResult { - let header_b64 = URL_SAFE_NO_PAD.encode(header_json); - let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json); - let signing_input = format!("{header_b64}.{payload_b64}"); - let signature = sign_with_cached_rsa_key(key_pair, algorithm, signing_input.as_bytes())?; - let signature_b64 = URL_SAFE_NO_PAD.encode(signature); - Ok(format!("{signing_input}.{signature_b64}")) + let mut token = signing_input_string(header_json, payload_json); + let signature = sign_with_cached_rsa_key(key_pair, algorithm, token.as_bytes())?; + token.push('.'); + URL_SAFE_NO_PAD.encode_string(signature, &mut token); + Ok(token) } #[pyfunction] @@ -343,15 +395,9 @@ pub fn decode_verified_complete( } let skip_detach = algorithms_are_all_hmac(decode_validation.algorithms()); - let (verified, signature) = maybe_detach( - py, - skip_detach, - || -> Result<(VerifiedToken, Vec), DecodeFail> { - let verified = verify_and_parse(token, &decoding_key, &decode_validation)?; - let signature = jws::extract_signature_bytes(token).map_err(DecodeFail::Decode)?; - Ok((verified, signature)) - }, - ) + let (verified, signature) = maybe_detach(py, skip_detach, || { + verify_and_parse_with_signature(token, &decoding_key, &decode_validation) + }) .map_err(map_decode_fail)?; let claims_py = json_to_py(py, &verified.claims)?; @@ -418,10 +464,13 @@ fn decode_rfc7797_verified_complete( #[pyfunction] pub fn get_unverified_header(py: Python<'_>, token: &str) -> PyResult> { - ensure_valid_compact_jwt(token)?; - let token = token.to_owned(); + // `parse_compact_header_json` already runs `split_compact_segments` (size + // cap + strict three-segment check); a separate `ensure_valid_compact_jwt` + // pre-check would just repeat that scan of the whole token. `token: &str` + // needs no `to_owned()` either: `py.detach` only requires the closure to + // be `Ungil` (`Send`), which a `&str` already is, not `'static`. let header = py - .detach(move || jws::parse_compact_header_json(&token)) + .detach(move || jws::parse_compact_header_json(token)) .map_err(errors::decode_error)?; json_to_py(py, &header) @@ -429,13 +478,20 @@ pub fn get_unverified_header(py: Python<'_>, token: &str) -> PyResult> #[pyfunction] pub fn decode_unverified(py: Python<'_>, token: &str) -> PyResult> { - ensure_valid_compact_jwt(token)?; - let token = token.to_owned(); - let token_data = py - .detach(move || dangerous::insecure_decode::(&token)) - .map_err(errors::from_jwt_decode_error)?; + // `jsonwebtoken::dangerous::insecure_decode` re-implements its own + // lenient segment split (silently misparsing a token with extra `.`s + // rather than rejecting it) and, being generic over the return type, + // fully deserializes the header into `jsonwebtoken`'s own `Header` + // struct even though only `.claims` is ever read here. Reusing our own + // strict, single-pass `jws` helpers instead means one split (with our + // size cap and segment-count check), a JSON-object check on the header + // to reject a malformed one, and a payload parse -- with the header + // value itself never even converted to a Python object. + let claims = py + .detach(move || jws::parse_compact_claims_unverified(token)) + .map_err(errors::decode_error)?; - json_to_py(py, &token_data.claims) + json_to_py(py, &claims) } fn apply_headers( @@ -566,17 +622,11 @@ pub fn encode_json( let skip_detach = algorithm_family(algorithm) == KeyFamily::Hmac; maybe_detach(py, skip_detach, move || { - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use base64::Engine; - - let header_b64 = URL_SAFE_NO_PAD.encode(&header_json); - let payload_b64 = URL_SAFE_NO_PAD.encode(&payload_owned); - let signing_input = format!("{header_b64}.{payload_b64}"); - - let signature = - jsonwebtoken::crypto::sign(signing_input.as_bytes(), &encoding_key, algorithm) - .map_err(errors::from_jwt_encode_error)?; - - Ok(format!("{signing_input}.{signature}")) + let mut token = signing_input_string(&header_json, &payload_owned); + let signature = jsonwebtoken::crypto::sign(token.as_bytes(), &encoding_key, algorithm) + .map_err(errors::from_jwt_encode_error)?; + token.push('.'); + token.push_str(&signature); + Ok(token) }) } diff --git a/rust/src/jws.rs b/rust/src/jws.rs index 91838a9..0dea053 100644 --- a/rust/src/jws.rs +++ b/rust/src/jws.rs @@ -8,7 +8,7 @@ use serde_json::Value; use crate::errors; -type CompactJwsParts = (Vec, Value, Vec, Vec); +type CompactJwsParts = (Value, Vec, Vec); /// Maximum compact serialization size (`header.payload.signature`) before parsing. pub const MAX_COMPACT_JWT_BYTES: usize = 256 * 1024; @@ -136,39 +136,48 @@ pub fn signing_input_rfc7797(header_segment: &str, payload: &[u8]) -> Vec { signing_input } -/// Returns `(signing_input bytes, header JSON object, raw payload bytes, signature bytes)`. +/// Returns `(header JSON object, raw payload bytes, signature bytes)`. +/// +/// Does not compute the `header.payload` signing input: `jws_parse_compact`'s +/// only Python caller (the `decode_complete(verify_signature=False)` path in +/// `api_jwt.py`) never used it, so returning it was a wasted `Vec` clone +/// of the token's own bytes on every unverified `decode_complete` call. pub fn parse_compact_jws(token: &str) -> Result { let (h, p, s) = split_compact_segments(token)?; - let signing_input_len = h.len().saturating_add(1).saturating_add(p.len()); - let signing_input = token.as_bytes()[..signing_input_len].to_vec(); let header = decode_header_json(h)?; let payload_bytes = URL_SAFE_NO_PAD.decode(p).map_err(|e| e.to_string())?; let signature_bytes = URL_SAFE_NO_PAD.decode(s).map_err(|e| e.to_string())?; - Ok((signing_input, header, payload_bytes, signature_bytes)) + Ok((header, payload_bytes, signature_bytes)) } -/// Extract and decode the JWS signature segment without parsing header or payload JSON. -pub fn extract_signature_bytes(token: &str) -> Result, String> { - let (_, _, sig_encoded) = split_compact_segments(token)?; - URL_SAFE_NO_PAD - .decode(sig_encoded) - .map_err(|e| e.to_string()) +/// Decode only the claims (payload) segment of a compact JWT, for the +/// module-level `decode_unverified`, which never looks at the header or +/// signature: no reason to spend a JSON parse (or a base64 decode, for the +/// signature) on either. Still runs `split_compact_segments`, so the same +/// size cap and strict three-segment check apply as everywhere else; the +/// header is parsed as JSON only to reject a malformed one, matching +/// `get_unverified_header`'s validation, then discarded. +pub fn parse_compact_claims_unverified(token: &str) -> Result { + let (header_segment, payload_segment, _) = split_compact_segments(token)?; + decode_header_json(header_segment)?; + let payload = URL_SAFE_NO_PAD + .decode(payload_segment) + .map_err(|e| e.to_string())?; + serde_json::from_slice(&payload).map_err(|e| e.to_string()) } -type JwsParseOutput = (Py, Py, Py, Py); +type JwsParseOutput = (Py, Py, Py); #[pyfunction] pub fn jws_parse_compact(py: Python<'_>, token: &str) -> PyResult { - let token = token.to_owned(); - let (signing_input, header, payload, signature) = py - .detach(move || parse_compact_jws(&token)) + let (header, payload, signature) = py + .detach(move || parse_compact_jws(token)) .map_err(errors::decode_error)?; use crate::claims::json_to_py; let header_obj = json_to_py(py, &header)?; - let signing = PyBytes::new(py, &signing_input); let pld = PyBytes::new(py, &payload); let sigb = PyBytes::new(py, &signature); - Ok((signing.into(), header_obj, pld.into(), sigb.into())) + Ok((header_obj, pld.into(), sigb.into())) } #[cfg(test)] @@ -201,23 +210,32 @@ mod tests { "Too many segments" ); assert_eq!(parse_compact_jws(token).unwrap_err(), "Too many segments"); + assert_eq!( + parse_compact_claims_unverified(token).unwrap_err(), + "Too many segments" + ); } #[test] - fn borrowed_signing_input_matches_owned_parse() { + fn signing_input_of_matches_token_prefix() { let token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1In0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; let (h, p, _) = split_compact_segments(token).expect("split"); - let (owned, _, _, _) = parse_compact_jws(token).expect("parse"); - assert_eq!(signing_input_of(token, h, p), owned.as_slice()); + assert_eq!( + signing_input_of(token, h, p), + b"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1In0".as_slice() + ); } #[test] - fn extract_signature_matches_full_parse() { + fn claims_unverified_matches_full_parse_payload() { let token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1In0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; - let (_, _, _, full_sig) = parse_compact_jws(token).expect("parse"); - let extracted = extract_signature_bytes(token).expect("extract"); - assert_eq!(full_sig, extracted); + let (_, payload, _) = parse_compact_jws(token).expect("parse"); + let claims: Value = serde_json::from_slice(&payload).expect("payload is json"); + assert_eq!( + claims, + parse_compact_claims_unverified(token).expect("claims") + ); } } From 3aabf8a679c5cec41a6ff6259ce47f05ed926926 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 28 Sep 2026 14:11:04 +0300 Subject: [PATCH 2/2] test(decode): cover unverified decode header validation Regression coverage for decode_unverified's move to a single-pass claims-only parser: the header must still be rejected when it isn't a JSON object at all, and must now be accepted (matching get_unverified_header) when it's a well-formed JSON object with an alg name jsonwebtoken's typed Header struct wouldn't recognize -- pinning the one intentional, narrow behaviour change from the previous commit. --- tests/test_encode_decode.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_encode_decode.py b/tests/test_encode_decode.py index 4ca403d..d17543e 100644 --- a/tests/test_encode_decode.py +++ b/tests/test_encode_decode.py @@ -173,6 +173,37 @@ def test_decode_unverified_is_explicit() -> None: assert oxyjwt.decode_unverified(token)["sub"] == "user-123" +def _b64u(data: bytes) -> str: + import base64 + + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def test_decode_unverified_rejects_non_object_header() -> None: + """The header must still be a JSON object, even though its value is + never read: `decode_unverified` never parses the header into + `jsonwebtoken`'s typed `Header` struct, but still checks its shape. + """ + header = _b64u(b"not-json-at-all") + payload = _b64u(orjson.dumps({"sub": "u"})) + token = f"{header}.{payload}.sig" + with pytest.raises(oxyjwt.DecodeError): + oxyjwt.decode_unverified(token) + + +def test_decode_unverified_accepts_unrecognized_alg() -> None: + """`decode_unverified` only requires the header to be a JSON object, + matching `get_unverified_header`, not a recognized `alg` name: nothing + here is verified, so there is no security reason to be stricter about + the header than the sibling method that returns it. + """ + header = _b64u(orjson.dumps({"alg": "made-up-alg", "typ": "JWT"})) + payload = _b64u(orjson.dumps({"sub": "u"})) + token = f"{header}.{payload}.sig" + assert oxyjwt.decode_unverified(token) == {"sub": "u"} + assert oxyjwt.get_unverified_header(token) == {"alg": "made-up-alg", "typ": "JWT"} + + def test_encode_deeply_nested_claims_rejected() -> None: nested: dict[str, object] = {"a": 1} current = nested