Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-PyJWT for openSUSE:Factory 
checked in at 2026-06-13 18:45:45
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-PyJWT (Old)
 and      /work/SRC/openSUSE:Factory/.python-PyJWT.new.1981 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-PyJWT"

Sat Jun 13 18:45:45 2026 rev:39 rq:1358967 version:2.13.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-PyJWT/python-PyJWT.changes        
2026-03-20 21:20:21.810888495 +0100
+++ /work/SRC/openSUSE:Factory/.python-PyJWT.new.1981/python-PyJWT.changes      
2026-06-13 18:46:37.891669707 +0200
@@ -1,0 +2,51 @@
+Fri Jun 12 08:31:25 UTC 2026 - Georg Pfuetzenreuter 
<[email protected]>
+
+- Update to 2.13.0
+  - Security
+    * CVE-2026-48526 (bsc#1266802) — JWK JSON accepted as HMAC secret
+      (algorithm confusion). HMACAlgorithm.prepare_key previously rejected PEM-
+      and SSH-formatted asymmetric keys but did not catch a JWK passed as a raw
+      JSON string. In a verifier configured with both symmetric and asymmetric
+      algorithms in algorithms=[…] and a raw-JSON JWK as the key, an attacker
+      could forge HS256 tokens using the JWK text as the HMAC secret. The guard
+      has been extended to reject any JWK-shaped JSON.
+    * CVE-2026-48523 (bsc#1266799) — Algorithm allow-list bypass with PyJWK /
+      PyJWKClient. When verifying with a PyJWK, the caller's algorithms=[…]
+      allow-list was checked against the token header alg as a string only;
+      actual verification used the algorithm bound to the PyJWK. An attacker
+      who controlled a registered JWKS key could sign with one algorithm and
+      advertise another on the header. PyJWT now requires the token header alg
+      to match the PyJWK's algorithm before verification.
+    * CVE-2026-48525 (bsc#1266801) — DoS via base64 decode of unused payload
+      segment when b64=false. For detached-payload JWS (b64=false), the
+      compact-form payload segment was base64-decoded before being discarded in
+      favor of the caller-supplied detached_payload. An attacker could inflate
+      the unused segment to force CPU + memory cost without holding a valid
+      signature. The segment is now required to be empty per RFC 7515
+      Appendix F, and is no longer decoded.
+    * CVE-2026-48522 (bsc#1266798) — PyJWKClient accepts non-HTTP(S) URIs.
+      PyJWKClient.fetch_data passed its URI to urllib.request.urlopen, which
+      by default also handles file://, ftp://, and data: schemes. An
+      application that fed an attacker-influenced URI into PyJWKClient could be
+      coerced into reading local files or reaching other unintended schemes.
+      PyJWKClient now rejects any URI whose scheme isn't http or https.
+    * CVE-2026-48524 (bsc#1266800) — PyJWKClient cache wiped on fetch error. A
+      finally-block put(jwk_set=None) cleared the JWK Set cache whenever a
+      fetch raised, turning a transient JWKS-endpoint outage into application-
+      wide auth failure. The cache write was moved into the success path;
+      transient errors no longer evict valid cached keys.
+  - Fixed
+    * Reject empty HMAC keys outright in HMACAlgorithm.prepare_key with
+      InvalidKeyError instead of accepting them with only a warning. Defends
+      against the os.getenv("JWT_SECRET", "") footgun.
+    * Forward per-call options (including enforce_minimum_key_length) from
+      PyJWT.decode through to PyJWS._verify_signature. The option was
+      previously silently dropped between the two layers, so it only took
+      effect when set on the PyJWT instance.
+    * RFC 7797 §3 compliance for b64=false: the encoder now auto-adds "b64" to
+      crit, and the decoder rejects tokens that set b64=false without listing
+      it in crit
+  - Changed
+    * Migrate the dev, docs, and tests package extras to dependency groups
+
+-------------------------------------------------------------------

Old:
----
  pyjwt-2.12.1.tar.gz

New:
----
  pyjwt-2.13.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-PyJWT.spec ++++++
--- /var/tmp/diff_new_pack.JW2zFi/_old  2026-06-13 18:46:39.883752468 +0200
+++ /var/tmp/diff_new_pack.JW2zFi/_new  2026-06-13 18:46:39.883752468 +0200
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-PyJWT
-Version:        2.12.1
+Version:        2.13.0
 Release:        0
 Summary:        JSON Web Token implementation in Python
 License:        MIT

++++++ pyjwt-2.12.1.tar.gz -> pyjwt-2.13.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/.pre-commit-config.yaml 
new/pyjwt-2.13.0/.pre-commit-config.yaml
--- old/pyjwt-2.12.1/.pre-commit-config.yaml    2026-03-13 20:09:20.000000000 
+0100
+++ new/pyjwt-2.13.0/.pre-commit-config.yaml    2026-05-21 21:53:13.000000000 
+0200
@@ -36,7 +36,7 @@
       - id: pyprojectsort
 
   - repo: https://github.com/python-jsonschema/check-jsonschema
-    rev: "0.37.0"
+    rev: "0.37.1"
     hooks:
       - id: check-github-workflows
       - id: check-readthedocs
@@ -48,7 +48,7 @@
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
     # Ruff version.
-    rev: v0.15.4
+    rev: v0.15.8
     hooks:
       # Run the linter.
       - id: ruff
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/.readthedocs.yaml 
new/pyjwt-2.13.0/.readthedocs.yaml
--- old/pyjwt-2.12.1/.readthedocs.yaml  2026-03-13 20:09:20.000000000 +0100
+++ new/pyjwt-2.13.0/.readthedocs.yaml  2026-05-21 21:53:13.000000000 +0200
@@ -6,14 +6,10 @@
   os: "ubuntu-lts-latest"
   tools:
     python: "3.11"
-
-python:
-  install:
-    - method: "pip"
-      path: "."
-      extra_requirements:
-        - "docs"
-        - "crypto"
+  jobs:
+    install:
+      - "pip install --upgrade pip"
+      - "pip install .[crypto] --group 'docs'"
 
 sphinx:
   configuration: "docs/conf.py"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/CHANGELOG.rst 
new/pyjwt-2.13.0/CHANGELOG.rst
--- old/pyjwt-2.12.1/CHANGELOG.rst      2026-03-13 20:09:20.000000000 +0100
+++ new/pyjwt-2.13.0/CHANGELOG.rst      2026-05-21 21:53:13.000000000 +0200
@@ -4,9 +4,58 @@
 All notable changes to this project will be documented in this file.
 This project adheres to `Semantic Versioning <https://semver.org/>`__.
 
-`Unreleased <https://github.com/jpadilla/pyjwt/compare/2.12.1...HEAD>`__
+`Unreleased <https://github.com/jpadilla/pyjwt/compare/2.13.0...HEAD>`__
 ------------------------------------------------------------------------
 
+`v2.13.0 <https://github.com/jpadilla/pyjwt/compare/2.12.1...2.13.0>`__
+-----------------------------------------------------------------------
+
+Security
+~~~~~~~~
+
+- Reject JWK JSON documents passed as raw HMAC secrets in
+  ``HMACAlgorithm.prepare_key`` to close an algorithm-confusion gap that
+  the existing PEM/SSH guard did not cover. Reported by @aradona91 in
+  `GHSA-xgmm-8j9v-c9wx 
<https://github.com/jpadilla/pyjwt/security/advisories/GHSA-xgmm-8j9v-c9wx>`__.
+- Bind the JWT header ``alg`` to ``PyJWK.algorithm_name`` during
+  verification so the caller's ``algorithms=[...]`` allow-list cannot be
+  bypassed when decoding with a ``PyJWK`` / ``PyJWKClient`` key. Reported
+  by @sushi-gif in `GHSA-jq35-7prp-9v3f 
<https://github.com/jpadilla/pyjwt/security/advisories/GHSA-jq35-7prp-9v3f>`__.
+- Reject non-``http(s)`` URI schemes in ``PyJWKClient`` so attacker-
+  influenced URIs cannot read local files or reach unintended schemes via
+  urllib's default ``file://`` / ``ftp://`` / ``data:`` handlers. Reported
+  by @KEIJOT in `GHSA-993g-76c3-p5m4 
<https://github.com/jpadilla/pyjwt/security/advisories/GHSA-993g-76c3-p5m4>`__.
+- Preserve the cached JWK Set on fetch errors in ``PyJWKClient.fetch_data``.
+  The previous ``finally``-block ``put(None)`` pattern cleared the cache
+  on any transient outage, turning one bad JWKS request into application-
+  wide auth failure. Reported by @eddieran in `GHSA-fhv5-28vv-h8m8 
<https://github.com/jpadilla/pyjwt/security/advisories/GHSA-fhv5-28vv-h8m8>`__.
+- Skip the unconditional base64 decode of the compact-form payload segment
+  when ``b64=false`` is set in the protected header, and require that
+  segment to be empty (RFC 7515 Appendix F detached form). Closes an
+  unauthenticated DoS amplifier. Reported by @thesmartshadow in
+  `GHSA-w7vc-732c-9m39 
<https://github.com/jpadilla/pyjwt/security/advisories/GHSA-w7vc-732c-9m39>`__.
+
+Fixed
+~~~~~
+
+- Reject empty HMAC keys outright in ``HMACAlgorithm.prepare_key`` with
+  ``InvalidKeyError`` instead of accepting them with only a warning.
+  Thanks to @SnailSploit and @spartan8806 for independently flagging the
+  footgun.
+- Forward per-call ``options`` (including ``enforce_minimum_key_length``)
+  from ``PyJWT.decode`` through to ``PyJWS._verify_signature`` so the
+  option actually takes effect when set at the call site rather than only
+  on the ``PyJWT`` instance. Thanks to @WLUB for the report.
+- RFC 7797 §3 compliance for ``b64=false``: the encoder now auto-adds
+  ``"b64"`` to the ``crit`` header parameter, and the decoder rejects
+  tokens that set ``b64=false`` without listing it in ``crit``. Thanks to
+  @MachineLearning-Nerd for the report.
+
+Changed
+~~~~~~~
+
+- Migrate the ``dev``, ``docs``, and ``tests`` package extras to dependency 
groups by @kurtmckee in `#1152 <https://github.com/jpadilla/pyjwt/pull/1152>`__
+
 `v2.12.1 <https://github.com/jpadilla/pyjwt/compare/2.12.0...2.12.1>`__
 ------------------------------------------------------------------------
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/PKG-INFO new/pyjwt-2.13.0/PKG-INFO
--- old/pyjwt-2.12.1/PKG-INFO   2026-03-13 20:09:25.881943500 +0100
+++ new/pyjwt-2.13.0/PKG-INFO   2026-05-21 21:53:18.965076200 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: PyJWT
-Version: 2.12.1
+Version: 2.13.0
 Summary: JSON Web Token implementation in Python
 Author-email: Jose Padilla <[email protected]>
 License-Expression: MIT
@@ -26,21 +26,6 @@
 Requires-Dist: typing_extensions>=4.0; python_version < "3.11"
 Provides-Extra: crypto
 Requires-Dist: cryptography>=3.4.0; extra == "crypto"
-Provides-Extra: dev
-Requires-Dist: coverage[toml]==7.10.7; extra == "dev"
-Requires-Dist: cryptography>=3.4.0; extra == "dev"
-Requires-Dist: pre-commit; extra == "dev"
-Requires-Dist: pytest<9.0.0,>=8.4.2; extra == "dev"
-Requires-Dist: sphinx; extra == "dev"
-Requires-Dist: sphinx-rtd-theme; extra == "dev"
-Requires-Dist: zope.interface; extra == "dev"
-Provides-Extra: docs
-Requires-Dist: sphinx; extra == "docs"
-Requires-Dist: sphinx-rtd-theme; extra == "docs"
-Requires-Dist: zope.interface; extra == "docs"
-Provides-Extra: tests
-Requires-Dist: coverage[toml]==7.10.7; extra == "tests"
-Requires-Dist: pytest<9.0.0,>=8.4.2; extra == "tests"
 Dynamic: license-file
 
 PyJWT
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/PyJWT.egg-info/PKG-INFO 
new/pyjwt-2.13.0/PyJWT.egg-info/PKG-INFO
--- old/pyjwt-2.12.1/PyJWT.egg-info/PKG-INFO    2026-03-13 20:09:25.000000000 
+0100
+++ new/pyjwt-2.13.0/PyJWT.egg-info/PKG-INFO    2026-05-21 21:53:18.000000000 
+0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: PyJWT
-Version: 2.12.1
+Version: 2.13.0
 Summary: JSON Web Token implementation in Python
 Author-email: Jose Padilla <[email protected]>
 License-Expression: MIT
@@ -26,21 +26,6 @@
 Requires-Dist: typing_extensions>=4.0; python_version < "3.11"
 Provides-Extra: crypto
 Requires-Dist: cryptography>=3.4.0; extra == "crypto"
-Provides-Extra: dev
-Requires-Dist: coverage[toml]==7.10.7; extra == "dev"
-Requires-Dist: cryptography>=3.4.0; extra == "dev"
-Requires-Dist: pre-commit; extra == "dev"
-Requires-Dist: pytest<9.0.0,>=8.4.2; extra == "dev"
-Requires-Dist: sphinx; extra == "dev"
-Requires-Dist: sphinx-rtd-theme; extra == "dev"
-Requires-Dist: zope.interface; extra == "dev"
-Provides-Extra: docs
-Requires-Dist: sphinx; extra == "docs"
-Requires-Dist: sphinx-rtd-theme; extra == "docs"
-Requires-Dist: zope.interface; extra == "docs"
-Provides-Extra: tests
-Requires-Dist: coverage[toml]==7.10.7; extra == "tests"
-Requires-Dist: pytest<9.0.0,>=8.4.2; extra == "tests"
 Dynamic: license-file
 
 PyJWT
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/PyJWT.egg-info/requires.txt 
new/pyjwt-2.13.0/PyJWT.egg-info/requires.txt
--- old/pyjwt-2.12.1/PyJWT.egg-info/requires.txt        2026-03-13 
20:09:25.000000000 +0100
+++ new/pyjwt-2.13.0/PyJWT.egg-info/requires.txt        2026-05-21 
21:53:18.000000000 +0200
@@ -4,21 +4,3 @@
 
 [crypto]
 cryptography>=3.4.0
-
-[dev]
-coverage[toml]==7.10.7
-cryptography>=3.4.0
-pre-commit
-pytest<9.0.0,>=8.4.2
-sphinx
-sphinx-rtd-theme
-zope.interface
-
-[docs]
-sphinx
-sphinx-rtd-theme
-zope.interface
-
-[tests]
-coverage[toml]==7.10.7
-pytest<9.0.0,>=8.4.2
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/jwt/__init__.py 
new/pyjwt-2.13.0/jwt/__init__.py
--- old/pyjwt-2.12.1/jwt/__init__.py    2026-03-13 20:09:20.000000000 +0100
+++ new/pyjwt-2.13.0/jwt/__init__.py    2026-05-21 21:53:13.000000000 +0200
@@ -28,7 +28,7 @@
 from .jwks_client import PyJWKClient
 from .warnings import InsecureKeyLengthWarning
 
-__version__ = "2.12.1"
+__version__ = "2.13.0"
 
 __title__ = "PyJWT"
 __description__ = "JSON Web Token implementation in Python"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/jwt/algorithms.py 
new/pyjwt-2.13.0/jwt/algorithms.py
--- old/pyjwt-2.12.1/jwt/algorithms.py  2026-03-13 20:09:20.000000000 +0100
+++ new/pyjwt-2.13.0/jwt/algorithms.py  2026-05-21 21:53:13.000000000 +0200
@@ -325,12 +325,35 @@
     def prepare_key(self, key: str | bytes) -> bytes:
         key_bytes = force_bytes(key)
 
+        if len(key_bytes) == 0:
+            raise InvalidKeyError("HMAC key must not be empty.")
+
         if is_pem_format(key_bytes) or is_ssh_key(key_bytes):
             raise InvalidKeyError(
                 "The specified key is an asymmetric key or x509 certificate 
and"
                 " should not be used as an HMAC secret."
             )
 
+        # Defense against algorithm-confusion attacks: an attacker with
+        # control over the token header can force this code path by setting
+        # alg=HS*, and HMACAlgorithm is the only algorithm that accepts
+        # arbitrary bytes as a valid secret. Other algorithms reject
+        # non-key-shaped input naturally. Even a symmetric (kty=oct) JWK
+        # should be loaded via PyJWK / from_jwk rather than fed as raw JSON
+        # bytes (whose contents are not the secret material).
+        stripped = key_bytes.lstrip()
+        if stripped.startswith(b"{"):
+            try:
+                jwk_obj = json.loads(key_bytes)
+            except ValueError:
+                jwk_obj = None
+            if isinstance(jwk_obj, dict) and "kty" in jwk_obj:
+                raise InvalidKeyError(
+                    "The specified key looks like a JWK and should not be "
+                    "used directly as an HMAC secret. Load it via "
+                    "PyJWK / HMACAlgorithm.from_jwk first."
+                )
+
         return key_bytes
 
     @overload
@@ -420,7 +443,10 @@
 
         def prepare_key(self, key: AllowedRSAKeys | str | bytes) -> 
AllowedRSAKeys:
             if isinstance(key, self._crypto_key_types):
-                return cast(AllowedRSAKeys, key)
+                # Cast is required for type narrowing on Python 3.9's mypy
+                # but redundant on newer mypy versions; suppress both
+                # diagnostics so the line works across all supported envs.
+                return cast(AllowedRSAKeys, key)  # type: 
ignore[redundant-cast,unused-ignore]
 
             if not isinstance(key, (bytes, str)):
                 raise TypeError("Expecting a PEM-formatted key.")
@@ -614,7 +640,8 @@
 
         def prepare_key(self, key: AllowedECKeys | str | bytes) -> 
AllowedECKeys:
             if isinstance(key, self._crypto_key_types):
-                ec_key = cast(AllowedECKeys, key)
+                # See note in RSAAlgorithm.prepare_key.
+                ec_key = cast(AllowedECKeys, key)  # type: 
ignore[redundant-cast,unused-ignore]
                 self._validate_curve(ec_key)
                 return ec_key
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/jwt/api_jws.py 
new/pyjwt-2.13.0/jwt/api_jws.py
--- old/pyjwt-2.12.1/jwt/api_jws.py     2026-03-13 20:09:20.000000000 +0100
+++ new/pyjwt-2.13.0/jwt/api_jws.py     2026-05-21 21:53:13.000000000 +0200
@@ -165,6 +165,15 @@
 
         if is_payload_detached:
             header["b64"] = False
+            # RFC 7797 §3: producers MUST list "b64" in "crit" whenever
+            # "b64" appears in the protected header, so b64-unaware
+            # verifiers don't silently treat an unencoded payload as
+            # base64-encoded.
+            existing_crit = header.get("crit", [])
+            if not isinstance(existing_crit, list):
+                raise InvalidTokenError("Invalid 'crit' header: must be a 
list")
+            if "b64" not in existing_crit:
+                header["crit"] = [*existing_crit, "b64"]
         elif "b64" in header:
             # True is the standard value for b64, so no need for it
             del header["b64"]
@@ -242,6 +251,14 @@
         self._validate_headers(header)
 
         if header.get("b64", True) is False:
+            # RFC 7797 §3: when "b64" is present in the protected header,
+            # it MUST also appear in "crit". A token that sets b64=false
+            # without declaring it critical is malformed.
+            crit = header.get("crit") or []
+            if not isinstance(crit, list) or "b64" not in crit:
+                raise InvalidTokenError(
+                    "The 'b64' header parameter requires 'b64' to be listed in 
'crit'."
+                )
             if detached_payload is None:
                 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.'
@@ -250,7 +267,14 @@
             signing_input = b".".join([signing_input.rsplit(b".", 1)[0], 
payload])
 
         if verify_signature:
-            self._verify_signature(signing_input, header, signature, key, 
algorithms)
+            self._verify_signature(
+                signing_input,
+                header,
+                signature,
+                key,
+                algorithms,
+                options=merged_options,
+            )
 
         return {
             "payload": payload,
@@ -317,10 +341,22 @@
         if not isinstance(header, dict):
             raise DecodeError("Invalid header string: must be a json object")
 
-        try:
-            payload = base64url_decode(payload_segment)
-        except (TypeError, binascii.Error) as err:
-            raise DecodeError("Invalid payload padding") from err
+        if header.get("b64", True) is False:
+            # Detached payload form (RFC 7515 Appendix F): the compact-form
+            # payload segment must be empty; the caller supplies the actual
+            # payload via the `detached_payload` argument in decode_complete.
+            # Skipping the base64 decode here removes an unauthenticated work
+            # amplifier — otherwise an attacker can inflate the unused
+            # segment to force CPU + memory cost before the signature is
+            # even checked.
+            if payload_segment:
+                raise DecodeError("Payload segment must be empty when 'b64' is 
false.")
+            payload = b""
+        else:
+            try:
+                payload = base64url_decode(payload_segment)
+            except (TypeError, binascii.Error) as err:
+                raise DecodeError("Invalid payload padding") from err
 
         try:
             signature = base64url_decode(crypto_segment)
@@ -336,7 +372,10 @@
         signature: bytes,
         key: AllowedPublicKeys | PyJWK | str | bytes = "",
         algorithms: Sequence[str] | None = None,
+        options: SigOptions | None = None,
     ) -> None:
+        effective_options = options if options is not None else self.options
+
         if algorithms is None and isinstance(key, PyJWK):
             algorithms = [key.algorithm_name]
         try:
@@ -348,6 +387,16 @@
             raise InvalidAlgorithmError("The specified alg value is not 
allowed")
 
         if isinstance(key, PyJWK):
+            # The PyJWK has a fixed algorithm bound at construction time.
+            # Verification must use that algorithm, not whatever the token
+            # header advertises, otherwise the caller's allow-list check
+            # above degenerates into a string compare with no behavioural
+            # effect on which algorithm actually verifies the signature.
+            if alg != key.algorithm_name:
+                raise InvalidAlgorithmError(
+                    f"Token algorithm {alg!r} does not match the key's "
+                    f"algorithm {key.algorithm_name!r}"
+                )
             alg_obj = key.Algorithm
             prepared_key = key.key
         else:
@@ -359,7 +408,7 @@
 
         key_length_msg = alg_obj.check_key_length(prepared_key)
         if key_length_msg:
-            if self.options.get("enforce_minimum_key_length", False):
+            if effective_options.get("enforce_minimum_key_length", False):
                 raise InvalidKeyError(key_length_msg)
             else:
                 warnings.warn(key_length_msg, InsecureKeyLengthWarning, 
stacklevel=4)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/jwt/api_jwt.py 
new/pyjwt-2.13.0/jwt/api_jwt.py
--- old/pyjwt-2.12.1/jwt/api_jwt.py     2026-03-13 20:09:20.000000000 +0100
+++ new/pyjwt-2.13.0/jwt/api_jwt.py     2026-05-21 21:53:13.000000000 +0200
@@ -258,6 +258,9 @@
 
         sig_options: SigOptions = {
             "verify_signature": verify_signature,
+            "enforce_minimum_key_length": merged_options.get(
+                "enforce_minimum_key_length", False
+            ),
         }
         decoded = self._jws.decode_complete(
             jwt,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/jwt/jwks_client.py 
new/pyjwt-2.13.0/jwt/jwks_client.py
--- old/pyjwt-2.12.1/jwt/jwks_client.py 2026-03-13 20:09:20.000000000 +0100
+++ new/pyjwt-2.13.0/jwt/jwks_client.py 2026-05-21 21:53:13.000000000 +0200
@@ -6,6 +6,7 @@
 from ssl import SSLContext
 from typing import Any
 from urllib.error import HTTPError, URLError
+from urllib.parse import urlparse
 
 from .api_jwk import PyJWK, PyJWKSet
 from .api_jwt import decode_complete as decode_token
@@ -69,6 +70,16 @@
         """
         if headers is None:
             headers = {}
+        # urllib's default OpenerDirector also handles file://, ftp://, and
+        # data: URIs. Reject anything that isn't http(s) eagerly so a caller
+        # passing an attacker-influenced URL (e.g. taken from a `jku` token
+        # header) can't read local files or reach other unintended schemes.
+        scheme = urlparse(uri).scheme.lower()
+        if scheme not in ("http", "https"):
+            raise PyJWKClientError(
+                f"Invalid JWKS URI scheme {scheme!r}: only 'http' and 'https' "
+                f"are supported."
+            )
         self.uri = uri
         self.jwk_set_cache: JWKSetCache | None = None
         self.headers = headers
@@ -102,7 +113,6 @@
         :returns: The parsed JWK Set as a dictionary.
         :raises PyJWKClientConnectionError: If the HTTP request fails.
         """
-        jwk_set: Any = None
         try:
             r = urllib.request.Request(url=self.uri, headers=self.headers)
             with urllib.request.urlopen(
@@ -115,11 +125,14 @@
             raise PyJWKClientConnectionError(
                 f'Fail to fetch data from the url, err: "{e}"'
             ) from e
-        else:
-            return jwk_set
-        finally:
-            if self.jwk_set_cache is not None:
-                self.jwk_set_cache.put(jwk_set)
+
+        # Only update the cache on a successful fetch. Writing in a
+        # `finally` block with `jwk_set=None` on error clears any
+        # previously-cached JWKS, turning a transient outage into a cache
+        # wipe that breaks legitimate auth.
+        if self.jwk_set_cache is not None:
+            self.jwk_set_cache.put(jwk_set)
+        return jwk_set
 
     def get_jwk_set(self, refresh: bool = False) -> PyJWKSet:
         """Return the JWK Set, using the cache when available.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/pyproject.toml 
new/pyjwt-2.13.0/pyproject.toml
--- old/pyjwt-2.12.1/pyproject.toml     2026-03-13 20:09:20.000000000 +0100
+++ new/pyjwt-2.13.0/pyproject.toml     2026-05-21 21:53:13.000000000 +0200
@@ -4,6 +4,26 @@
     "setuptools>=77.0.3",
 ]
 
+[dependency-groups]
+dev = [
+    "coverage[toml]==7.10.7",
+    "cryptography>=3.4.0",
+    "pre-commit",
+    "pytest>=8.4.2,<9.0.0",
+    "sphinx",
+    "sphinx-rtd-theme",
+    "zope.interface",
+]
+docs = [
+    "sphinx",
+    "sphinx-rtd-theme",
+    "zope.interface",
+]
+tests = [
+    "coverage[toml]==7.10.7",
+    "pytest>=8.4.2,<9.0.0",
+]
+
 [project]
 authors = [
     { email = "[email protected]", name = "Jose Padilla" },
@@ -46,24 +66,6 @@
 crypto = [
     "cryptography>=3.4.0",
 ]
-dev = [
-    "coverage[toml]==7.10.7",
-    "cryptography>=3.4.0",
-    "pre-commit",
-    "pytest>=8.4.2,<9.0.0",
-    "sphinx",
-    "sphinx-rtd-theme",
-    "zope.interface",
-]
-docs = [
-    "sphinx",
-    "sphinx-rtd-theme",
-    "zope.interface",
-]
-tests = [
-    "coverage[toml]==7.10.7",
-    "pytest>=8.4.2,<9.0.0",
-]
 
 [project.readme]
 content-type = "text/x-rst"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/tests/test_algorithms.py 
new/pyjwt-2.13.0/tests/test_algorithms.py
--- old/pyjwt-2.12.1/tests/test_algorithms.py   2026-03-13 20:09:20.000000000 
+0100
+++ new/pyjwt-2.13.0/tests/test_algorithms.py   2026-05-21 21:53:13.000000000 
+0200
@@ -122,6 +122,38 @@
             with pytest.raises(InvalidKeyError):
                 algo.from_jwk(keyfile.read())
 
+    @pytest.mark.parametrize("empty_key", ["", b""])
+    def test_hmac_prepare_key_rejects_empty_key(
+        self, empty_key: Union[str, bytes]
+    ) -> None:
+        algo = HMACAlgorithm(HMACAlgorithm.SHA256)
+
+        with pytest.raises(InvalidKeyError, match="must not be empty"):
+            algo.prepare_key(empty_key)
+
+    @pytest.mark.parametrize(
+        "jwk_file",
+        [
+            "jwk_rsa_pub.json",
+            "jwk_ec_pub_P-256.json",
+            "jwk_okp_pub_Ed25519.json",
+            "jwk_hmac.json",
+        ],
+    )
+    def test_hmac_prepare_key_rejects_jwk_json(self, jwk_file: str) -> None:
+        algo = HMACAlgorithm(HMACAlgorithm.SHA256)
+
+        with open(key_path(jwk_file)) as keyfile:
+            with pytest.raises(InvalidKeyError, match="looks like a JWK"):
+                algo.prepare_key(keyfile.read())
+
+    def test_hmac_prepare_key_accepts_json_without_kty(self) -> None:
+        # JSON that doesn't look like a JWK (no "kty") should not be 
misclassified.
+        algo = HMACAlgorithm(HMACAlgorithm.SHA256)
+
+        key = algo.prepare_key('{"this": "is just a json-shaped secret"}')
+        assert key == b'{"this": "is just a json-shaped secret"}'
+
     @crypto_required
     def test_rsa_should_parse_pem_public_key(self) -> None:
         algo = RSAAlgorithm(RSAAlgorithm.SHA256)
@@ -1452,11 +1484,10 @@
         assert msg is not None
         assert "64" in msg
 
-    def test_hmac_empty_key_returns_warning_message(self) -> None:
+    def test_hmac_empty_key_rejected_outright(self) -> None:
         algo = HMACAlgorithm(HMACAlgorithm.SHA256)
-        key = algo.prepare_key(b"")
-        msg = algo.check_key_length(key)
-        assert msg is not None
+        with pytest.raises(InvalidKeyError, match="must not be empty"):
+            algo.prepare_key(b"")
 
     def test_hmac_exact_minimum_no_warning(self) -> None:
         algo = HMACAlgorithm(HMACAlgorithm.SHA256)
@@ -1603,6 +1634,35 @@
         with pytest.raises(InvalidKeyError):
             pyjwt_enforce.decode(token, "short", algorithms=["HS256"])
 
+    def test_pyjwt_decode_honors_per_call_enforce_minimum_key_length(self) -> 
None:
+        # Regression: per-call options passed to PyJWT.decode() must be
+        # forwarded to the JWS verification layer. enforce_minimum_key_length
+        # was previously dropped between PyJWT.decode_complete and
+        # PyJWS._verify_signature.
+        import jwt
+
+        adequate_key = "a" * 32
+        token = jwt.encode({"hello": "world"}, adequate_key, algorithm="HS256")
+
+        # Module-level singleton path with per-call enforcement.
+        with pytest.raises(InvalidKeyError, match="below"):
+            jwt.decode(
+                token,
+                "short",
+                algorithms=["HS256"],
+                options={"enforce_minimum_key_length": True},
+            )
+
+        # PyJWT() instance path (instance default off, per-call on).
+        pyjwt = jwt.PyJWT()
+        with pytest.raises(InvalidKeyError, match="below"):
+            pyjwt.decode(
+                token,
+                "short",
+                algorithms=["HS256"],
+                options={"enforce_minimum_key_length": True},
+            )
+
     def test_pyjwt_encode_no_warning_adequate_key(self) -> None:
         import warnings
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/tests/test_api_jws.py 
new/pyjwt-2.13.0/tests/test_api_jws.py
--- old/pyjwt-2.12.1/tests/test_api_jws.py      2026-03-13 20:09:20.000000000 
+0100
+++ new/pyjwt-2.13.0/tests/test_api_jws.py      2026-05-21 21:53:13.000000000 
+0200
@@ -9,10 +9,11 @@
 from jwt.exceptions import (
     DecodeError,
     InvalidAlgorithmError,
+    InvalidKeyError,
     InvalidSignatureError,
     InvalidTokenError,
 )
-from jwt.utils import base64url_decode
+from jwt.utils import base64url_decode, base64url_encode
 from jwt.warnings import RemovedInPyjwt3Warning
 
 from .utils import crypto_required, key_path, no_crypto_required
@@ -397,6 +398,33 @@
         with pytest.raises(InvalidAlgorithmError):
             jws.decode(example_jws, jwk)
 
+    def test_decodes_with_jwk_rejects_header_alg_outside_jwk_alg(
+        self, jws: PyJWS
+    ) -> None:
+        # Token header says HS256 and the caller's allow-list also accepts
+        # HS256, but the PyJWK is bound to HS512. Even though the allow-list
+        # would pass, verification must be locked to the PyJWK's algorithm
+        # rather than the header's — otherwise an attacker who controls a
+        # registered key can advertise a disallowed algorithm in the header
+        # and have it accepted.
+        jwk = PyJWK(
+            {
+                "kty": "oct",
+                "alg": "HS512",
+                "k": "c2VjcmV0",  # "secret"
+            }
+        )
+        example_jws = (
+            b"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9."
+            b"aGVsbG8gd29ybGQ."
+            b"gEW0pdU4kxPthjtehYdhxB9mMOGajt1xCKlGGXDJ8PM"
+        )
+
+        with pytest.raises(
+            InvalidAlgorithmError, match="does not match the key's algorithm"
+        ):
+            jws.decode(example_jws, jwk, algorithms=["HS256", "HS512"])
+
     # 'Control' Elliptic Curve jws created by another library.
     # Used to test for regressions that could affect both
     # encoding / decoding operations equally (causing tests
@@ -511,18 +539,16 @@
         right_secret = "foo"
         jws_message = jws.encode(payload, right_secret)
 
-        with pytest.raises(DecodeError):
+        with pytest.raises(InvalidKeyError, match="must not be empty"):
             jws.decode(jws_message, algorithms=["HS256"])
 
     def test_verify_signature_with_no_secret(self, jws: PyJWS, payload: bytes) 
-> None:
         right_secret = "foo"
         jws_message = jws.encode(payload, right_secret)
 
-        with pytest.raises(DecodeError) as exc:
+        with pytest.raises(InvalidKeyError, match="must not be empty"):
             jws.decode(jws_message, algorithms=["HS256"])
 
-        assert "Signature verification" in str(exc.value)
-
     def test_verify_signature_with_no_algo_header_throws_exception(
         self, jws: PyJWS, payload: bytes
     ) -> None:
@@ -957,13 +983,97 @@
         assert "b64" not in msg_header_obj
         assert msg_payload
 
-    def test_decode_detached_content_without_proper_argument(self, jws: PyJWS) 
-> None:
-        example_jws = (
-            "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2V9"
-            "."
-            ".65yNkX_ZH4A_6pHaTL_eI84OXOHtfl4K0k5UnlXZ8f4"
+    def test_encode_b64_false_auto_adds_b64_to_crit(
+        self, jws: PyJWS, payload: bytes
+    ) -> None:
+        # RFC 7797 §3: producers MUST list "b64" in "crit" whenever "b64"
+        # appears in the protected header.
+        secret = "secret"
+        token = jws.encode(payload, secret, algorithm="HS256", 
is_payload_detached=True)
+
+        msg_header, _, _ = token.split(".")
+        header_obj = json.loads(base64url_decode(msg_header.encode()))
+
+        assert header_obj["b64"] is False
+        assert "b64" in header_obj.get("crit", [])
+
+    def test_encode_b64_false_preserves_existing_crit_entries(
+        self, jws: PyJWS, payload: bytes
+    ) -> None:
+        secret = "secret"
+        # Caller-supplied crit (containing a hypothetical extension that
+        # PyJWT does support) should be preserved alongside the auto-added
+        # "b64" marker.
+        token = jws.encode(
+            payload,
+            secret,
+            algorithm="HS256",
+            headers={"b64": False, "crit": ["b64"]},
+        )
+
+        header_obj = json.loads(base64url_decode(token.split(".")[0].encode()))
+        assert header_obj["crit"] == ["b64"]
+
+    def test_decode_b64_false_rejects_non_empty_payload_segment(
+        self, jws: PyJWS, payload: bytes
+    ) -> None:
+        # RFC 7515 Appendix F detached form: when b64=false, the compact-
+        # serialization payload segment must be empty. PyJWT must reject a
+        # non-empty middle segment without doing any base64-decoding work
+        # on it — that decode used to be the unauthenticated DoS amplifier.
+        secret = "secret"
+        import hmac as _hmac
+        import hashlib as _hashlib
+
+        header_obj = {
+            "typ": "JWT",
+            "alg": "HS256",
+            "b64": False,
+            "crit": ["b64"],
+        }
+        header_b64 = base64url_encode(
+            json.dumps(header_obj, separators=(",", ":")).encode()
         )
+        # Stuff the middle segment with arbitrary attacker-controlled bytes.
+        # This should be rejected without being base64-decoded.
+        attacker_segment = b"A" * 1024
+        signing_input = b".".join([header_b64, payload])
+        sig = _hmac.new(secret.encode(), signing_input, 
_hashlib.sha256).digest()
+        token = b".".join(
+            [header_b64, attacker_segment, base64url_encode(sig)]
+        ).decode()
+
+        with pytest.raises(DecodeError, match="Payload segment must be empty"):
+            jws.decode(token, secret, algorithms=["HS256"], 
detached_payload=payload)
+
+    def test_decode_b64_false_without_crit_b64_is_rejected(
+        self, jws: PyJWS, payload: bytes
+    ) -> None:
+        # Hand-craft a non-compliant token: header has b64=false but no
+        # crit:["b64"]. Per RFC 7797 §3, such tokens are malformed and must
+        # be rejected even though PyJWT understands b64.
+        secret = "secret"
+        import hmac as _hmac
+        import hashlib as _hashlib
+
+        header_obj = {"typ": "JWT", "alg": "HS256", "b64": False}
+        header_b64 = base64url_encode(
+            json.dumps(header_obj, separators=(",", ":")).encode()
+        )
+        signing_input = b".".join([header_b64, payload])
+        sig = _hmac.new(secret.encode(), signing_input, 
_hashlib.sha256).digest()
+        token = b".".join([header_b64, b"", base64url_encode(sig)]).decode()
+
+        with pytest.raises(InvalidTokenError, match="b64.*crit"):
+            jws.decode(token, secret, algorithms=["HS256"], 
detached_payload=payload)
+
+    def test_decode_detached_content_without_proper_argument(
+        self, jws: PyJWS, payload: bytes
+    ) -> None:
         example_secret = "secret"
+        example_jws = jws.encode(
+            payload, example_secret, algorithm="HS256", 
is_payload_detached=True
+        )
 
         with pytest.raises(DecodeError) as exc:
             jws.decode(example_jws, example_secret, algorithms=["HS256"])
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/tests/test_jwks_client.py 
new/pyjwt-2.13.0/tests/test_jwks_client.py
--- old/pyjwt-2.12.1/tests/test_jwks_client.py  2026-03-13 20:09:20.000000000 
+0100
+++ new/pyjwt-2.13.0/tests/test_jwks_client.py  2026-05-21 21:53:13.000000000 
+0200
@@ -288,18 +288,30 @@
 
         assert repeated_call.call_count == 1
 
-    def test_get_jwt_set_failed_request_should_clear_cache(self) -> None:
+    def test_get_jwt_set_failed_refresh_preserves_cached_jwks(self) -> None:
+        # Regression: a transient fetch failure used to clear the cache via
+        # the previous `finally: put(jwk_set=None)` pattern, turning one bad
+        # request from the JWKS endpoint into application-wide auth failure.
+        # The cache must survive.
         url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json";
 
         jwks_client = PyJWKClient(url)
         with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID):
             jwks_client.get_jwk_set()
 
+        assert jwks_client.jwk_set_cache is not None
+        assert jwks_client.jwk_set_cache.get() is not None
+
         with pytest.raises(PyJWKClientError):
             with mocked_failed_response():
                 jwks_client.get_jwk_set(refresh=True)
 
-            assert jwks_client.jwk_set_cache is None
+        cached = jwks_client.jwk_set_cache.get()
+        assert cached is not None
+        # Subsequent reads still serve from cache without another fetch.
+        with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID) as call:
+            jwks_client.get_jwk_set()
+        assert call.call_count == 0
 
     def test_failed_request_should_raise_connection_error(self) -> None:
         token = 
"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik5FRTFRVVJCT1RNNE16STVSa0ZETlRZeE9UVTFNRGcyT0Rnd1EwVXpNVGsxUWpZeVJrUkZRdyJ9.eyJpc3MiOiJodHRwczovL2Rldi04N2V2eDlydS5hdXRoMC5jb20vIiwic3ViIjoiYVc0Q2NhNzl4UmVMV1V6MGFFMkg2a0QwTzNjWEJWdENAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vZXhwZW5zZXMtYXBpIiwiaWF0IjoxNTcyMDA2OTU0LCJleHAiOjE1NzIwMDY5NjQsImF6cCI6ImFXNENjYTc5eFJlTFdVejBhRTJINmtEME8zY1hCVnRDIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.PUxE7xn52aTCohGiWoSdMBZGiYAHwE5FYie0Y1qUT68IHSTXwXVd6hn02HTah6epvHHVKA2FqcFZ4GGv5VTHEvYpeggiiZMgbxFrmTEY0csL6VNkX1eaJGcuehwQCRBKRLL3zKmA5IKGy5GeUnIbpPHLHDxr-GXvgFzsdsyWlVQvPX2xjeaQ217r2PtxDeqjlf66UYl6oY6AqNS8DH3iryCvIfCcybRZkc_hdy-6ZMoKT6Piijvk_aXdm7-QQqKJFHLuEqrVSOuBqqiNfVrG27QzAPuPOxvfXTVLXL2jek5meH6n-VWgrBdoMFH93QEszEDowDAEhQPHVs0xj7SIzA"
@@ -344,6 +356,36 @@
             jwks_client = PyJWKClient(url, lifespan=-1)
             assert jwks_client is None
 
+    @pytest.mark.parametrize(
+        "uri",
+        [
+            "file:///etc/passwd",
+            "ftp://example.org/keys.json";,
+            'data:application/json,{"keys":[]}',
+            "/etc/passwd",  # urlparse gives scheme="" — also rejected
+            "ldap://internal.test/jwks";,
+        ],
+    )
+    def test_pyjwkclient_rejects_non_http_schemes(self, uri: str) -> None:
+        # urllib's default OpenerDirector handles file://, ftp://, and data:
+        # URIs. PyJWKClient must reject these so callers can't be tricked
+        # into reading attacker-controlled local files or other unintended
+        # schemes via a manipulated URI.
+        with pytest.raises(PyJWKClientError, match="Invalid JWKS URI scheme"):
+            PyJWKClient(uri)
+
+    @pytest.mark.parametrize(
+        "uri",
+        [
+            "http://localhost/jwks.json";,
+            "https://example.test/jwks.json";,
+            "HTTPS://Example.Test/jwks.json",  # case-insensitive
+        ],
+    )
+    def test_pyjwkclient_accepts_http_https_schemes(self, uri: str) -> None:
+        # Construction succeeds; no fetch is made until get_jwk_set().
+        PyJWKClient(uri)
+
     def test_get_jwt_set_timeout(self) -> None:
         url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json";
         jwks_client = PyJWKClient(url, timeout=5)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pyjwt-2.12.1/tox.ini new/pyjwt-2.13.0/tox.ini
--- old/pyjwt-2.12.1/tox.ini    2026-03-13 20:09:20.000000000 +0100
+++ new/pyjwt-2.13.0/tox.ini    2026-05-21 21:53:13.000000000 +0200
@@ -39,8 +39,9 @@
 # https://github.com/pypa/setuptools/issues/1042 from breaking our builds.
 setenv =
     VIRTUALENV_NO_DOWNLOAD=1
-extras =
+dependency_groups =
     tests
+extras =
     crypto: crypto
 commands = {envpython} -b -m coverage run -m pytest {posargs}
 
@@ -48,8 +49,9 @@
 [testenv:docs]
 # The tox config must match the ReadTheDocs config.
 basepython = python3.11
-extras =
+dependency_groups =
     docs
+extras =
     crypto
 commands =
     sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs docs/_build/html
@@ -58,8 +60,9 @@
 
 
 [testenv:py{39,310,311,312,313,314}{,-crypto}-mypy]
-extras =
+dependency_groups =
     tests
+extras =
     crypto: crypto
 deps =
     mypy
@@ -69,8 +72,8 @@
     mypy
 
 [testenv:lint]
-basepython = python3.9
-extras = dev
+basepython = python3.10
+dependency_groups = dev
 passenv = HOMEPATH  # needed on Windows
 commands = pre-commit run --all-files
 

Reply via email to