Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
factory) on every call. HMAC secrets passed as raw `str`/`bytes` are
unaffected — there is no persistent key object to borrow from in that case.
No behavioural change. (#121)
- **Less Python-side work on the plain `decode`/`decode_complete` fast path.**
The `_is_plain_decode` argument check and the RFC 7797 `detached_payload`
pre-check are now inlined into `decode`/`decode_complete` instead of going
through a 9-argument and a keyword-argument function call. `exp` is only
re-checked in Python when it is not a plain `int`: Rust already enforces
`exp > now` with an integer clock on this path, which is the exact same
predicate for an integer `exp`, so re-running it only cost a `time.time()`
call with no behavioural difference; a `float` `exp` still gets the Python
recheck, since Rust rounds a fractional value to the nearest second where
PyJWT (and this check, for parity) truncates it, and the two can disagree
right at the boundary. Measured (HS256, prebuilt token, `int` `exp`):
wrapper overhead over the native call dropped from ~0.85 microseconds to
~0.57 microseconds (~33% less); with no time claims at all, ~0.37
microseconds (~57% less). No behavioural change. (#122)

## [0.7.0] — 2026-08-26

Expand Down
160 changes: 91 additions & 69 deletions python/oxyjwt/api_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,40 +115,6 @@ def _check_string_or_iterable(value: object, name: str) -> None:
raise TypeError(f"{name} must be a string, iterable or None")


def _is_plain_decode(
options: dict[str, Any] | None,
verify: bool | None,
detached_payload: bytes | None,
audience: object,
issuer: object,
subject: str | None,
leeway: float | timedelta,
typ: str | None,
algorithms: list[str] | None,
) -> bool:
"""True when no argument changes the default verified-decode behaviour.

With nothing but ``algorithms`` supplied, Rust validates ``exp``/``nbf``
and has no audience/issuer/subject to check, so the remaining claim rules
reduce to :meth:`PyJWT._validate_claims_default`. A ``timedelta`` leeway
never compares equal to ``0`` and therefore takes the general path, as do
algorithm containers the native module cannot read directly (a set or an
iterator), which the general path normalises with ``list()``.
"""
return (
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))
)


def _json_default_from_encoder(encoder_cls: type[JSONEncoder]) -> Callable[[Any], Any]:
enc = encoder_cls()

Expand Down Expand Up @@ -222,18 +188,47 @@ def decode(
typ: str | None = None,
**kwargs: Any,
) -> Any:
# Inlined instead of a shared `_is_plain_decode` helper: this condition
# runs on every call, and a 9-argument function call costs more than
# the comparisons themselves. True when no argument changes the
# default verified-decode behaviour: with nothing but `algorithms`
# supplied, Rust validates exp/nbf and has no audience/issuer/subject
# to check, so the remaining claim rules reduce to
# `_validate_claims_default`. A `timedelta` leeway never compares
# equal to `0` and therefore takes the general path, as do algorithm
# containers the native module cannot read directly (a set or an
# iterator), which the general path normalises with `list()`.
if (
self._default_options
and not kwargs
and _is_plain_decode(
options, verify, detached_payload, audience, issuer, subject, leeway,
typ, algorithms,
)
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))
):
token = jwt if isinstance(jwt, str) else jwt.decode("utf-8")
_require_detached_payload_for_rfc7797(
token, detached_payload=None, verify_signature=True
)
# Inlined `_require_detached_payload_for_rfc7797` for the same
# reason: detached_payload/verify_signature are always None/True
# here, so its own early-return check is dead code on this path.
# ".." can only appear via an empty (b64:false) payload segment,
# since base64url never emits a dot; that keeps ordinary tokens
# off the split/header-parse path below.
if ".." in token:
segments = token.split(".", 2)
if len(segments) >= 3 and segments[1] == "":
header_peek = _as_plain_dict(_oxyjwt.get_unverified_header(token))
if header_peek.get("b64") is False:
raise DecodeError(
'It is required that you pass in a value for the '
'"detached_payload" argument to decode a message '
"having the b64 header set to false."
)
payload = _oxyjwt.decode(token, key, algorithms)
self._validate_claims_default(payload)
return payload
Expand Down Expand Up @@ -275,18 +270,33 @@ def decode_complete(
typ: str | None = None,
**kwargs: Any,
) -> dict[str, Any]:
# See the matching block in `decode()` for why this is inlined rather
# than calling a shared `_is_plain_decode` / rfc7797-peek helper.
if (
self._default_options
and not kwargs
and _is_plain_decode(
options, verify, detached_payload, audience, issuer, subject, leeway,
typ, algorithms,
)
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))
):
token = jwt if isinstance(jwt, str) else jwt.decode("utf-8")
_require_detached_payload_for_rfc7797(
token, detached_payload=None, verify_signature=True
)
if ".." in token:
segments = token.split(".", 2)
if len(segments) >= 3 and segments[1] == "":
header_peek = _as_plain_dict(_oxyjwt.get_unverified_header(token))
if header_peek.get("b64") is False:
raise DecodeError(
'It is required that you pass in a value for the '
'"detached_payload" argument to decode a message '
"having the b64 header set to false."
)
payload, header, signature = _oxyjwt.decode_verified_complete(
token, key, algorithms
)
Expand Down Expand Up @@ -454,30 +464,42 @@ def _validate_claims_default(self, payload: dict[str, Any]) -> None:
``rust_standard_claims`` true: Rust already validated ``exp``/``nbf``,
so only ``iat``, the Python ``exp`` boundary, the "no audience
expected" rule and the ``sub`` type check remain.
"""
now = time.time()

``exp`` is only re-checked here when it is not a plain ``int``. Rust
already enforced ``exp > now`` using an integer clock (leeway is 0 on
this path), and for an integer ``exp`` that is the exact same
predicate as the truncating check below, so redoing it would only
cost a ``time.time()`` call for no behavioural difference. A ``float``
``exp`` still needs it: Rust rounds a fractional value to the nearest
second, while PyJWT (and the check below, for parity) truncates it,
so the two can disagree right at the boundary.
"""
iat = payload.get("iat", _MISSING)
if iat is not _MISSING:
try:
iat_value = int(iat)
except (ValueError, TypeError) as e:
raise InvalidIssuedAtError(
"Issued At claim (iat) must be an integer."
) from e
if iat_value > now:
raise ImmatureSignatureError("The token is not yet valid (iat)")

