diff --git a/.github/RELEASE_NOTES_v0.9.0.md b/.github/RELEASE_NOTES_v0.9.0.md new file mode 100644 index 0000000..9d8ceeb --- /dev/null +++ b/.github/RELEASE_NOTES_v0.9.0.md @@ -0,0 +1,40 @@ +# OxyJWT 0.9.0 + +**Beta** performance and security release — zero-copy streaming claims deserialization, pre-encoded static headers, native fast-path claim validation in Rust, zero-copy header algorithm extraction, direct JWK deserialization, RFC 7515/7797 strict `crit` header enforcement, and public top-level `encode_json` export. + +## Highlights + +### Security + +- **Strict rejection of unrecognized `crit` (Critical) header parameters** — Under RFC 7515 §4.1.11 and RFC 7797, any critical header extensions that are unrecognized, malformed, empty, or targeting reserved claims are rejected immediately on both verified and unverified decode paths (#132). + +### Performance + +- **Pre-encoded static headers and native claim validation** — Standard default headers (`{"typ":"JWT","alg":"..."}`) use pre-encoded immutable base64 slices in `encode` and `encode_json`, eliminating header JSON serialization and base64 allocation. Fast-path `decode` now validates `iat`, unrequested `aud`, and `sub` entirely in Rust, eliminating secondary Python validation passes. HS256 encode benchmark reached **~895,000 ops/s** (#150). +- **Direct JWK deserialization** — Materializing a decoding key from a JWK dictionary now avoids intermediate `serde_json::Value` DOM trees; parsed in a single pass via `orjson.dumps` and streaming `jsonwebtoken::jwk::Jwk` parsing. Speedup: 10× for octet/HMAC keys (0.97 µs vs 9.66 µs) and 21× for RSA keys (1.21 µs vs 25.48 µs) (#140). +- **Zero-copy header algorithm resolution** — Standard compact headers are resolved via static string matching without base64 or JSON decoding; non-standard headers extract `alg` via a streaming serde visitor without allocating map structures (#138). +- **Streaming claims deserialization into `PyDict`** — Deserialization in `decode_complete` directly streams serde JSON tokens into Python `PyDict` without building intermediate Rust DOM trees (#137). +- **In-Rust validation for `aud`, `iss`, and `sub`** — Standard claim checks execute natively during fast-path decoding (#136). +- **Payload borrowing in `encode_json` and GIL optimization** — JWS signing inputs are built directly from borrowed slices; non-cryptographic sub-microsecond helpers run attached to the GIL to eliminate `py.detach` thread context switching overhead (#139). + +### Added + +- **Top-level `encode_json` export** — `encode_json` is exported directly on `oxyjwt.encode_json` and `PyJWT.encode_json` with complete typing and parity tests (#148). +- `DecodingKey.from_jwk`, `PyJWK`, and `PyJWKSet.from_json` now accept raw UTF-8 `bytes` directly in addition to `str` and `Mapping[str, Any]` (#140). + +## Install + +```bash +pip install oxyjwt==0.9.0 +``` + +## Upgrade from 0.8.0 + +```bash +pip install -U oxyjwt +``` + +- No breaking changes to public APIs. +- Full parity with PyJWT 2.x maintained across all algorithms and options. + +See the full [changelog](https://github.com/QueryaHub/OxyJWT/blob/main/CHANGELOG.md#090--2026-10-17). diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5b1d2..f8ac74b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -(No changes yet.) +## [0.9.0] — 2026-10-17 + +### Security + +- **Strict rejection of unrecognized `crit` (Critical) header parameters.** + Under RFC 7515 §4.1.11 and RFC 7797, any header parameter listed in the `crit` + header array must be understood and processed. OxyJWT now strictly validates + `crit` on both verified and unverified decode paths: unrecognized critical + extensions, empty `crit` lists, malformed structures, or critical references + to standard reserved headers immediately raise `DecodeError` (#132). + +### Performance + +- **Pre-encoded static base64 headers and native fast-path claim verification.** + In `encode` and `encode_json`, tokens with standard default headers now use + pre-encoded immutable base64 header slices, eliminating runtime `Header` JSON + serialization and base64 allocation. In `decode`, standard claim validations + (`iat`, unrequested `aud` rejection, and `sub` type checks) now execute entirely + within the Rust core, allowing the fast-path decode to return the claims dictionary + directly without redundant Python-side re-checks (#150). +- **Eliminate triple JSON round-trip in `DecodingKey.from_jwk`.** + Materializing a decoding key from a JWK Python dictionary now bypasses intermediate + `serde_json::Value` DOM allocations and re-serialization. The dictionary is + serialized directly in C via `orjson.dumps`, and parsed into `jsonwebtoken::jwk::Jwk` + in a single pass. `DecodingKey.from_jwk` also accepts `&str`, `&[u8]` (`bytes`), + and `bytearray` natively with zero-copy deserialization. Benchmark speedup: + 10× faster for octet/HMAC keys (0.97 µs vs 9.66 µs) and 21× faster for RSA keys + (1.21 µs vs 25.48 µs) (#140). +- **Avoid redundant payload copying in `encode_json` and trim `py.detach` overhead.** + In `encode_json`, the JWS signing input is now built directly from the borrowed + payload byte slice without an owned `payload_bytes.to_vec()` heap clone. + Non-cryptographic sub-microsecond paths (`get_unverified_header` and + `decode_unverified`) now run synchronously attached to the GIL, eliminating + repeated `PyEval_SaveThread` / `PyEval_RestoreThread` cycles (#139). +- **Zero-copy header algorithm extraction & static header fast-path.** + Decode paths avoid full JSON DOM parsing when only the algorithm is needed. + Standard compact headers (`{"typ":"JWT","alg":"..."}`, `{"alg":"...","typ":"JWT"}`, + `{"alg":"..."}`) are recognized in a single string match without base64 or JSON + decoding. Non-standard headers extract `alg` via a streaming serde visitor + without allocating a `serde_json::Map` (#138). +- **Streaming claims deserialization into `PyDict`.** + Deserialization of claims in `decode_complete` directly streams serde JSON tokens + into a `PyDict` without allocating an intermediate `serde_json::Value` tree (#137). +- **Fast-path native validation for `aud`, `iss`, and `sub`.** + Extended the in-Rust fast-path decoder to validate audience, issuer, and subject + claims directly in Rust, reducing Python fallback overhead (#136). + +### Added + +- **Top-level `encode_json` export with full typing and parity coverage.** + `encode_json` is now exported in the public API root (`oxyjwt.encode_json`) and on + `PyJWT.encode_json`, allowing callers who already serialize payloads to JSON + (such as via orjson) to avoid redundant dictionary deserialization (#148). +- `DecodingKey.from_jwk`, `PyJWK`, and `PyJWKSet.from_json` now accept raw UTF-8 + `bytes` directly in addition to `str` and `Mapping[str, Any]`. ## [0.8.0] — 2026-09-28 @@ -425,7 +479,8 @@ Initial alpha release. - Mixed algorithm families are rejected for one decode call. - In 0.1.0, `verify_signature=False` was rejected in `decode` (0.2.0 allows an explicit unverified path). -[Unreleased]: https://github.com/QueryaHub/OxyJWT/compare/v0.8.0...HEAD +[Unreleased]: https://github.com/QueryaHub/OxyJWT/compare/v0.9.0...HEAD +[0.9.0]: https://github.com/QueryaHub/OxyJWT/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/QueryaHub/OxyJWT/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/QueryaHub/OxyJWT/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/QueryaHub/OxyJWT/compare/v0.5.0...v0.6.0 diff --git a/README.md b/README.md index 1fe2bea..d4d400b 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,20 @@ print(token_data["signature"]) --- +### 5. Pre-Serialized High-Throughput Encoding (`encode_json`) + +For high-throughput pipelines where payloads are already serialized as JSON bytes or strings: + +```python +import orjson +import oxyjwt + +payload_bytes = orjson.dumps({"sub": "user_42", "exp": 1893456000}) +token = oxyjwt.encode_json(payload_bytes, "secret", algorithm="HS256") +``` + +--- + ## 🛡️ Exception Hierarchy OxyJWT provides a predictable exception tree that mirrors PyJWT for easy catch blocks: diff --git a/docs-site/docs/api-reference.md b/docs-site/docs/api-reference.md index 013dbff..fc7c445 100644 --- a/docs-site/docs/api-reference.md +++ b/docs-site/docs/api-reference.md @@ -28,6 +28,21 @@ Raises: - `InvalidAlgorithmError` for unsupported algorithms or `alg` header overrides. - `InvalidKeyError` for invalid key material. +## `encode_json` + +```python +def encode_json( + payload_json: str | bytes, + key: str | bytes | EncodingKey, + algorithm: str = "HS256", + headers: Mapping[str, Any] | None = None, +) -> str: ... +``` + +High-performance signing variant that accepts an already-serialized JSON payload as `str` or `bytes`. Bypasses Python dictionary serialization and reflection. For HMAC algorithms, the borrowed byte slice is signed directly without heap cloning. + +Raises the same exceptions as `encode`. + ## `decode` ```python @@ -140,10 +155,61 @@ class DecodingKey: def from_ed_pem(pem: str | bytes) -> DecodingKey: ... @staticmethod - def from_jwk(jwk: str | Mapping[str, Any]) -> DecodingKey: ... + def from_jwk(jwk: str | bytes | Mapping[str, Any]) -> DecodingKey: ... +``` + +Use `DecodingKey` for verifying tokens. `from_jwk` accepts a Python dictionary, JSON string, or UTF-8 JSON bytes (with zero-copy single-pass parsing). + +## `PyJWK` + +```python +class PyJWK: + def __init__( + self, + jwk: Mapping[str, Any] | str | bytes, + algorithm: str | None = None, + ) -> None: ... + + @staticmethod + def from_dict(obj: Mapping[str, Any], algorithm: str | None = None) -> PyJWK: ... + + @staticmethod + def from_json(data: str | bytes, algorithm: str | None = None) -> PyJWK: ... + + @property + def key(self) -> DecodingKey: ... + + @property + def key_id(self) -> str | None: ... + + @property + def key_type(self) -> str | None: ... + + @property + def public_key_use(self) -> str | None: ... +``` + +Represents a single parsed JSON Web Key. The underlying `DecodingKey` is lazily materialized when `.key` is first accessed. Keys with `use: enc` are rejected with `PyJWKError`. + +## `PyJWKSet` + +```python +class PyJWKSet: + def __init__(self, keys: list[dict[str, Any]]) -> None: ... + + @staticmethod + def from_dict(obj: dict[str, Any]) -> PyJWKSet: ... + + @staticmethod + def from_json(data: str | bytes) -> PyJWKSet: ... + + @property + def keys(self) -> list[PyJWK]: ... + + def __getitem__(self, kid: str) -> PyJWK: ... ``` -Use `DecodingKey` for verifying tokens. +Represents a set of JWKs (RFC 7517). Supports fast $O(1)$ key lookup by `kid` via `keyset[kid]`. Keys designated for encryption are automatically skipped with `PyJWKSetSkipWarning`. ## `PyJWKClient` diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index c2a8e3f..c9594d9 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -2,7 +2,66 @@ ## Unreleased -(No changes yet.) +## 0.9.0 — 2026-10-17 + +Performance and security release: zero-copy streaming claims deserialization, +pre-encoded static headers, native fast-path claim validation in Rust, zero-copy +header algorithm extraction, direct JWK deserialization, RFC 7515/7797 strict crit +header enforcement, and public top-level `encode_json` export. + +### Security + +- **Strict rejection of unrecognized `crit` (Critical) header parameters.** + Under RFC 7515 §4.1.11 and RFC 7797, any header parameter listed in the `crit` + header array must be understood and processed. OxyJWT now strictly validates + `crit` on both verified and unverified decode paths: unrecognized critical + extensions, empty `crit` lists, malformed structures, or critical references + to standard reserved headers immediately raise `DecodeError` (#132). + +### Performance + +- **Pre-encoded static base64 headers and native fast-path claim verification.** + In `encode` and `encode_json`, tokens with standard default headers now use + pre-encoded immutable base64 header slices, eliminating runtime `Header` JSON + serialization and base64 allocation. In `decode`, standard claim validations + (`iat`, unrequested `aud` rejection, and `sub` type checks) now execute entirely + within the Rust core, allowing the fast-path decode to return the claims dictionary + directly without redundant Python-side re-checks (#150). +- **Eliminate triple JSON round-trip in `DecodingKey.from_jwk`.** + Materializing a decoding key from a JWK Python dictionary now bypasses intermediate + `serde_json::Value` DOM allocations and re-serialization. The dictionary is + serialized directly in C via `orjson.dumps`, and parsed into `jsonwebtoken::jwk::Jwk` + in a single pass. `DecodingKey.from_jwk` also accepts `&str`, `&[u8]` (`bytes`), + and `bytearray` natively with zero-copy deserialization. Benchmark speedup: + 10× faster for octet/HMAC keys (0.97 µs vs 9.66 µs) and 21× faster for RSA keys + (1.21 µs vs 25.48 µs) (#140). +- **Avoid redundant payload copying in `encode_json` and trim `py.detach` overhead.** + In `encode_json`, the JWS signing input is now built directly from the borrowed + payload byte slice without an owned `payload_bytes.to_vec()` heap clone. + Non-cryptographic sub-microsecond paths (`get_unverified_header` and + `decode_unverified`) now run synchronously attached to the GIL, eliminating + repeated `PyEval_SaveThread` / `PyEval_RestoreThread` cycles (#139). +- **Zero-copy header algorithm extraction & static header fast-path.** + Decode paths avoid full JSON DOM parsing when only the algorithm is needed. + Standard compact headers (`{"typ":"JWT","alg":"..."}`, `{"alg":"...","typ":"JWT"}`, + `{"alg":"..."}`) are recognized in a single string match without base64 or JSON + decoding. Non-standard headers extract `alg` via a streaming serde visitor + without allocating a `serde_json::Map` (#138). +- **Streaming claims deserialization into `PyDict`.** + Deserialization of claims in `decode_complete` directly streams serde JSON tokens + into a `PyDict` without allocating an intermediate `serde_json::Value` tree (#137). +- **Fast-path native validation for `aud`, `iss`, and `sub`.** + Extended the in-Rust fast-path decoder to validate audience, issuer, and subject + claims directly in Rust, reducing Python fallback overhead (#136). + +### Added + +- **Top-level `encode_json` export with full typing and parity coverage.** + `encode_json` is now exported in the public API root (`oxyjwt.encode_json`) and on + `PyJWT.encode_json`, allowing callers who already serialize payloads to JSON + (such as via orjson) to avoid redundant dictionary deserialization (#148). +- `DecodingKey.from_jwk`, `PyJWK`, and `PyJWKSet.from_json` now accept raw UTF-8 + `bytes` directly in addition to `str` and `Mapping[str, Any]`. ## 0.8.0 — 2026-09-28 diff --git a/docs-site/docs/security.md b/docs-site/docs/security.md index 82ed54a..092e308 100644 --- a/docs-site/docs/security.md +++ b/docs-site/docs/security.md @@ -95,6 +95,17 @@ When you pass a raw `str` / `bytes` HMAC secret (or use `EncodingKey.from_secret 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. +## Critical Header Parameter Protection (`crit`) + +Under RFC 7515 §4.1.11, any extension header specified in the `crit` (Critical) array MUST be understood and processed by the recipient. If an unrecognized header is listed, or if `crit` itself contains invalid types or empty lists, the token must be rejected. + +OxyJWT strictly enforces this rule in the native Rust core on **both verified and unverified decode paths**. This prevents attackers from bypassing security checks or injecting arbitrary extension claims into tokens. Only RFC 7797 unencoded payload (`b64`) is accepted if recognized; all unknown critical extensions trigger immediate rejection with `DecodeError`. + +## JWK Key Usage Restrictions (`use`) + +RFC 7517 distinguishes keys intended for digital signatures (`use: sig`) from keys intended for data encryption (`use: enc`). OxyJWT strictly refuses to materialize `use: enc` keys for signature verification across `PyJWK`, `PyJWKSet`, and `PyJWKClient`. Encrypted-use keys in JWKS sets are safely skipped with a `PyJWKSetSkipWarning`, preventing cross-purpose key misuse attacks. + + ## Treat Unverified Helpers As Inspection Only `get_unverified_header` and `decode_unverified` do not verify the signature and do not validate claims. diff --git a/docs-site/docs/usage/decoding.md b/docs-site/docs/usage/decoding.md index 792ec0c..4d3d2cf 100644 --- a/docs-site/docs/usage/decoding.md +++ b/docs-site/docs/usage/decoding.md @@ -145,6 +145,15 @@ Supported options: `decode_unverified` and `get_unverified_header` are for inspection and debugging only; they skip cryptographic verification entirely. +## Critical Header Parameters (`crit`) + +Under RFC 7515 §4.1.11 and RFC 7797, any header parameter listed in the `crit` header array must be understood and processed by the recipient. + +OxyJWT strictly enforces this requirement: +- When a token contains `b64` in `crit`, OxyJWT processes it according to RFC 7797 detached payload rules. +- If a token contains unrecognized critical header extensions, or if `crit` is empty, malformed, or references reserved standard headers, OxyJWT rejects the token with `DecodeError`. +- This protection applies to both verified `decode` and unverified helpers (`decode_unverified` and `decode_complete(options={"verify_signature": False})`) to prevent parser differential security bypasses. + ## Unverified Helpers These helpers do not authenticate a token: @@ -154,4 +163,5 @@ header = oxyjwt.get_unverified_header(token) claims = oxyjwt.decode_unverified(token) ``` -Use them for debugging, key lookup, or inspection flows. Never use their output to authorize a request. +Use them for debugging, key lookup, or inspection flows. Never use their output to authorize a request. Both helpers run synchronously attached to the GIL with sub-microsecond latency. + diff --git a/docs-site/docs/usage/encoding.md b/docs-site/docs/usage/encoding.md index 5cd0d76..8d8197d 100644 --- a/docs-site/docs/usage/encoding.md +++ b/docs-site/docs/usage/encoding.md @@ -97,3 +97,30 @@ token = oxyjwt.encode( OxyJWT does not allow `headers["alg"]` to override the `algorithm` argument. Supported built-in header fields: `typ`, `cty`, `kid`, `jku`, `x5u`, `x5t`, `x5t#S256`, `url`, and `nonce`. Any other **string** name is emitted as a custom JWS header parameter (for example `X-Trace-Id`). Custom values must be strings; complex fields such as `jwk` and `x5c` are not accepted in `headers`. + +## High-Throughput `encode_json` + +When your application already holds a pre-serialized JSON string or UTF-8 bytes (for example, produced by `orjson.dumps()`, cached payloads, or streaming pipelines), use `oxyjwt.encode_json` to bypass dictionary serialization overhead in Python: + +```python +import orjson +import oxyjwt + +# Pre-serialized payload bytes +payload_bytes = orjson.dumps({ + "sub": "user-123", + "role": "admin", + "exp": 1893456000, +}) + +# Directly sign without re-serializing +token = oxyjwt.encode_json( + payload_bytes, + "super-secret", + algorithm="HS256", + headers={"kid": "key-1"}, +) +``` + +`encode_json` accepts `payload_json` as either `str` or `bytes`. For HMAC signing, the Rust core borrows the byte slice directly to construct the JWS signing input with zero redundant heap copying. + diff --git a/docs-site/docs/usage/keys.md b/docs-site/docs/usage/keys.md index 03bd6e8..a47ca14 100644 --- a/docs-site/docs/usage/keys.md +++ b/docs-site/docs/usage/keys.md @@ -43,14 +43,42 @@ The signing side usually uses a private key. The verification side usually uses ## JWK Verification -`DecodingKey.from_jwk` creates a verification key from a JSON Web Key: +`DecodingKey.from_jwk` creates a verification key from a JSON Web Key (RFC 7517). It accepts: +- a Python `dict` / `Mapping[str, Any]` (for example `{"kty": "RSA", "n": "...", "e": "AQAB"}`) +- a JSON `str` +- raw UTF-8 JSON `bytes` or `bytearray` ```python -verification_key = oxyjwt.DecodingKey.from_jwk(jwk) +# Direct from dictionary +verification_key = oxyjwt.DecodingKey.from_jwk(jwk_dict) + +# Direct from raw JSON bytes (zero-copy single-pass parse) +verification_key = oxyjwt.DecodingKey.from_jwk(b'{"kty":"RSA","n":"...","e":"AQAB"}') + claims = oxyjwt.decode(token, verification_key, algorithms=["RS256"]) ``` -The current API supports JWK for decoding. Encoding uses explicit secret or PEM constructors. +### `PyJWK` and `PyJWKSet` + +For PyJWT-compatible workflows, use `PyJWK` and `PyJWKSet`: + +```python +from oxyjwt import PyJWK, PyJWKSet + +# Single JWK from dict, JSON str, or raw bytes +jwk = PyJWK.from_dict({"kty": "RSA", "kid": "k1", "n": "...", "e": "AQAB"}) +# Or from JSON: PyJWK.from_json(jwks_json_str_or_bytes) + +claims = oxyjwt.decode(token, jwk.key, algorithms=["RS256"]) + +# JWKS Keyset +keyset = PyJWKSet.from_dict({"keys": [jwk_dict]}) +signing_key = keyset["k1"] # lookup by kid +claims = oxyjwt.decode(token, signing_key.key, algorithms=["RS256"]) +``` + +`PyJWKSet` lazily materializes `DecodingKey` instances only for keys that are actually accessed, and automatically ignores keys designated for encryption (`use: enc`). + ## Why Raw Keys Are HMAC-Only diff --git a/pyproject.toml b/pyproject.toml index d9d71cd..7df8598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "oxyjwt" -version = "0.8.0" +version = "0.9.0" description = "High-performance Python JWT/JWS library backed by a Rust core (PyJWT drop-in replacement)." readme = "README.md" requires-python = ">=3.10" diff --git a/python/oxyjwt/__init__.py b/python/oxyjwt/__init__.py index 4f76876..ffa8036 100644 --- a/python/oxyjwt/__init__.py +++ b/python/oxyjwt/__init__.py @@ -1,6 +1,6 @@ """OxyJWT public API (PyJWT-shaped module surface).""" -__version__ = "0.8.0" +__version__ = "0.9.0" from ._oxyjwt import ( DecodingKey, @@ -8,7 +8,7 @@ decode_unverified, get_unverified_header, ) -from .api_jwt import PyJWT, decode, decode_complete, encode +from .api_jwt import PyJWT, decode, decode_complete, encode, encode_json from .exceptions import ( DecodeError, EncodeError, @@ -74,5 +74,6 @@ "decode_complete", "decode_unverified", "encode", + "encode_json", "get_unverified_header", ] diff --git a/python/oxyjwt/_oxyjwt.pyi b/python/oxyjwt/_oxyjwt.pyi index af9e74f..f19b116 100644 --- a/python/oxyjwt/_oxyjwt.pyi +++ b/python/oxyjwt/_oxyjwt.pyi @@ -42,7 +42,7 @@ class DecodingKey: @staticmethod def from_ed_pem(pem: str | bytes) -> DecodingKey: ... @staticmethod - def from_jwk(jwk: str | Mapping[str, Any]) -> DecodingKey: ... + def from_jwk(jwk: str | bytes | Mapping[str, Any]) -> DecodingKey: ... def encode( diff --git a/python/oxyjwt/api_jwt.py b/python/oxyjwt/api_jwt.py index ecfd86c..15ed753 100644 --- a/python/oxyjwt/api_jwt.py +++ b/python/oxyjwt/api_jwt.py @@ -173,6 +173,16 @@ def encode( ) return _oxyjwt.encode_json(body_b, key, alg, headers) + def encode_json( + self, + payload_json: str | bytes, + key: object, + algorithm: str | None = "HS256", + headers: dict[str, Any] | None = None, + ) -> str: + alg = algorithm if algorithm is not None else "HS256" + return _oxyjwt.encode_json(payload_json, key, alg, headers) + def decode( self, jwt: str | bytes, @@ -202,15 +212,15 @@ def decode( self._default_options and not kwargs and options is None - and audience is None - and issuer is None - and subject is None and typ is None and detached_payload is None and verify is None and leeway == 0 and bool(algorithms) and isinstance(algorithms, (list, tuple)) + and (audience is None or isinstance(audience, (str, list, tuple))) + and (issuer is None or isinstance(issuer, (str, list, tuple))) + and (subject is None or isinstance(subject, str)) ): token = jwt if isinstance(jwt, str) else jwt.decode("utf-8") # Inlined `_require_detached_payload_for_rfc7797` for the same @@ -229,9 +239,14 @@ def decode( '"detached_payload" argument to decode a message ' "having the b64 header set to false." ) - payload = _oxyjwt.decode(token, key, algorithms) - self._validate_claims_default(payload) - return payload + return _oxyjwt.decode( + token, + key, + algorithms, + audience=audience, + issuer=issuer, + subject=subject, + ) if kwargs: warnings.warn( "passing additional kwargs to decode() is deprecated " @@ -276,15 +291,15 @@ def decode_complete( self._default_options and not kwargs and options is None - and audience is None - and issuer is None - and subject is None and typ is None and detached_payload is None and verify is None and leeway == 0 and bool(algorithms) and isinstance(algorithms, (list, tuple)) + and (audience is None or isinstance(audience, (str, list, tuple))) + and (issuer is None or isinstance(issuer, (str, list, tuple))) + and (subject is None or isinstance(subject, str)) ): token = jwt if isinstance(jwt, str) else jwt.decode("utf-8") if ".." in token: @@ -298,9 +313,13 @@ def decode_complete( "having the b64 header set to false." ) payload, header, signature = _oxyjwt.decode_verified_complete( - token, key, algorithms + token, + key, + algorithms, + audience=audience, + issuer=issuer, + subject=subject, ) - self._validate_claims_default(payload) return {"payload": payload, "header": header, "signature": signature} if kwargs: first = next(iter(kwargs)) @@ -456,7 +475,13 @@ def decode_complete( "signature": _sig_as_bytes(sigb), } - def _validate_claims_default(self, payload: dict[str, Any]) -> None: + def _validate_claims_default( + self, + payload: dict[str, Any], + *, + audience_checked: bool = False, + subject_checked: bool = False, + ) -> None: """Claim checks left to Python after a plain verified decode. Equivalent to :meth:`_validate_claims` with default options, no @@ -501,12 +526,13 @@ def _validate_claims_default(self, payload: dict[str, Any]) -> None: if exp_value <= now: raise ExpiredSignatureError("Signature has expired") - if payload.get("aud"): + if not audience_checked and payload.get("aud"): raise InvalidAudienceError("Invalid audience") - sub = payload.get("sub", _MISSING) - if sub is not _MISSING and not isinstance(sub, str): - raise InvalidSubjectError("Subject must be a string") + if not subject_checked: + sub = payload.get("sub", _MISSING) + if sub is not _MISSING and not isinstance(sub, str): + raise InvalidSubjectError("Subject must be a string") def _validate_claims( self, @@ -532,30 +558,24 @@ def _validate_claims( """ self._validate_required(payload, options) now = time.time() - if "iat" in payload and options.get("verify_iat", True): - self._validate_iat_fields(payload, now, leeway) if not rust_time_claims: + if "iat" in payload and options.get("verify_iat", True): + self._validate_iat_fields(payload, now, leeway) if "nbf" in payload and options.get("verify_nbf", True): 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 "exp" in payload and options.get("verify_exp", True): + self._validate_exp_fields(payload, now, leeway) strict_aud = bool(options.get("strict_aud", False)) if options.get("verify_aud", True): - if strict_aud or not ( - rust_standard_claims and audience is not None - ): + if strict_aud or not rust_standard_claims: self._validate_aud_field( payload, audience, strict=strict_aud, ) - if options.get("verify_iss", True) and ( - issuer is not None or not rust_standard_claims - ): + if options.get("verify_iss", True) and not rust_standard_claims: self._validate_iss_field(payload, issuer) - if options.get("verify_sub", True) and not ( - rust_standard_claims and subject is not None - ): + if options.get("verify_sub", True) and not rust_standard_claims: self._validate_sub_field(payload, subject) @staticmethod @@ -664,6 +684,20 @@ def _validate_aud_field( def _validate_headers( header: dict[str, Any], options: dict[str, Any] ) -> None: + if "crit" in header: + crit = header["crit"] + if not isinstance(crit, list) or len(crit) == 0: + raise InvalidTokenError( + "The 'crit' header parameter must be an array of strings" + ) + for param in crit: + if not isinstance(param, str): + raise InvalidTokenError("Items in 'crit' header must be strings") + if param == "b64" and header.get("b64") is False: + continue + raise InvalidTokenError( + f"Unsupported critical header parameter in 'crit': '{param}'" + ) if options.get("verify_typ", False) or "typ" in options: expected_typ = options.get("typ") actual_typ = header.get("typ") @@ -678,5 +712,6 @@ def _validate_headers( _jwt = PyJWT() encode = _jwt.encode +encode_json = _jwt.encode_json decode = _jwt.decode decode_complete = _jwt.decode_complete diff --git a/python/oxyjwt/api_jwt.pyi b/python/oxyjwt/api_jwt.pyi index 65b7343..fe419f5 100644 --- a/python/oxyjwt/api_jwt.pyi +++ b/python/oxyjwt/api_jwt.pyi @@ -20,6 +20,13 @@ class PyJWT: json_encoder: type[JSONEncoder] | None = None, sort_headers: bool = True, ) -> str: ... + def encode_json( + self, + payload_json: str | bytes, + key: object, + algorithm: str | None = "HS256", + headers: dict[str, Any] | None = None, + ) -> str: ... def decode( self, jwt: str | bytes, @@ -59,6 +66,12 @@ def encode( json_encoder: type[JSONEncoder] | None = None, sort_headers: bool = True, ) -> str: ... +def encode_json( + payload_json: str | bytes, + key: object, + algorithm: str | None = "HS256", + headers: dict[str, Any] | None = None, +) -> str: ... def decode( jwt: str | bytes, key: object = "", diff --git a/python/oxyjwt/jwk.py b/python/oxyjwt/jwk.py index 824230d..a0df838 100644 --- a/python/oxyjwt/jwk.py +++ b/python/oxyjwt/jwk.py @@ -17,9 +17,9 @@ from oxyjwt.warnings import PyJWKSetSkipWarning -def _as_dict(jwk: Mapping[str, Any] | str) -> dict[str, Any]: - if isinstance(jwk, str): - return cast("dict[str, Any]", orjson.loads(jwk.encode("utf-8"))) +def _as_dict(jwk: Mapping[str, Any] | str | bytes) -> dict[str, Any]: + if isinstance(jwk, (str, bytes)): + return cast("dict[str, Any]", orjson.loads(jwk)) return dict(jwk) @@ -32,7 +32,7 @@ def _reject_encryption_jwk(data: dict[str, Any]) -> None: class PyJWK: - def __init__(self, jwk: Mapping[str, Any] | str, algorithm: str | None = None) -> None: + def __init__(self, jwk: Mapping[str, Any] | str | bytes, algorithm: str | None = None) -> None: data = _as_dict(jwk) if not data.get("kty"): raise InvalidKeyError(f"kty is not found: {data!r}") @@ -60,8 +60,8 @@ def from_dict(obj: Mapping[str, Any], algorithm: str | None = None) -> PyJWK: return PyJWK(obj, algorithm) @staticmethod - def from_json(data: str, algorithm: str | None = None) -> PyJWK: - return PyJWK.from_dict(orjson.loads(data.encode("utf-8")), algorithm) + def from_json(data: str | bytes, algorithm: str | None = None) -> PyJWK: + return PyJWK.from_dict(orjson.loads(data), algorithm) @property def key_type(self) -> str | None: @@ -147,8 +147,8 @@ def from_dict(obj: dict[str, Any]) -> PyJWKSet: return PyJWKSet([x for x in ks if isinstance(x, dict)]) @staticmethod - def from_json(data: str) -> PyJWKSet: - return PyJWKSet.from_dict(orjson.loads(data.encode("utf-8"))) + def from_json(data: str | bytes) -> PyJWKSet: + return PyJWKSet.from_dict(orjson.loads(data)) def __getitem__(self, kid: str) -> PyJWK: if kid in self._materialized: diff --git a/python/oxyjwt/jwks_client.py b/python/oxyjwt/jwks_client.py index c13ff81..4247f49 100644 --- a/python/oxyjwt/jwks_client.py +++ b/python/oxyjwt/jwks_client.py @@ -7,7 +7,7 @@ import urllib.error import urllib.request from collections import OrderedDict -from typing import Any, Mapping +from typing import Any, Mapping, cast from urllib.parse import urlparse import orjson @@ -42,14 +42,6 @@ def _merge_request_headers(extra: Mapping[str, Any] | None) -> dict[str, str]: return headers -def _header_to_plain_dict(obj: Any) -> dict[str, Any]: - raw = orjson.dumps(obj, default=str) - out = orjson.loads(raw) - if not isinstance(out, dict): - raise TypeError("expected JSON object for JWT header") - return out - - class PyJWKClient: def __init__( self, @@ -221,9 +213,7 @@ def get_signing_key_from_jwt( algorithms: list[str] | None = None, ) -> PyJWK: token = jwt if isinstance(jwt, str) else jwt.decode("utf-8") - header: dict[str, Any] = _oxyjwt.get_unverified_header(token) # type: ignore[assignment] - if not isinstance(header, dict): - header = _header_to_plain_dict(header) + header = cast("dict[str, Any]", _oxyjwt.get_unverified_header(token)) if algorithms is not None: if not algorithms: raise InvalidAlgorithmError( diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4bcb91a..f129228 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -496,12 +496,13 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oxyjwt" -version = "0.8.0" +version = "0.9.0" dependencies = [ "aws-lc-rs", "base64", "jsonwebtoken", "pyo3", + "serde", "serde_json", "zeroize", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5717ed8..a7cf9bf 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxyjwt" -version = "0.8.0" +version = "0.9.0" edition = "2021" rust-version = "1.85" license = "MIT" @@ -19,6 +19,7 @@ aws_lc_rs = ["jsonwebtoken/aws_lc_rs", "dep:aws-lc-rs"] base64 = "0.22" jsonwebtoken = { version = "10", default-features = false, features = ["use_pem"] } pyo3 = { version = "0.28", features = ["abi3-py310"] } +serde = "1" serde_json = "1" zeroize = "1" diff --git a/rust/src/api.rs b/rust/src/api.rs index 04a620a..727327c 100644 --- a/rust/src/api.rs +++ b/rust/src/api.rs @@ -1,9 +1,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, encode as jwt_encode, DecodingKey, Header, -}; +use jsonwebtoken::errors::ErrorKind; +use jsonwebtoken::{crypto::verify as jwt_crypto_verify, DecodingKey, Header}; use pyo3::prelude::*; use pyo3::types::PyBytes; use serde_json::Value; @@ -12,9 +10,9 @@ use crate::algorithms::{ algorithm_family, algorithm_name, ensure_single_family, parse_algorithm, parse_algorithm_name, KeyFamily, }; -use crate::claims::{json_to_py, py_to_json_for_encode}; +use crate::claims::{deserialize_claims_dict, json_to_py, py_to_json_for_encode}; use crate::claims_validate; -use crate::errors; +use crate::errors::{self, decode_fail, map_decode_fail, DecodeFail}; use crate::jws; use crate::keys::{decoding_key_from_py, encoding_key_from_py}; use crate::validation::{self, DecodeValidation}; @@ -29,38 +27,10 @@ use aws_lc_rs::signature::{ RSA_PSS_SHA384, RSA_PSS_SHA512, }; -/// Failure from a decode step that runs with the GIL released. -/// -/// Keeping the variants typed (instead of matching on message text) means the -/// Python exception class is chosen from the failure itself. -enum DecodeFail { - Jwt(jsonwebtoken::errors::Error), - Decode(String), - Token(String), -} - -impl From for DecodeFail { - fn from(err: jsonwebtoken::errors::Error) -> Self { - DecodeFail::Jwt(err) - } -} - -fn decode_fail(kind: ErrorKind) -> DecodeFail { - DecodeFail::Jwt(new_error(kind)) -} - -fn map_decode_fail(fail: DecodeFail) -> PyErr { - match fail { - DecodeFail::Jwt(err) => errors::from_jwt_decode_error(err), - DecodeFail::Decode(message) => errors::decode_error(message), - DecodeFail::Token(message) => errors::invalid_token(message), - } -} - -/// Verified claims plus the protected header, both parsed exactly once. -struct VerifiedToken { - header: Value, - claims: Value, +struct VerifiedTokenParts { + header: Option, + payload: Vec, + signature: Option>, } /// Verify a compact JWT and parse it in a single pass, optionally also @@ -72,30 +42,36 @@ struct VerifiedToken { /// 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 -/// a `Header` struct that we would have to re-serialize to hand back to Python. -/// 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_impl( token: &str, decoding_key: &DecodingKey, decode_validation: &DecodeValidation, + with_header: bool, with_signature: bool, -) -> Result<(VerifiedToken, Option>), DecodeFail> { +) -> Result { let (header_segment, payload_segment, signature_segment) = jws::split_compact_segments(token).map_err(DecodeFail::Decode)?; - let header = jws::decode_header_json(header_segment).map_err(DecodeFail::Decode)?; - let algorithm = header_algorithm(&header)?; - if !decode_validation.algorithms().contains(&algorithm) { - return Err(decode_fail(ErrorKind::InvalidAlgorithm)); - } - // jsonwebtoken rejects an allow-list spanning several key families; keys - // built from a JWK skip the equivalent check in `keys.rs`. - ensure_single_family_for_decode(decode_validation.algorithms())?; + let (header, algorithm) = if with_header { + let header = jws::decode_header_json(header_segment).map_err(DecodeFail::Decode)?; + let algorithm = header_algorithm(&header)?; + if !decode_validation.algorithms().contains(&algorithm) { + return Err(decode_fail(ErrorKind::InvalidAlgorithm)); + } + ensure_single_family_for_decode(decode_validation.algorithms())?; + jws::validate_crit_header(&header, &[]).map_err(DecodeFail::Token)?; + (Some(header), algorithm) + } else { + let summary = jws::resolve_header_alg_and_crit(header_segment)?; + if !decode_validation.algorithms().contains(&summary.algorithm) { + return Err(decode_fail(ErrorKind::InvalidAlgorithm)); + } + ensure_single_family_for_decode(decode_validation.algorithms())?; + if let Some(crit_val) = summary.crit { + jws::validate_crit_value(&crit_val, &[]).map_err(DecodeFail::Token)?; + } + (None, summary.algorithm) + }; let signing_input = jws::signing_input_of(token, header_segment, payload_segment); if !jwt_crypto_verify(signature_segment, signing_input, decoding_key, algorithm)? { @@ -110,38 +86,35 @@ fn verify_and_parse_impl( let payload = URL_SAFE_NO_PAD .decode(payload_segment) .map_err(|err| DecodeFail::Decode(err.to_string()))?; - let claims: Value = - serde_json::from_slice(&payload).map_err(|err| DecodeFail::Decode(err.to_string()))?; - if !claims.is_object() { - return Err(DecodeFail::Decode( - "Invalid payload string: must be a json object".to_owned(), - )); - } - - claims_validate::validate_claims_value(&claims, &decode_validation.validation)?; - Ok((VerifiedToken { header, claims }, signature)) + Ok(VerifiedTokenParts { + header, + payload, + 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) +) -> Result, DecodeFail> { + let parts = verify_and_parse_impl(token, decoding_key, decode_validation, false, false)?; + Ok(parts.payload) } 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)?; +) -> Result<(Value, Vec, Vec), DecodeFail> { + let parts = verify_and_parse_impl(token, decoding_key, decode_validation, true, true)?; Ok(( - verified, - signature.expect("with_signature=true always returns Some"), + parts.header.expect("with_header=true always returns Some"), + parts.payload, + parts + .signature + .expect("with_signature=true always returns Some"), )) } @@ -217,6 +190,17 @@ fn signing_input_string(header: &[u8], payload: &[u8]) -> String { signing_input } +/// Base64url-encode `payload` appending to a pre-encoded base64 `header_b64` directly into one pre-sized +/// `header.payload` string, skipping header base64 encoding and allocation. +fn signing_input_with_header_b64(header_b64: &str, payload: &[u8]) -> String { + let mut signing_input = + String::with_capacity(header_b64.len() + 1 + base64_len_no_pad(payload.len())); + signing_input.push_str(header_b64); + 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 { @@ -256,13 +240,11 @@ fn sign_with_cached_rsa_key( /// Build a compact JWS (`header.payload.signature`) from already base64url-ready /// JSON bytes, signing with a cached, pre-parsed RSA key. #[cfg(feature = "aws_lc_rs")] -fn sign_compact_with_cached_rsa( - header_json: &[u8], - payload_json: &[u8], +fn sign_compact_token_with_cached_rsa( + mut token: String, algorithm: jsonwebtoken::Algorithm, key_pair: &RsaKeyPair, ) -> PyResult { - 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); @@ -284,27 +266,37 @@ pub fn encode( return Err(errors::encode_error("payload must be a JSON object")); } - let mut header = Header::new(algorithm); - apply_headers(&mut header, headers, algorithm)?; + let claims_json = serde_json::to_vec(&claims) + .map_err(|e| errors::encode_error(format!("failed to serialize claims: {e}")))?; - #[cfg(feature = "aws_lc_rs")] - if let Some(rsa_key) = cached_rsa_encoding_key_from_py(key, algorithm)? { + let has_custom_headers = headers.is_some_and(|h| !h.is_none()); + + let mut token = if !has_custom_headers { + let header_b64 = jws::standard_header_base64(algorithm); + signing_input_with_header_b64(header_b64, &claims_json) + } else { + let mut header = Header::new(algorithm); + apply_headers(&mut header, headers, algorithm)?; let header_json = serde_json::to_vec(&header) .map_err(|e| errors::encode_error(format!("failed to serialize header: {e}")))?; - let claims_json = serde_json::to_vec(&claims) - .map_err(|e| errors::encode_error(format!("failed to serialize claims: {e}")))?; - return py.detach(move || { - sign_compact_with_cached_rsa(&header_json, &claims_json, algorithm, rsa_key) - }); + signing_input_string(&header_json, &claims_json) + }; + + #[cfg(feature = "aws_lc_rs")] + if let Some(rsa_key) = cached_rsa_encoding_key_from_py(key, algorithm)? { + return py.detach(move || sign_compact_token_with_cached_rsa(token, algorithm, rsa_key)); } let encoding_key = encoding_key_from_py(key, algorithm)?; let skip_detach = algorithm_family(algorithm) == KeyFamily::Hmac; - maybe_detach(py, skip_detach, || { - jwt_encode(&header, &claims, &encoding_key) + maybe_detach(py, skip_detach, move || { + 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) }) - .map_err(errors::from_jwt_encode_error) } #[pyfunction] @@ -339,12 +331,18 @@ pub fn decode( let decoding_key = decoding_key_from_py(key, decode_validation.algorithms())?; let skip_detach = algorithms_are_all_hmac(decode_validation.algorithms()); - let verified = maybe_detach(py, skip_detach, || { + let payload = maybe_detach(py, skip_detach, || { verify_and_parse(token, &decoding_key, &decode_validation) }) .map_err(map_decode_fail)?; - json_to_py(py, &verified.claims) + let claims = deserialize_claims_dict(py, &payload) + .map_err(DecodeFail::Decode) + .map_err(map_decode_fail)?; + + claims_validate::validate_claims_dict(&claims, &decode_validation)?; + + Ok(claims.into_any().unbind()) } type DecodeVerifiedCompleteOutput = (Py, Py, Py); @@ -395,15 +393,20 @@ pub fn decode_verified_complete( } let skip_detach = algorithms_are_all_hmac(decode_validation.algorithms()); - let (verified, signature) = maybe_detach(py, skip_detach, || { + let (header, payload, 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)?; - let header_py = json_to_py(py, &verified.header)?; + let claims = deserialize_claims_dict(py, &payload) + .map_err(DecodeFail::Decode) + .map_err(map_decode_fail)?; + + claims_validate::validate_claims_dict(&claims, &decode_validation)?; + + let header_py = json_to_py(py, &header)?; let sig_py = PyBytes::new(py, &signature); - Ok((claims_py, header_py, sig_py.into())) + Ok((claims.into_any().unbind(), header_py, sig_py.into())) } fn decode_rfc7797_verified_complete( @@ -421,15 +424,8 @@ fn decode_rfc7797_verified_complete( } let skip_detach = algorithms_are_all_hmac(decode_validation.algorithms()); - let (parts, claims, signature) = maybe_detach(py, skip_detach, || -> Result<_, DecodeFail> { + let (parts, signature) = maybe_detach(py, skip_detach, || -> Result<_, DecodeFail> { let parts = jws::parse_rfc7797_compact(token).map_err(DecodeFail::Token)?; - let claims: Value = serde_json::from_slice(detached_payload) - .map_err(|err| DecodeFail::Decode(format!("Invalid payload string: {err}")))?; - if !claims.is_object() { - return Err(DecodeFail::Decode( - "Invalid payload string: must be a json object".to_owned(), - )); - } let algorithm = header_algorithm(&parts.header)?; if !decode_validation.algorithms().contains(&algorithm) { @@ -447,51 +443,43 @@ fn decode_rfc7797_verified_complete( return Err(decode_fail(ErrorKind::InvalidSignature)); } - claims_validate::validate_claims_value(&claims, &decode_validation.validation)?; - let signature = URL_SAFE_NO_PAD .decode(&parts.signature_segment) .map_err(|err| DecodeFail::Decode(err.to_string()))?; - Ok((parts, claims, signature)) + Ok((parts, signature)) }) .map_err(map_decode_fail)?; + let claims = deserialize_claims_dict(py, detached_payload) + .map_err(DecodeFail::Decode) + .map_err(map_decode_fail)?; + + claims_validate::validate_claims_dict(&claims, decode_validation)?; + let header_py = json_to_py(py, &parts.header)?; - let claims_py = json_to_py(py, &claims)?; let sig_py = PyBytes::new(py, &signature); - Ok((claims_py, header_py, sig_py.into())) + Ok((claims.into_any().unbind(), header_py, sig_py.into())) } #[pyfunction] pub fn get_unverified_header(py: Python<'_>, token: &str) -> PyResult> { - // `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)) - .map_err(errors::decode_error)?; - + let header = jws::parse_compact_header_json(token).map_err(errors::decode_error)?; json_to_py(py, &header) } #[pyfunction] pub fn decode_unverified(py: Python<'_>, token: &str) -> PyResult> { - // `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, &claims) + let (header_segment, payload_segment, _) = + jws::split_compact_segments(token).map_err(errors::decode_error)?; + if jws::match_standard_header(header_segment).is_none() { + jws::decode_header_json(header_segment).map_err(errors::decode_error)?; + } + let payload = URL_SAFE_NO_PAD + .decode(payload_segment) + .map_err(|e| errors::decode_error(e.to_string()))?; + + let claims = deserialize_claims_dict(py, &payload).map_err(errors::decode_error)?; + Ok(claims.into_any().unbind()) } fn apply_headers( @@ -603,26 +591,29 @@ pub fn encode_json( ) -> PyResult { let payload_bytes = extract_payload_object_bytes(payload_json)?; let algorithm = parse_algorithm(algorithm)?; - let mut header = Header::new(algorithm); - apply_headers(&mut header, headers, algorithm)?; - let header_json = serde_json::to_vec(&header) - .map_err(|e| errors::encode_error(format!("failed to serialize header: {e}")))?; + let has_custom_headers = headers.is_some_and(|h| !h.is_none()); - let payload_owned = payload_bytes.to_vec(); + let mut token = if !has_custom_headers { + let header_b64 = jws::standard_header_base64(algorithm); + signing_input_with_header_b64(header_b64, payload_bytes) + } else { + let mut header = Header::new(algorithm); + apply_headers(&mut header, headers, algorithm)?; + let header_json = serde_json::to_vec(&header) + .map_err(|e| errors::encode_error(format!("failed to serialize header: {e}")))?; + signing_input_string(&header_json, payload_bytes) + }; #[cfg(feature = "aws_lc_rs")] if let Some(rsa_key) = cached_rsa_encoding_key_from_py(key, algorithm)? { - return py.detach(move || { - sign_compact_with_cached_rsa(&header_json, &payload_owned, algorithm, rsa_key) - }); + return py.detach(move || sign_compact_token_with_cached_rsa(token, algorithm, rsa_key)); } let encoding_key = encoding_key_from_py(key, algorithm)?; let skip_detach = algorithm_family(algorithm) == KeyFamily::Hmac; maybe_detach(py, skip_detach, move || { - 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('.'); diff --git a/rust/src/claims.rs b/rust/src/claims.rs index 266ee1b..2f494c0 100644 --- a/rust/src/claims.rs +++ b/rust/src/claims.rs @@ -1,7 +1,9 @@ use pyo3::prelude::*; use pyo3::types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple}; +use serde::de::{self, DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor}; use serde_json::Value; use serde_json::{Map, Number}; +use std::borrow::Cow; use crate::errors; @@ -9,10 +11,6 @@ pub fn py_to_json_for_encode(value: &Bound<'_, PyAny>) -> PyResult { py_to_json(value).map_err(|err| errors::encode_error(err.to_string())) } -pub fn py_to_json_for_decode(value: &Bound<'_, PyAny>) -> PyResult { - py_to_json(value).map_err(|err| errors::decode_error(err.to_string())) -} - pub fn json_to_py(py: Python<'_>, value: &Value) -> PyResult> { json_to_bound(py, value).map(|value| value.unbind()) } @@ -115,6 +113,20 @@ fn claim_key<'py>(py: Python<'py>, key: &str) -> Bound<'py, PyString> { "alg" => pyo3::intern!(py, "alg").clone(), "typ" => pyo3::intern!(py, "typ").clone(), "kid" => pyo3::intern!(py, "kid").clone(), + "name" => pyo3::intern!(py, "name").clone(), + "email" => pyo3::intern!(py, "email").clone(), + "scope" => pyo3::intern!(py, "scope").clone(), + "scopes" => pyo3::intern!(py, "scopes").clone(), + "roles" => pyo3::intern!(py, "roles").clone(), + "permissions" => pyo3::intern!(py, "permissions").clone(), + "client_id" => pyo3::intern!(py, "client_id").clone(), + "azp" => pyo3::intern!(py, "azp").clone(), + "nonce" => pyo3::intern!(py, "nonce").clone(), + "auth_time" => pyo3::intern!(py, "auth_time").clone(), + "user_id" => pyo3::intern!(py, "user_id").clone(), + "metadata" => pyo3::intern!(py, "metadata").clone(), + "data" => pyo3::intern!(py, "data").clone(), + "id" => pyo3::intern!(py, "id").clone(), _ => PyString::new(py, key), } } @@ -152,3 +164,150 @@ fn json_to_bound<'py>(py: Python<'py>, value: &Value) -> PyResult { + py: Python<'py>, +} + +impl<'de, 'py> DeserializeSeed<'de> for PyAnySeed<'py> { + type Value = Bound<'py, PyAny>; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(PyAnyVisitor { py: self.py }) + } +} + +struct PyAnyVisitor<'py> { + py: Python<'py>, +} + +impl<'de, 'py> Visitor<'de> for PyAnyVisitor<'py> { + type Value = Bound<'py, PyAny>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("any valid JSON value") + } + + #[inline] + fn visit_bool(self, v: bool) -> Result { + Ok(PyBool::new(self.py, v).to_owned().into_any()) + } + + #[inline] + fn visit_i64(self, v: i64) -> Result + where + E: de::Error, + { + v.into_pyobject(self.py) + .map(|obj| obj.into_any()) + .map_err(de::Error::custom) + } + + #[inline] + fn visit_u64(self, v: u64) -> Result + where + E: de::Error, + { + v.into_pyobject(self.py) + .map(|obj| obj.into_any()) + .map_err(de::Error::custom) + } + + #[inline] + fn visit_i128(self, v: i128) -> Result + where + E: de::Error, + { + v.into_pyobject(self.py) + .map(|obj| obj.into_any()) + .map_err(de::Error::custom) + } + + #[inline] + fn visit_u128(self, v: u128) -> Result + where + E: de::Error, + { + v.into_pyobject(self.py) + .map(|obj| obj.into_any()) + .map_err(de::Error::custom) + } + + #[inline] + fn visit_f64(self, v: f64) -> Result + where + E: de::Error, + { + PyFloat::new(self.py, v) + .into_pyobject(self.py) + .map(|obj| obj.into_any()) + .map_err(de::Error::custom) + } + + #[inline] + fn visit_str(self, v: &str) -> Result { + Ok(PyString::new(self.py, v).into_any()) + } + + #[inline] + fn visit_borrowed_str(self, v: &'de str) -> Result { + Ok(PyString::new(self.py, v).into_any()) + } + + #[inline] + fn visit_none(self) -> Result { + Ok(self.py.None().into_bound(self.py)) + } + + #[inline] + fn visit_unit(self) -> Result { + Ok(self.py.None().into_bound(self.py)) + } + + #[inline] + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut elements = match seq.size_hint() { + Some(len) => Vec::with_capacity(len), + None => Vec::new(), + }; + while let Some(elem) = seq.next_element_seed(PyAnySeed { py: self.py })? { + elements.push(elem); + } + PyList::new(self.py, elements) + .map(|list| list.into_any()) + .map_err(de::Error::custom) + } + + #[inline] + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let dict = PyDict::new(self.py); + while let Some(key) = map.next_key::>()? { + let py_key = claim_key(self.py, &key); + let val = map.next_value_seed(PyAnySeed { py: self.py })?; + dict.set_item(py_key, val).map_err(de::Error::custom)?; + } + Ok(dict.into_any()) + } +} + +pub fn deserialize_claims_dict<'py>( + py: Python<'py>, + bytes: &[u8], +) -> Result, String> { + let mut de = serde_json::Deserializer::from_slice(bytes); + let obj = de + .deserialize_any(PyAnyVisitor { py }) + .map_err(|e| e.to_string())?; + de.end().map_err(|e| e.to_string())?; + obj.cast_into::() + .map_err(|_| "Invalid payload string: must be a json object".to_string()) +} diff --git a/rust/src/claims_validate.rs b/rust/src/claims_validate.rs index 84a409b..a332b7a 100644 --- a/rust/src/claims_validate.rs +++ b/rust/src/claims_validate.rs @@ -1,10 +1,12 @@ -//! 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; +use jsonwebtoken::errors::{new_error, ErrorKind}; +use jsonwebtoken::get_current_timestamp; +use pyo3::prelude::*; +use pyo3::types::{PyBool, PyDict, PyList, PyString}; + +use crate::errors; +use crate::validation::DecodeValidation; /// Spec claims that `required_spec_claims` can hold, in a fixed order. /// @@ -12,7 +14,7 @@ use serde_json::Value; /// makes the reported missing claim depend on hash seeding and therefore differ /// between processes. Walking a constant list instead keeps the error stable and /// skips the set iteration when nothing is required. -const REQUIRABLE_SPEC_CLAIMS: [&str; 5] = ["aud", "exp", "iss", "nbf", "sub"]; +const REQUIRABLE_SPEC_CLAIMS: [&str; 6] = ["aud", "exp", "iat", "iss", "nbf", "sub"]; enum NumericClaim { Missing, @@ -20,66 +22,100 @@ enum NumericClaim { Value(u64), } -fn parse_numeric_claim(value: Option<&Value>) -> NumericClaim { +fn parse_numeric_claim_py(value: Option>) -> 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) + if value.is_none() { + return NumericClaim::Invalid; + } + if value.is_instance_of::() { + return NumericClaim::Invalid; + } + if let Ok(u) = value.extract::() { + NumericClaim::Value(u) + } else if let Ok(i) = value.extract::() { + if i >= 0 { + NumericClaim::Value(i as u64) + } else { + NumericClaim::Invalid + } + } else if let Ok(f) = value.extract::() { + if f.is_finite() && f >= 0.0 && f < u64::MAX as f64 { + NumericClaim::Value(f as u64) + } else { + NumericClaim::Invalid + } + } else { + NumericClaim::Invalid + } +} + +fn audience_matches_py(claim: &Bound<'_, PyAny>, expected: &HashSet) -> bool { + if claim.is_instance_of::() { + if let Ok(s) = claim.extract::<&str>() { + return !s.is_empty() && expected.contains(s); + } + } else if let Ok(items) = claim.cast::() { + if items.is_empty() { + return false; + } + let mut any_match = false; + for item in items.iter() { + if item.is_instance_of::() { + if let Ok(s) = item.extract::<&str>() { + if !s.is_empty() && expected.contains(s) { + any_match = true; + } } else { - NumericClaim::Invalid + return false; } } else { - NumericClaim::Invalid + return false; } } - _ => 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, + return any_match; } + 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, +fn issuer_matches_py(claim: &Bound<'_, PyAny>, expected: &HashSet) -> bool { + if claim.is_instance_of::() { + if let Ok(s) = claim.extract::<&str>() { + return expected.contains(s); + } + } else if let Ok(items) = claim.cast::() { + for item in items.iter() { + if item.is_instance_of::() { + if let Ok(s) = item.extract::<&str>() { + if expected.contains(s) { + return true; + } + } + } + } } + 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)); - } - +/// Validate standard claims directly on a PyDict claims object. +pub fn validate_claims_dict( + claims: &Bound<'_, PyDict>, + decode_val: &DecodeValidation, +) -> PyResult<()> { + let options = &decode_val.validation; if !options.required_spec_claims.is_empty() { for required in REQUIRABLE_SPEC_CLAIMS { if !options.required_spec_claims.contains(required) { continue; } - // A JSON null counts as absent, matching `_validate_required` on the - // Python side and PyJWT's `payload.get(claim) is None` rule. A - // present but malformed value is reported by the format checks - // below instead. - if !matches!(claims.get(required), Some(value) if !value.is_null()) { - return Err(new_error(ErrorKind::MissingRequiredClaim( - required.to_owned(), + let is_present = match claims.get_item(required) { + Ok(Some(value)) => !value.is_none(), + _ => false, + }; + if !is_present { + return Err(errors::from_jwt_decode_error(new_error( + ErrorKind::MissingRequiredClaim(required.to_owned()), ))); } } @@ -87,82 +123,140 @@ pub fn validate_claims_value(claims: &Value, options: &Validation) -> Result<(), 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 options.validate_exp || options.validate_nbf || decode_val.validate_iat { + let exp_claim = parse_numeric_claim_py(claims.get_item("exp").ok().flatten()); + let nbf_claim = parse_numeric_claim_py(claims.get_item("nbf").ok().flatten()); + let iat_claim = parse_numeric_claim_py(claims.get_item("iat").ok().flatten()); + + if options.validate_exp && matches!(exp_claim, NumericClaim::Invalid) { + return Err(errors::decode_error( + "Expiration Time claim (exp) must be an integer.", + )); + } + if options.validate_nbf && matches!(nbf_claim, NumericClaim::Invalid) { + return Err(errors::decode_error( + "Not Before claim (nbf) must be an integer.", + )); + } + if decode_val.validate_iat && matches!(iat_claim, NumericClaim::Invalid) { + return Err(errors::InvalidIssuedAtError::new_err( + "Issued At claim (iat) must be an integer.", + )); + } + + if let NumericClaim::Value(exp) = exp_claim { if exp < options.reject_tokens_expiring_in_less_than { - return Err(new_error(ErrorKind::InvalidToken)); + return Err(errors::invalid_token("Invalid token")); } 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)); + return Err(errors::ExpiredSignatureError::new_err( + "Signature has expired", + )); } } - if let NumericClaim::Value(nbf) = parse_numeric_claim(claims.get("nbf")) { + if let NumericClaim::Value(nbf) = nbf_claim { if options.validate_nbf && nbf > now.saturating_add(options.leeway) { - return Err(new_error(ErrorKind::ImmatureSignature)); + return Err(errors::ImmatureSignatureError::new_err( + "The token is not yet valid (nbf)", + )); } } - } - 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 NumericClaim::Value(iat) = iat_claim { + if decode_val.validate_iat && iat > now.saturating_add(options.leeway) { + return Err(errors::ImmatureSignatureError::new_err( + "The token is not yet valid (iat)", + )); + } } } - 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 decode_val.validate_sub { + match claims.get_item("sub") { + Ok(Some(val)) => { + if !val.is_none() { + if val.is_instance_of::() { + let sub = val + .extract::<&str>() + .map_err(|_| errors::InvalidSubjectError::new_err("Invalid subject"))?; + if let Some(expected_sub) = options.sub.as_deref() { + if sub != expected_sub { + return Err(errors::InvalidSubjectError::new_err( + "Invalid subject", + )); + } + } + } else { + return Err(errors::InvalidSubjectError::new_err( + "Subject must be a string", + )); + } + } + } + Ok(None) => (), + Err(_) => return Err(errors::InvalidSubjectError::new_err("Invalid subject")), } } - if !options.validate_aud { - return Ok(()); + if let Some(expected_iss) = options.iss.as_ref() { + match claims.get_item("iss") { + Ok(None) => return Err(errors::InvalidIssuerError::new_err("Invalid issuer")), + Ok(Some(val)) if val.is_none() => { + return Err(errors::InvalidIssuerError::new_err("Invalid issuer")) + } + Ok(Some(val)) => { + if !issuer_matches_py(&val, expected_iss) { + return Err(errors::InvalidIssuerError::new_err("Invalid issuer")); + } + } + Err(_) => return Err(errors::InvalidIssuerError::new_err("Invalid issuer")), + } } - 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)) + if options.validate_aud { + let aud_claim = claims.get_item("aud").ok().flatten(); + match (aud_claim, options.aud.as_ref()) { + (Some(_), None) => { + return Err(errors::InvalidAudienceError::new_err("Invalid audience")) + } + (None, Some(_)) => { + return Err(errors::from_jwt_decode_error(new_error( + ErrorKind::MissingRequiredClaim("aud".to_string()), + ))); + } + (Some(val), Some(_)) if val.is_none() => { + return Err(errors::from_jwt_decode_error(new_error( + ErrorKind::MissingRequiredClaim("aud".to_string()), + ))); + } + (Some(val), Some(_)) + if val.is_instance_of::() + && val.extract::<&str>().is_ok_and(|s| s.is_empty()) => + { + return Err(errors::from_jwt_decode_error(new_error( + ErrorKind::MissingRequiredClaim("aud".to_string()), + ))); + } + (Some(val), Some(_)) + if val.is_instance_of::() + && val.cast::().is_ok_and(|l| l.is_empty()) => + { + return Err(errors::from_jwt_decode_error(new_error( + ErrorKind::MissingRequiredClaim("aud".to_string()), + ))); + } + (Some(val), Some(expected)) => { + if !audience_matches_py(&val, expected) { + return Err(errors::InvalidAudienceError::new_err("Invalid audience")); + } + } + (None, None) => (), } - _ => 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)); - } + Ok(()) } diff --git a/rust/src/errors.rs b/rust/src/errors.rs index 0aa76c9..a178546 100644 --- a/rust/src/errors.rs +++ b/rust/src/errors.rs @@ -1,8 +1,37 @@ -use jsonwebtoken::errors::Error; +use jsonwebtoken::errors::{new_error, Error, ErrorKind}; use pyo3::create_exception; use pyo3::exceptions::PyException; use pyo3::prelude::*; +/// Failure from a decode step that runs with the GIL released. +/// +/// Keeping the variants typed (instead of matching on message text) means the +/// Python exception class is chosen from the failure itself. +#[derive(Debug)] +pub enum DecodeFail { + Jwt(Error), + Decode(String), + Token(String), +} + +impl From for DecodeFail { + fn from(err: Error) -> Self { + DecodeFail::Jwt(err) + } +} + +pub fn decode_fail(kind: ErrorKind) -> DecodeFail { + DecodeFail::Jwt(new_error(kind)) +} + +pub fn map_decode_fail(fail: DecodeFail) -> PyErr { + match fail { + DecodeFail::Jwt(err) => from_jwt_decode_error(err), + DecodeFail::Decode(message) => decode_error(message), + DecodeFail::Token(message) => invalid_token(message), + } +} + // Mirrors PyJWT: InvalidTokenError -> DecodeError -> InvalidSignatureError create_exception!(_oxyjwt, OxyJWTError, PyException); create_exception!(_oxyjwt, InvalidTokenError, OxyJWTError); @@ -90,9 +119,9 @@ pub fn from_jwt_decode_error(err: Error) -> PyErr { ErrorKind::ExpiredSignature => ExpiredSignatureError::new_err(message), ErrorKind::ImmatureSignature => ImmatureSignatureError::new_err(message), ErrorKind::InvalidSignature => InvalidSignatureError::new_err(message), - ErrorKind::InvalidAudience => InvalidAudienceError::new_err(message), - ErrorKind::InvalidIssuer => InvalidIssuerError::new_err(message), - ErrorKind::InvalidSubject => InvalidSubjectError::new_err(message), + ErrorKind::InvalidAudience => InvalidAudienceError::new_err("Invalid audience"), + ErrorKind::InvalidIssuer => InvalidIssuerError::new_err("Invalid issuer"), + ErrorKind::InvalidSubject => InvalidSubjectError::new_err("Subject doesn't match"), ErrorKind::MissingRequiredClaim(_) => MissingRequiredClaimError::new_err(message), ErrorKind::InvalidAlgorithm | ErrorKind::InvalidAlgorithmName diff --git a/rust/src/jws.rs b/rust/src/jws.rs index 0dea053..3e2fac4 100644 --- a/rust/src/jws.rs +++ b/rust/src/jws.rs @@ -4,6 +4,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use pyo3::prelude::*; use pyo3::types::PyBytes; +use serde::de::Deserializer; use serde_json::Value; use crate::errors; @@ -66,6 +67,381 @@ pub struct Rfc7797Parts { pub header: Value, } +/// Validate the `crit` header parameter value according to RFC 7515 §4.1.11. +/// +/// `crit_val` must be a non-empty array of strings, and each named parameter +/// must be present in `understood`. If any named parameter is unrecognized or +/// malformed, an error is returned. +pub fn validate_crit_value(crit_val: &Value, understood: &[&str]) -> Result<(), String> { + let Some(crit) = crit_val.as_array() else { + return Err("Items in 'crit' header must be strings".to_string()); + }; + if crit.is_empty() { + return Err("The 'crit' header parameter must not be empty".to_string()); + } + for param in crit { + let Some(param_str) = param.as_str() else { + return Err("Items in 'crit' header must be strings".to_string()); + }; + if !understood.contains(¶m_str) { + return Err(format!( + "Unsupported critical header parameter in 'crit': '{param_str}'" + )); + } + } + Ok(()) +} + +/// Validate the `crit` header parameter in `header` according to RFC 7515 §4.1.11. +/// +/// If `crit` is present in `header`, it must be a non-empty array of strings, +/// and each named parameter must be present in `understood`. If any named parameter +/// is unrecognized, an error is returned. +pub fn validate_crit_header(header: &Value, understood: &[&str]) -> Result<(), String> { + let Some(crit_val) = header.get("crit") else { + return Ok(()); + }; + validate_crit_value(crit_val, understood) +} + +/// Fast-path match for standard base64url-encoded JWT header segments. +/// +/// Standard headers (such as `{"typ":"JWT","alg":"HS256"}` or `{"alg":"HS256","typ":"JWT"}`) +/// account for the vast majority of compact JWTs. Recognizing these static base64 +/// slices allows instantaneous algorithm resolution with zero base64 decoding, +/// zero JSON parsing, and zero heap allocations. +#[inline] +pub fn match_standard_header(header_segment: &str) -> Option { + use jsonwebtoken::Algorithm; + match header_segment { + // Form 1: {"typ":"JWT","alg":""} (standard in jsonwebtoken / OxyJWT) + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9" => Some(Algorithm::HS256), + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzM4NCJ9" => Some(Algorithm::HS384), + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9" => Some(Algorithm::HS512), + "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9" => Some(Algorithm::RS256), + "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzM4NCJ9" => Some(Algorithm::RS384), + "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9" => Some(Algorithm::RS512), + "eyJ0eXAiOiJKV1QiLCJhbGciOiJQUzI1NiJ9" => Some(Algorithm::PS256), + "eyJ0eXAiOiJKV1QiLCJhbGciOiJQUzM4NCJ9" => Some(Algorithm::PS384), + "eyJ0eXAiOiJKV1QiLCJhbGciOiJQUzUxMiJ9" => Some(Algorithm::PS512), + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9" => Some(Algorithm::ES256), + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9" => Some(Algorithm::ES384), + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9" => Some(Algorithm::EdDSA), + + // Form 2: {"alg":"","typ":"JWT"} (standard in PyJWT and many other libraries) + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" => Some(Algorithm::HS256), + "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9" => Some(Algorithm::HS384), + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9" => Some(Algorithm::HS512), + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" => Some(Algorithm::RS256), + "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9" => Some(Algorithm::RS384), + "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9" => Some(Algorithm::RS512), + "eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9" => Some(Algorithm::PS256), + "eyJhbGciOiJQUzM4NCIsInR5cCI6IkpXVCJ9" => Some(Algorithm::PS384), + "eyJhbGciOiJQUzUxMiIsInR5cCI6IkpXVCJ9" => Some(Algorithm::PS512), + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9" => Some(Algorithm::ES256), + "eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9" => Some(Algorithm::ES384), + "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9" => Some(Algorithm::EdDSA), + + // Form 3: {"alg":""} (minimal header without typ) + "eyJhbGciOiJIUzI1NiJ9" => Some(Algorithm::HS256), + "eyJhbGciOiJIUzM4NCJ9" => Some(Algorithm::HS384), + "eyJhbGciOiJIUzUxMiJ9" => Some(Algorithm::HS512), + "eyJhbGciOiJSUzI1NiJ9" => Some(Algorithm::RS256), + "eyJhbGciOiJSUzM4NCJ9" => Some(Algorithm::RS384), + "eyJhbGciOiJSUzUxMiJ9" => Some(Algorithm::RS512), + "eyJhbGciOiJQUzI1NiJ9" => Some(Algorithm::PS256), + "eyJhbGciOiJQUzM4NCJ9" => Some(Algorithm::PS384), + "eyJhbGciOiJQUzUxMiJ9" => Some(Algorithm::PS512), + "eyJhbGciOiJFUzI1NiJ9" => Some(Algorithm::ES256), + "eyJhbGciOiJFUzM4NCJ9" => Some(Algorithm::ES384), + "eyJhbGciOiJFZERTQSJ9" => Some(Algorithm::EdDSA), + + _ => None, + } +} + +/// Pre-encoded URL-safe unpadded base64 representation of `{"typ":"JWT","alg":""}`. +/// +/// For standard encodings with default headers, using this constant slice avoids +/// runtime JSON serialization and base64 encoding on every single `encode` call. +#[inline] +pub fn standard_header_base64(algorithm: jsonwebtoken::Algorithm) -> &'static str { + use jsonwebtoken::Algorithm; + match algorithm { + Algorithm::HS256 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9", + Algorithm::HS384 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzM4NCJ9", + Algorithm::HS512 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9", + Algorithm::RS256 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9", + Algorithm::RS384 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzM4NCJ9", + Algorithm::RS512 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9", + Algorithm::PS256 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJQUzI1NiJ9", + Algorithm::PS384 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJQUzM4NCJ9", + Algorithm::PS512 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJQUzUxMiJ9", + Algorithm::ES256 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9", + Algorithm::ES384 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9", + Algorithm::EdDSA => "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9", + } +} + +#[derive(Debug)] +pub struct FastHeaderSummary { + pub algorithm: jsonwebtoken::Algorithm, + pub crit: Option, +} + +#[derive(Debug)] +enum AlgValue<'de> { + Str(std::borrow::Cow<'de, str>), + NotAString, +} + +struct AlgValueVisitor; + +impl<'de> serde::de::Visitor<'de> for AlgValueVisitor { + type Value = AlgValue<'de>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("any valid JSON value") + } + + fn visit_borrowed_str(self, v: &'de str) -> Result + where + E: serde::de::Error, + { + Ok(AlgValue::Str(std::borrow::Cow::Borrowed(v))) + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Ok(AlgValue::Str(std::borrow::Cow::Owned(v.to_owned()))) + } + + fn visit_string(self, v: String) -> Result + where + E: serde::de::Error, + { + Ok(AlgValue::Str(std::borrow::Cow::Owned(v))) + } + + fn visit_i64(self, _v: i64) -> Result { + Ok(AlgValue::NotAString) + } + + fn visit_u64(self, _v: u64) -> Result { + Ok(AlgValue::NotAString) + } + + fn visit_f64(self, _v: f64) -> Result { + Ok(AlgValue::NotAString) + } + + fn visit_bool(self, _v: bool) -> Result { + Ok(AlgValue::NotAString) + } + + fn visit_none(self) -> Result { + Ok(AlgValue::NotAString) + } + + fn visit_unit(self) -> Result { + Ok(AlgValue::NotAString) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + while let Some(serde::de::IgnoredAny) = seq.next_element()? {} + Ok(AlgValue::NotAString) + } + + fn visit_map(self, mut map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + while let Some((serde::de::IgnoredAny, serde::de::IgnoredAny)) = map.next_entry()? {} + Ok(AlgValue::NotAString) + } +} + +impl<'de> serde::Deserialize<'de> for AlgValue<'de> { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(AlgValueVisitor) + } +} + +const NON_OBJECT_HEADER_ERR: &str = "Invalid header string: must be a json object"; + +struct HeaderSummary<'de> { + alg: Option>, + crit: Option, +} + +struct HeaderSummaryVisitor; + +impl<'de> serde::de::Visitor<'de> for HeaderSummaryVisitor { + type Value = HeaderSummary<'de>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a json object") + } + + fn visit_map(self, mut access: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let mut alg = None; + let mut crit = None; + + while let Some(key) = access.next_key::>()? { + match key.as_ref() { + "alg" => { + alg = Some(access.next_value::>()?); + } + "crit" => { + crit = Some(access.next_value::()?); + } + _ => { + let _ = access.next_value::()?; + } + } + } + + Ok(HeaderSummary { alg, crit }) + } + + fn visit_seq(self, _seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + Err(serde::de::Error::custom(NON_OBJECT_HEADER_ERR)) + } + + fn visit_str(self, _v: &str) -> Result + where + E: serde::de::Error, + { + Err(serde::de::Error::custom(NON_OBJECT_HEADER_ERR)) + } + + fn visit_i64(self, _v: i64) -> Result + where + E: serde::de::Error, + { + Err(serde::de::Error::custom(NON_OBJECT_HEADER_ERR)) + } + + fn visit_u64(self, _v: u64) -> Result + where + E: serde::de::Error, + { + Err(serde::de::Error::custom(NON_OBJECT_HEADER_ERR)) + } + + fn visit_f64(self, _v: f64) -> Result + where + E: serde::de::Error, + { + Err(serde::de::Error::custom(NON_OBJECT_HEADER_ERR)) + } + + fn visit_bool(self, _v: bool) -> Result + where + E: serde::de::Error, + { + Err(serde::de::Error::custom(NON_OBJECT_HEADER_ERR)) + } + + fn visit_unit(self) -> Result + where + E: serde::de::Error, + { + Err(serde::de::Error::custom(NON_OBJECT_HEADER_ERR)) + } +} + +fn parse_header_alg_from_slice( + header_bytes: &[u8], +) -> Result { + let mut de = serde_json::Deserializer::from_slice(header_bytes); + let summary = de.deserialize_any(HeaderSummaryVisitor).map_err(|e| { + let msg = e.to_string(); + if msg.starts_with(NON_OBJECT_HEADER_ERR) { + errors::DecodeFail::Decode(NON_OBJECT_HEADER_ERR.to_string()) + } else { + errors::DecodeFail::Decode(msg) + } + })?; + + de.end().map_err(|e| { + let msg = e.to_string(); + if msg.starts_with(NON_OBJECT_HEADER_ERR) { + errors::DecodeFail::Decode(NON_OBJECT_HEADER_ERR.to_string()) + } else { + errors::DecodeFail::Decode(msg) + } + })?; + + let Some(alg_value) = summary.alg else { + return Err(errors::DecodeFail::Decode( + "missing field `alg` in JWT header".to_owned(), + )); + }; + + let alg_name = match alg_value { + AlgValue::Str(s) => s, + AlgValue::NotAString => { + return Err(errors::DecodeFail::Decode( + "missing field `alg` in JWT header".to_owned(), + )); + } + }; + + let algorithm = + crate::algorithms::parse_algorithm_name(&alg_name).map_err(errors::DecodeFail::Decode)?; + + Ok(FastHeaderSummary { + algorithm, + crit: summary.crit, + }) +} + +/// Zero-copy resolution of algorithm and critical header parameters from a protected header segment. +/// +/// Tries `match_standard_header` first. On non-standard headers, uses a streaming serde visitor +/// to extract `alg` and `crit` without heap map allocations or unnecessary string cloning. +pub fn resolve_header_alg_and_crit( + header_segment: &str, +) -> Result { + if let Some(algorithm) = match_standard_header(header_segment) { + return Ok(FastHeaderSummary { + algorithm, + crit: None, + }); + } + + let mut stack_buf = [0u8; 512]; + if header_segment.len() <= 512 { + match URL_SAFE_NO_PAD.decode_slice(header_segment.as_bytes(), &mut stack_buf) { + Ok(len) => parse_header_alg_from_slice(&stack_buf[..len]), + Err(_) => { + let err = URL_SAFE_NO_PAD.decode(header_segment).unwrap_err(); + Err(errors::DecodeFail::Decode(err.to_string())) + } + } + } else { + let header_bytes = URL_SAFE_NO_PAD + .decode(header_segment) + .map_err(|e| errors::DecodeFail::Decode(e.to_string()))?; + parse_header_alg_from_slice(&header_bytes) + } +} + pub fn validate_rfc7797_header(header: &Value) -> Result<(), String> { if header.get("b64").and_then(Value::as_bool) != Some(false) { return Err( @@ -83,21 +459,8 @@ pub fn validate_rfc7797_header(header: &Value) -> Result<(), String> { "The 'b64' header parameter requires 'b64' to be listed in 'crit'.".to_string(), ); } - let mut has_b64 = false; - for param in crit { - let Some(param_str) = param.as_str() else { - return Err("Items in 'crit' header must be strings".to_string()); - }; - match param_str { - "b64" => has_b64 = true, - other => { - return Err(format!( - "Unsupported critical header parameter in 'crit': '{other}'" - )) - } - } - } - if !has_b64 { + validate_crit_header(header, &["b64"])?; + if !crit.iter().any(|param| param.as_str() == Some("b64")) { return Err( "The 'b64' header parameter requires 'b64' to be listed in 'crit'.".to_string(), ); @@ -157,6 +520,7 @@ pub fn parse_compact_jws(token: &str) -> Result { /// 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. +#[cfg(test)] pub fn parse_compact_claims_unverified(token: &str) -> Result { let (header_segment, payload_segment, _) = split_compact_segments(token)?; decode_header_json(header_segment)?; @@ -238,4 +602,175 @@ mod tests { parse_compact_claims_unverified(token).expect("claims") ); } + + #[test] + fn validate_crit_header_accepts_no_crit() { + let header = serde_json::json!({"alg": "HS256"}); + assert!(validate_crit_header(&header, &[]).is_ok()); + } + + #[test] + fn validate_crit_header_rejects_empty_crit() { + let header = serde_json::json!({"alg": "HS256", "crit": []}); + let err = validate_crit_header(&header, &[]).unwrap_err(); + assert!(err.contains("must not be empty")); + } + + #[test] + fn validate_crit_header_rejects_non_array_crit() { + let header = serde_json::json!({"alg": "HS256", "crit": "b64"}); + let err = validate_crit_header(&header, &[]).unwrap_err(); + assert!(err.contains("must be strings")); + } + + #[test] + fn validate_crit_header_rejects_non_string_items() { + let header = serde_json::json!({"alg": "HS256", "crit": [123]}); + let err = validate_crit_header(&header, &[]).unwrap_err(); + assert!(err.contains("must be strings")); + } + + #[test] + fn validate_crit_header_rejects_unsupported_crit() { + let header = serde_json::json!({"alg": "HS256", "crit": ["exp-strict-mode"]}); + let err = validate_crit_header(&header, &[]).unwrap_err(); + assert!(err.contains("Unsupported critical header parameter in 'crit': 'exp-strict-mode'")); + } + + #[test] + fn validate_crit_header_accepts_understood_crit() { + let header = serde_json::json!({"alg": "HS256", "crit": ["b64"]}); + assert!(validate_crit_header(&header, &["b64"]).is_ok()); + } + + #[test] + fn match_standard_header_recognizes_forms() { + use jsonwebtoken::Algorithm; + assert_eq!( + match_standard_header("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"), + Some(Algorithm::HS256) + ); + assert_eq!( + match_standard_header("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + Some(Algorithm::HS256) + ); + assert_eq!( + match_standard_header("eyJhbGciOiJIUzI1NiJ9"), + Some(Algorithm::HS256) + ); + assert_eq!( + match_standard_header("eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"), + Some(Algorithm::RS256) + ); + assert_eq!(match_standard_header("non-standard"), None); + } + + #[test] + fn resolve_header_alg_extracts_from_custom_header() { + use jsonwebtoken::Algorithm; + // {"alg":"HS256","kid":"k1","typ":"JWT"} + let segment = "eyJhbGciOiJIUzI1NiIsImtpZCI6ImsxIiwidHlwIjoiSldUIn0"; + let summary = resolve_header_alg_and_crit(segment).expect("parse"); + assert_eq!(summary.algorithm, Algorithm::HS256); + assert!(summary.crit.is_none()); + } + + #[test] + fn resolve_header_alg_extracts_crit_field() { + use jsonwebtoken::Algorithm; + // {"alg":"HS256","crit":["unknown"]} + let segment = "eyJhbGciOiJIUzI1NiIsImNyaXQiOlsidW5rbm93biJdfQ"; + let summary = resolve_header_alg_and_crit(segment).expect("parse"); + assert_eq!(summary.algorithm, Algorithm::HS256); + assert_eq!(summary.crit, Some(serde_json::json!(["unknown"]))); + } + + #[test] + fn resolve_header_alg_rejects_missing_or_non_string_alg() { + // {"typ":"JWT"} + let missing_alg = "eyJ0eXAiOiJKV1QifQ"; + match resolve_header_alg_and_crit(missing_alg).unwrap_err() { + errors::DecodeFail::Decode(msg) => { + assert_eq!(msg, "missing field `alg` in JWT header"); + } + other => panic!("expected Decode error, got {other:?}"), + } + + // {"alg":123} + let int_alg = "eyJhbGciOjEyM30"; + match resolve_header_alg_and_crit(int_alg).unwrap_err() { + errors::DecodeFail::Decode(msg) => { + assert_eq!(msg, "missing field `alg` in JWT header"); + } + other => panic!("expected Decode error, got {other:?}"), + } + } + + #[test] + fn resolve_header_alg_rejects_non_object_header() { + // [1, 2] + let array_header = "WzEsMl0"; + match resolve_header_alg_and_crit(array_header).unwrap_err() { + errors::DecodeFail::Decode(msg) => { + assert_eq!(msg, "Invalid header string: must be a json object"); + } + other => panic!("expected Decode error, got {other:?}"), + } + + // "string" + let str_header = "InN0cmluZyI"; + match resolve_header_alg_and_crit(str_header).unwrap_err() { + errors::DecodeFail::Decode(msg) => { + assert_eq!(msg, "Invalid header string: must be a json object"); + } + other => panic!("expected Decode error, got {other:?}"), + } + } + + #[test] + fn resolve_header_alg_rejects_invalid_base64_or_json() { + let invalid_b64 = "???"; + assert!(matches!( + resolve_header_alg_and_crit(invalid_b64), + Err(errors::DecodeFail::Decode(_)) + )); + + // {"alg": + let invalid_json = "eyJhbGciOi"; + assert!(matches!( + resolve_header_alg_and_crit(invalid_json), + Err(errors::DecodeFail::Decode(_)) + )); + } + + #[test] + fn standard_header_base64_matches_header_new() { + use jsonwebtoken::{Algorithm, Header}; + + let algorithms = [ + Algorithm::HS256, + Algorithm::HS384, + Algorithm::HS512, + Algorithm::RS256, + Algorithm::RS384, + Algorithm::RS512, + Algorithm::PS256, + Algorithm::PS384, + Algorithm::PS512, + Algorithm::ES256, + Algorithm::ES384, + Algorithm::EdDSA, + ]; + + for alg in algorithms { + let header = Header::new(alg); + let json = serde_json::to_vec(&header).unwrap(); + let b64 = URL_SAFE_NO_PAD.encode(&json); + assert_eq!( + standard_header_base64(alg), + b64, + "mismatch for algorithm {alg:?}" + ); + } + } } diff --git a/rust/src/keys.rs b/rust/src/keys.rs index 90b0ebe..f2da244 100644 --- a/rust/src/keys.rs +++ b/rust/src/keys.rs @@ -1,10 +1,9 @@ use jsonwebtoken::{Algorithm, DecodingKey as JwtDecodingKey, EncodingKey as JwtEncodingKey}; use pyo3::prelude::*; -use pyo3::types::PyBytes; +use pyo3::types::{PyByteArray, PyBytes, PyString}; use zeroize::Zeroizing; use crate::algorithms::{ensure_algorithm_family, ensure_single_family, KeyFamily}; -use crate::claims; use crate::errors; #[cfg(feature = "aws_lc_rs")] @@ -149,21 +148,37 @@ impl DecodingKey { #[staticmethod] pub fn from_jwk(jwk: &Bound<'_, PyAny>) -> PyResult { - let raw = if let Ok(value) = jwk.extract::() { - value + let jwk_parsed: jsonwebtoken::jwk::Jwk = if let Ok(py_str) = jwk.cast::() { + let s = py_str.to_str()?; + serde_json::from_str(s) + .map_err(|err| errors::invalid_key(format!("invalid JWK: {err}")))? + } else if let Ok(py_bytes) = jwk.cast::() { + serde_json::from_slice(py_bytes.as_bytes()) + .map_err(|err| errors::invalid_key(format!("invalid JWK: {err}")))? + } else if let Ok(py_bytearray) = jwk.cast::() { + let bytes = unsafe { py_bytearray.as_bytes() }; + serde_json::from_slice(bytes) + .map_err(|err| errors::invalid_key(format!("invalid JWK: {err}")))? } else { - let value = claims::py_to_json_for_decode(jwk)?; - serde_json::to_string(&value) + let py = jwk.py(); + let orjson = py + .import(pyo3::intern!(py, "orjson")) + .map_err(|err| errors::invalid_key(format!("invalid JWK value: {err}")))?; + let dumped = orjson + .call_method1(pyo3::intern!(py, "dumps"), (jwk,)) + .map_err(|err| errors::invalid_key(format!("invalid JWK value: {err}")))?; + let slice = dumped + .cast::() .map_err(|err| errors::invalid_key(format!("invalid JWK value: {err}")))? + .as_bytes(); + serde_json::from_slice(slice) + .map_err(|err| errors::invalid_key(format!("invalid JWK: {err}")))? }; - let jwk = serde_json::from_str(&raw) - .map_err(|err| errors::invalid_key(format!("invalid JWK: {err}")))?; - Ok(Self { material: DecodingKeyMaterial::new( KeyFamily::Jwk, - JwtDecodingKey::from_jwk(&jwk).map_err(errors::from_jwt_decode_error)?, + JwtDecodingKey::from_jwk(&jwk_parsed).map_err(errors::from_jwt_decode_error)?, ), }) } diff --git a/rust/src/validation.rs b/rust/src/validation.rs index 86cfdd7..4e2fe74 100644 --- a/rust/src/validation.rs +++ b/rust/src/validation.rs @@ -7,6 +7,8 @@ use crate::errors; pub struct DecodeValidation { pub validation: Validation, + pub validate_iat: bool, + pub validate_sub: bool, } impl DecodeValidation { @@ -45,6 +47,8 @@ pub fn build_validation( validation.leeway = leeway_as_u64(leeway); validation.validate_exp = opts.flag("verify_exp", true)?; validation.validate_nbf = opts.flag("verify_nbf", true)?; + let validate_iat = opts.flag("verify_iat", true)?; + let validate_sub = opts.flag("verify_sub", true)?; // `Validation::new` pre-seeds `exp`; keep it only if the caller asked for it. if !opts.flag("require_exp", false)? { @@ -67,7 +71,7 @@ pub fn build_validation( validation.validate_aud = false; } } else { - validation.validate_aud = false; + validation.validate_aud = verify_aud; } if opts.flag("verify_iss", true)? { @@ -78,11 +82,15 @@ pub fn build_validation( } } - if opts.flag("verify_sub", true)? { + if validate_sub { validation.sub = subject; } - Ok(DecodeValidation { validation }) + Ok(DecodeValidation { + validation, + validate_iat, + validate_sub, + }) } /// The decode `options` mapping, cast once and then queried by key. diff --git a/tests/test_decode_fast_path.py b/tests/test_decode_fast_path.py index b454f4a..38f4e0e 100644 --- a/tests/test_decode_fast_path.py +++ b/tests/test_decode_fast_path.py @@ -225,3 +225,199 @@ def test_sort_headers_does_not_change_output(self, payload: dict[str, Any]) -> N assert oxyjwt.encode(payload, SECRET, "HS256", sort_headers=True) == ( oxyjwt.encode(payload, SECRET, "HS256", sort_headers=False) ) + + +class TestFastPathAudienceIssuerSubject: + """Parity tests for audience, issuer, and subject in fast-path decode.""" + + @pytest.mark.parametrize( + ("payload", "audience"), + [ + ({"aud": "my-aud"}, "my-aud"), + ({"aud": ["my-aud", "other-aud"]}, "my-aud"), + ({"aud": "wrong-aud"}, "my-aud"), + ({"aud": ["wrong-aud"]}, "my-aud"), + ({}, "my-aud"), + ({"aud": None}, "my-aud"), + ({"aud": ""}, "my-aud"), + ({"aud": []}, "my-aud"), + ({"aud": 123}, "my-aud"), + ({"aud": "aud1"}, ["aud1", "aud2"]), + ({"aud": "aud2"}, ("aud1", "aud2")), + ({"aud": ["aud2", "aud3"]}, ["aud1", "aud2"]), + ({"aud": "aud3"}, ["aud1", "aud2"]), + ({}, ["aud1", "aud2"]), + ({"aud": None}, ["aud1", "aud2"]), + ({"aud": ""}, ["aud1", "aud2"]), + ({"aud": []}, ["aud1", "aud2"]), + ], + ) + def test_audience_fast_path_matches_general( + self, payload: dict[str, Any], audience: Any + ) -> None: + token = oxyjwt.encode(payload, SECRET, "HS256") + fast = _outcome( + lambda: oxyjwt.decode( + token, SECRET, algorithms=["HS256"], audience=audience + ) + ) + general = _outcome( + lambda: oxyjwt.decode( + token, + SECRET, + algorithms=["HS256"], + options={}, + audience=audience, + ) + ) + assert fast == general + + fast_c = _outcome( + lambda: oxyjwt.decode_complete( + token, SECRET, algorithms=["HS256"], audience=audience + ) + ) + general_c = _outcome( + lambda: oxyjwt.decode_complete( + token, + SECRET, + algorithms=["HS256"], + options={}, + audience=audience, + ) + ) + assert fast_c == general_c + + @pytest.mark.parametrize( + ("payload", "issuer"), + [ + ({"iss": "https://auth.example.com"}, "https://auth.example.com"), + ({"iss": "https://other.example.com"}, "https://auth.example.com"), + ({}, "https://auth.example.com"), + ({"iss": None}, "https://auth.example.com"), + ({"iss": 123}, "https://auth.example.com"), + ({"iss": "auth1"}, ["auth1", "auth2"]), + ({"iss": "auth2"}, ("auth1", "auth2")), + ({"iss": "auth3"}, ["auth1", "auth2"]), + ({}, ["auth1", "auth2"]), + ({"iss": None}, ["auth1", "auth2"]), + ], + ) + def test_issuer_fast_path_matches_general( + self, payload: dict[str, Any], issuer: Any + ) -> None: + token = oxyjwt.encode(payload, SECRET, "HS256") + fast = _outcome( + lambda: oxyjwt.decode( + token, SECRET, algorithms=["HS256"], issuer=issuer + ) + ) + general = _outcome( + lambda: oxyjwt.decode( + token, SECRET, algorithms=["HS256"], options={}, issuer=issuer + ) + ) + assert fast == general + + fast_c = _outcome( + lambda: oxyjwt.decode_complete( + token, SECRET, algorithms=["HS256"], issuer=issuer + ) + ) + general_c = _outcome( + lambda: oxyjwt.decode_complete( + token, SECRET, algorithms=["HS256"], options={}, issuer=issuer + ) + ) + assert fast_c == general_c + + @pytest.mark.parametrize( + ("payload", "subject"), + [ + ({"sub": "user123"}, "user123"), + ({"sub": "user456"}, "user123"), + ({}, "user123"), + ({"sub": None}, "user123"), + ({"sub": 123}, "user123"), + ], + ) + def test_subject_fast_path_matches_general( + self, payload: dict[str, Any], subject: str + ) -> None: + token = oxyjwt.encode(payload, SECRET, "HS256") + fast = _outcome( + lambda: oxyjwt.decode( + token, SECRET, algorithms=["HS256"], subject=subject + ) + ) + general = _outcome( + lambda: oxyjwt.decode( + token, SECRET, algorithms=["HS256"], options={}, subject=subject + ) + ) + assert fast == general + + fast_c = _outcome( + lambda: oxyjwt.decode_complete( + token, SECRET, algorithms=["HS256"], subject=subject + ) + ) + general_c = _outcome( + lambda: oxyjwt.decode_complete( + token, + SECRET, + algorithms=["HS256"], + options={}, + subject=subject, + ) + ) + assert fast_c == general_c + + def test_combined_aud_iss_sub_fast_path(self) -> None: + payload = { + "aud": "my-aud", + "iss": "my-iss", + "sub": "my-sub", + "exp": NOW + 3600, + "data": "ok", + } + token = oxyjwt.encode(payload, SECRET, "HS256") + + fast = oxyjwt.decode( + token, + SECRET, + algorithms=["HS256"], + audience="my-aud", + issuer="my-iss", + subject="my-sub", + ) + general = oxyjwt.decode( + token, + SECRET, + algorithms=["HS256"], + options={}, + audience="my-aud", + issuer="my-iss", + subject="my-sub", + ) + assert fast == general == payload + + fast_c = oxyjwt.decode_complete( + token, + SECRET, + algorithms=["HS256"], + audience="my-aud", + issuer="my-iss", + subject="my-sub", + ) + general_c = oxyjwt.decode_complete( + token, + SECRET, + algorithms=["HS256"], + options={}, + audience="my-aud", + issuer="my-iss", + subject="my-sub", + ) + assert fast_c == general_c + diff --git a/tests/test_encode_decode.py b/tests/test_encode_decode.py index d17543e..5fd7b49 100644 --- a/tests/test_encode_decode.py +++ b/tests/test_encode_decode.py @@ -270,3 +270,148 @@ def roundtrip(i: int) -> bool: assert all(results) +def test_decode_complex_claims_streaming_deserialization() -> None: + payload = { + "sub": "user-456", + "aud": "https://api.example.com", + "iss": "https://auth.example.com", + "exp": int(time.time()) + 3600, + "bool_true": True, + "bool_false": False, + "null_val": None, + "int_val": 42, + "float_val": 3.14159, + "nested_list": [1, "two", 3.0, [True, False, None]], + "nested_dict": { + "level1": { + "level2": { + "tag": "deep", + "values": ["a", "b", "c"], + } + } + }, + "unicode": "Привет, мир! 🚀 123", + } + secret = "test-secret-with-plenty-of-bytes-for-hs256-security" + token = oxyjwt.encode(payload, secret, "HS256") + decoded = oxyjwt.decode( + token, + secret, + algorithms=["HS256"], + audience="https://api.example.com", + issuer="https://auth.example.com", + ) + assert decoded == payload + + +def test_encode_json_str_payload() -> None: + payload_str = '{"sub": "user-1", "exp": 2000000000}' + token = oxyjwt.encode_json(payload_str, "secret", algorithm="HS256") + decoded = oxyjwt.decode(token, "secret", algorithms=["HS256"]) + assert decoded["sub"] == "user-1" + assert decoded["exp"] == 2000000000 + + +def test_encode_json_bytes_payload() -> None: + payload_b = b'{"sub": "user-2", "role": "admin"}' + token = oxyjwt.encode_json(payload_b, "secret") + decoded = oxyjwt.decode( + token, + "secret", + algorithms=["HS256"], + options={"verify_exp": False}, + ) + assert decoded == {"sub": "user-2", "role": "admin"} + + +def test_encode_json_with_headers_and_key_objects() -> None: + key = oxyjwt.EncodingKey.from_secret(b"secret-key-123456789012345678901234567890") + token = oxyjwt.encode_json( + '{"sub": "user-3"}', + key, + algorithm="HS256", + headers={"kid": "key-id-99", "typ": "JWT"}, + ) + header = oxyjwt.get_unverified_header(token) + assert header["kid"] == "key-id-99" + assert header["typ"] == "JWT" + decoded = oxyjwt.decode( + token, + "secret-key-123456789012345678901234567890", + algorithms=["HS256"], + options={"verify_exp": False}, + ) + assert decoded["sub"] == "user-3" + + +def test_encode_json_pyjwt_class_instance() -> None: + inst = oxyjwt.PyJWT() + token = inst.encode_json('{"sub": "user-4"}', "secret") + decoded = inst.decode(token, "secret", algorithms=["HS256"], options={"verify_exp": False}) + assert decoded["sub"] == "user-4" + + +def test_encode_json_whitespace_tolerance() -> None: + token = oxyjwt.encode_json(" \n\t {\"sub\": \"ws\"} \r\n ", "secret") + decoded = oxyjwt.decode(token, "secret", algorithms=["HS256"], options={"verify_exp": False}) + assert decoded["sub"] == "ws" + + +def test_encode_json_invalid_payload_error() -> None: + for invalid in ["[1, 2, 3]", "123", '"just-a-string"', "", " "]: + with pytest.raises(oxyjwt.EncodeError, match="Expecting a dict object"): + oxyjwt.encode_json(invalid, "secret") + + with pytest.raises(oxyjwt.EncodeError, match="payload_json must be a UTF-8 str or bytes"): + oxyjwt.encode_json(12345, "secret") # type: ignore[arg-type] + + with pytest.raises(oxyjwt.EncodeError, match="payload_json must be a UTF-8 str or bytes"): + oxyjwt.encode_json({"sub": "dict"}, "secret") # type: ignore[arg-type] + + +def test_encode_json_algorithm_override_rejected() -> None: + with pytest.raises(oxyjwt.InvalidAlgorithmError, match="cannot override"): + oxyjwt.encode_json('{"sub": "u"}', "secret", algorithm="HS256", headers={"alg": "RS256"}) + + +def test_encode_static_header_equivalence() -> None: + payload = {"sub": "user-42", "scope": "read"} + secret = "secret-key-32-bytes-long-for-test" + token_default = oxyjwt.encode(payload, secret, algorithm="HS256") + token_explicit = oxyjwt.encode( + payload, secret, algorithm="HS256", headers={"typ": "JWT", "alg": "HS256"} + ) + assert token_default == token_explicit + assert oxyjwt.get_unverified_header(token_default) == {"typ": "JWT", "alg": "HS256"} + + +def test_encode_json_static_header_equivalence() -> None: + payload_json = '{"sub":"user-42","scope":"read"}' + secret = "secret-key-32-bytes-long-for-test" + token_default = oxyjwt.encode_json(payload_json, secret, algorithm="HS256") + token_explicit = oxyjwt.encode_json( + payload_json, secret, algorithm="HS256", headers={"typ": "JWT", "alg": "HS256"} + ) + assert token_default == token_explicit + + +def test_sub_non_string_rejected_by_rust() -> None: + secret = "secret-key-32-bytes-long-for-test" + token_int_sub = oxyjwt.encode_json('{"sub": 123}', secret) + with pytest.raises(oxyjwt.InvalidSubjectError, match="Subject must be a string"): + oxyjwt.decode(token_int_sub, secret, algorithms=["HS256"]) + + token_list_sub = oxyjwt.encode_json('{"sub": ["admin"]}', secret) + with pytest.raises(oxyjwt.InvalidSubjectError, match="Subject must be a string"): + oxyjwt.decode(token_list_sub, secret, algorithms=["HS256"]) + + +def test_unrequested_aud_rejected_by_rust() -> None: + secret = "secret-key-32-bytes-long-for-test" + token_with_aud = oxyjwt.encode_json('{"sub": "u", "aud": "api"}', secret) + with pytest.raises(oxyjwt.InvalidAudienceError, match="Invalid audience"): + oxyjwt.decode(token_with_aud, secret, algorithms=["HS256"]) + + + + diff --git a/tests/test_errors.py b/tests/test_errors.py index 7ee5f93..526f1e5 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -59,3 +59,54 @@ def test_exception_hierarchy_pyjwt_shape() -> None: assert issubclass(oxyjwt.InvalidSignatureError, oxyjwt.DecodeError) assert issubclass(oxyjwt.ExpiredSignatureError, oxyjwt.InvalidTokenError) assert oxyjwt.PyJWTError is oxyjwt.OxyJWTError + + +def test_decode_rejects_missing_or_non_string_alg_in_header() -> None: + # {"typ": "JWT"} + t1 = "eyJ0eXAiOiJKV1QifQ.e30.sig" + with pytest.raises(oxyjwt.DecodeError, match="missing field `alg` in JWT header"): + oxyjwt.decode(t1, "secret", algorithms=["HS256"]) + + # {"alg": 123} + t2 = "eyJhbGciOjEyM30.e30.sig" + with pytest.raises(oxyjwt.DecodeError, match="missing field `alg` in JWT header"): + oxyjwt.decode(t2, "secret", algorithms=["HS256"]) + + # {"alg": null} + t3 = "eyJhbGciOm51bGx9.e30.sig" + with pytest.raises(oxyjwt.DecodeError, match="missing field `alg` in JWT header"): + oxyjwt.decode(t3, "secret", algorithms=["HS256"]) + + +def test_decode_rejects_non_object_header() -> None: + # [1, 2] + t1 = "WzEsMl0.e30.sig" + with pytest.raises(oxyjwt.DecodeError, match="Invalid header string: must be a json object"): + oxyjwt.decode(t1, "secret", algorithms=["HS256"]) + + # "string" + t2 = "InN0cmluZyI.e30.sig" + with pytest.raises(oxyjwt.DecodeError, match="Invalid header string: must be a json object"): + oxyjwt.decode(t2, "secret", algorithms=["HS256"]) + + +def test_decode_rejects_malformed_header_json() -> None: + # {"alg": + t = "eyJhbGciOi.e30.sig" + with pytest.raises(oxyjwt.DecodeError): + oxyjwt.decode(t, "secret", algorithms=["HS256"]) + + +def test_decode_with_custom_header_fields() -> None: + token = oxyjwt.encode( + {"sub": "user42"}, + "a-very-long-secret-key-that-is-secure-32b", + algorithm="HS256", + headers={"kid": "my-key-1", "custom": "some-value"}, + ) + claims = oxyjwt.decode( + token, + "a-very-long-secret-key-that-is-secure-32b", + algorithms=["HS256"], + ) + assert claims == {"sub": "user42"} diff --git a/tests/test_jwk.py b/tests/test_jwk.py index 25ef438..e8f5e12 100644 --- a/tests/test_jwk.py +++ b/tests/test_jwk.py @@ -222,3 +222,62 @@ def worker() -> None: assert errors == [] +def test_decoding_key_from_jwk_str_bytes_bytearray_dict() -> None: + secret = b"signing-key-32-bytes-long-ok!!" + k = base64.urlsafe_b64encode(secret).decode("ascii").rstrip("=") + jwk_dict = {"kty": "oct", "k": k, "kid": "k1"} + jwk_str = json.dumps(jwk_dict) + jwk_bytes = jwk_str.encode("utf-8") + jwk_bytearray = bytearray(jwk_bytes) + + key_from_dict = oxyjwt.DecodingKey.from_jwk(jwk_dict) + key_from_str = oxyjwt.DecodingKey.from_jwk(jwk_str) + key_from_bytes = oxyjwt.DecodingKey.from_jwk(jwk_bytes) + key_from_bytearray = oxyjwt.DecodingKey.from_jwk(jwk_bytearray) + + tok = oxyjwt.encode({"sub": "user"}, secret, algorithm="HS256") + assert oxyjwt.decode(tok, key_from_dict, algorithms=["HS256"])["sub"] == "user" + assert oxyjwt.decode(tok, key_from_str, algorithms=["HS256"])["sub"] == "user" + assert oxyjwt.decode(tok, key_from_bytes, algorithms=["HS256"])["sub"] == "user" + assert oxyjwt.decode(tok, key_from_bytearray, algorithms=["HS256"])["sub"] == "user" + + +def test_pyjwk_and_pyjwkset_from_json_bytes() -> None: + secret = b"signing-key-32-bytes-long-ok!!" + k = base64.urlsafe_b64encode(secret).decode("ascii").rstrip("=") + jwk_dict = {"kty": "oct", "k": k, "kid": "k1"} + jwk_bytes = json.dumps(jwk_dict).encode("utf-8") + jwks_bytes = json.dumps({"keys": [jwk_dict]}).encode("utf-8") + + jwk = PyJWK(jwk_bytes) + assert jwk.key_id == "k1" + assert jwk.key is not None + + jwk_from_json = PyJWK.from_json(jwk_bytes) + assert jwk_from_json.key_id == "k1" + + jwks = PyJWKSet.from_json(jwks_bytes) + assert jwks["k1"].key is not None + + +def test_decoding_key_from_jwk_error_parity() -> None: + from oxyjwt.exceptions import InvalidKeyError + + # Malformed JSON string + with pytest.raises(InvalidKeyError, match="invalid JWK:"): + oxyjwt.DecodingKey.from_jwk("not a json") + + # Malformed JSON bytes + with pytest.raises(InvalidKeyError, match="invalid JWK:"): + oxyjwt.DecodingKey.from_jwk(b"{not a json") + + # Non-JWK JSON structure (missing kty) + with pytest.raises(InvalidKeyError, match="invalid JWK:"): + oxyjwt.DecodingKey.from_jwk({"foo": "bar"}) + + # Non-serializable dict key + with pytest.raises(InvalidKeyError, match="invalid JWK value:"): + oxyjwt.DecodingKey.from_jwk({1: "bad-key-type"}) # type: ignore[dict-item] + + + diff --git a/tests/test_security_regression.py b/tests/test_security_regression.py index 1f88b15..127cd6b 100644 --- a/tests/test_security_regression.py +++ b/tests/test_security_regression.py @@ -276,3 +276,75 @@ def test_security_disallowed_alg_rejected_before_jwks_http() -> None: with pytest.raises(oxyjwt.InvalidAlgorithmError, match="not allowed"): client.get_signing_key_from_jwt(token, algorithms=["RS256"]) assert _JWKHandler.request_count == 0 + + +# --- Critical header extensions rejection (issue #132, RFC 7515 §4.1.11) --- + + +def test_security_rejects_unrecognized_crit_header() -> None: + import jwt + + secret = "benchmark-secret-with-at-least-32-bytes-len" + token = jwt.encode( + {"sub": "user_42", "exp": int(time.time()) + 3600}, + secret, + algorithm="HS256", + headers={"crit": ["exp-strict-mode"], "exp-strict-mode": True}, + ) + + with pytest.raises( + oxyjwt.InvalidTokenError, + match="Unsupported critical header parameter in 'crit': 'exp-strict-mode'", + ): + oxyjwt.decode(token, secret, algorithms=["HS256"]) + + with pytest.raises( + oxyjwt.InvalidTokenError, + match="Unsupported critical header parameter in 'crit': 'exp-strict-mode'", + ): + oxyjwt.decode_complete(token, secret, algorithms=["HS256"]) + + with pytest.raises( + oxyjwt.InvalidTokenError, + match="Unsupported critical header parameter in 'crit': 'exp-strict-mode'", + ): + oxyjwt.decode_complete(token, options={"verify_signature": False}) + + +def test_security_rejects_malformed_crit_headers() -> None: + import hashlib + import hmac + import jwt + + secret = "benchmark-secret-with-at-least-32-bytes-len" + + # Empty crit array + token_empty = jwt.encode( + {"sub": "user_42", "exp": int(time.time()) + 3600}, + secret, + algorithm="HS256", + headers={"crit": []}, + ) + with pytest.raises(oxyjwt.InvalidTokenError): + oxyjwt.decode(token_empty, secret, algorithms=["HS256"]) + + # Non-string item in crit + h = _b64u( + json.dumps( + {"alg": "HS256", "typ": "JWT", "crit": [123]}, + separators=(",", ":"), + ).encode() + ) + p = _b64u( + json.dumps( + {"sub": "user_42", "exp": int(time.time()) + 3600}, + separators=(",", ":"), + ).encode() + ) + sig = _b64u( + hmac.new(secret.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest() + ) + token_non_string = f"{h}.{p}.{sig}" + with pytest.raises(oxyjwt.InvalidTokenError, match="must be strings"): + oxyjwt.decode(token_non_string, secret, algorithms=["HS256"]) + diff --git a/tests/typing/test_public_api.py b/tests/typing/test_public_api.py index 6d6e7fd..232b0cd 100644 --- a/tests/typing/test_public_api.py +++ b/tests/typing/test_public_api.py @@ -5,7 +5,15 @@ from datetime import timedelta import oxyjwt -from oxyjwt import DecodingKey, EncodingKey, PyJWK, PyJWKClient, PyJWKSet, PyJWT +from oxyjwt import ( + DecodingKey, + EncodingKey, + PyJWK, + PyJWKClient, + PyJWKSet, + PyJWT, + encode_json, +) def exercise_public_api() -> None: @@ -15,6 +23,13 @@ def exercise_public_api() -> None: algorithm="HS256", headers={"kid": "k1"}, ) + tok_json: str = encode_json( + b'{"sub": "user", "exp": 9999999999}', + "secret", + algorithm="HS256", + headers={"kid": "k1"}, + ) + _ = oxyjwt.encode_json('{"sub": "user"}', "secret") claims: dict[str, object] = oxyjwt.decode( tok, "secret", @@ -33,6 +48,7 @@ def exercise_public_api() -> None: inst = PyJWT(options={"verify_exp": False}) _ = inst.encode({"a": 1}, "secret") + _ = inst.encode_json('{"a": 1}', "secret") _ = inst.decode(tok, "secret", algorithms=["HS256"]) enc = EncodingKey.from_secret(b"secret")