exp = payload.get("exp", _MISSING)
if exp is not _MISSING:
try:
exp_value = int(exp)
except (ValueError, TypeError) as e:
raise DecodeError(
"Expiration Time claim (exp) must be an integer."
) from e
if exp_value <= now:
raise ExpiredSignatureError("Signature has expired")
exp_needs_check = exp is not _MISSING and type(exp) is not int

if iat is not _MISSING or exp_needs_check:
now = time.time()

if iat is not _MISSING:
try:
iat_value = int(iat)
except (ValueError, TypeError) as e:
raise InvalidIssuedAtError(
"Issued At claim (iat) must be an integer."
) from e
if iat_value > now:
raise ImmatureSignatureError("The token is not yet valid (iat)")

if exp_needs_check:
try:
exp_value = int(exp)
except (ValueError, TypeError) as e:
raise DecodeError(
"Expiration Time claim (exp) must be an integer."
) from e
if exp_value <= now:
raise ExpiredSignatureError("Signature has expired")

if payload.get("aud"):
raise InvalidAudienceError("Invalid audience")
Expand Down
19 changes: 19 additions & 0 deletions tests/test_decode_fast_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,25 @@ def test_timedelta_leeway_still_honoured() -> None:
)["sub"] == "u"


def test_float_exp_boundary_still_checked_in_python() -> None:
"""An integer `exp` skips the redundant Python recheck (Rust's own
boundary check is exact for it), but a `float` `exp` must not.

Pin `exp` to `K + 0.6` for the current whole second `K`: Rust rounds that
up to `K + 1` and, checked within the same second `K`, considers it not
yet expired. PyJWT (and the Python recheck below, for parity) truncates
it back down to `K`, which is already <= "now", so the token must still
be rejected. Adding a fraction to `time.time()` instead (`now + 0.6`)
would not reproduce this: it lands in the future either way and both
sides agree the token is valid.
"""
current_second = int(time.time())
exp = current_second + 0.6
token = oxyjwt.encode({"sub": "u", "exp": exp}, SECRET, "HS256")
with pytest.raises(oxyjwt.ExpiredSignatureError):
oxyjwt.decode(token, SECRET, algorithms=["HS256"])


class TestRequiredClaims:
"""`require` follows PyJWT: a JSON null counts as an absent claim."""

Expand Down
Loading