Repository: jpadilla/pyjwt

Issue:
Should `jwt.decode` accept `PyJWK` keys?
First of all, thanks for `pyjwt`!

I'm implementing the consumer side of an OIDC flow, with the verification keys being retrieved periodically from the OIDC provider's JSON Web Key (JWK) set. I then turn each JWK's contents into this library's `PyJWK` type.

## Expected Result

For verification, I'm doing something like this:

```python
signed_payload = jwt.decode(
    unverified_token,
    key=key,
    algorithms=["RS256"],
    options=dict(
        verify_signature=True,
        # "require" only checks for the presence of these claims, not
        # their validity. Each has a corresponding "verify_" kwarg
        # that enforces their actual validity.
        require=["iss", "iat", "nbf", "exp", "aud"],
        verify_iss=True,
        verify_iat=True,
        verify_nbf=True,
        verify_exp=True,
        verify_aud=True,
    ),
    issuer=self.issuer_url,
    audience=self.audience,
    leeway=30,
)
```

...where `key` is a `PyJWK`.

I expect this to succeed and return the signed claim dictionary (assuming `key` is valid).

## Actual Result

Instead, I get a `TypeError` with the following message: `Expecting a PEM-formatted key.`

I traced this down to `algorithms.py`, where the key is expected to be in PEM format for RSA keys:

https://github.com/jpadilla/pyjwt/blob/777efa2f51249f63b0f95804230117723eca5d09/jwt/algorithms.py#L294C15-L295

...which made me check the types for the `jwt.decode` API, where I realized that this wasn't supported to begin with 😅 

https://github.com/jpadilla/pyjwt/blob/777efa2f51249f63b0f95804230117723eca5d09/jwt/api_jwt.py#L182-L198

## Request

So, my feature request: could `jwt.decode` accept `PyJWK` instances for the `key=` parameter?

I think most of the scaffolding for this is already in place: the `Algorithm` ABC already has a `from_jwk` classmethod, so each concrete implementation could use it in its concrete `prepare_key` implementation. From there, the top-level signature(s) could be broadened from `str | bytes` to `str | bytes | PyJWK`.

## Alternatives considered

Given that the top level `decode` API only accepts PEM-encoded `str` or `bytes` objects, I'm not sure if there's a clean alternative to this.

I could do something hacky and manually re-encoded the `PyJWK` as a PEM-encoded key, potentially with the help of the `cryptography` APIs. But that seems error prone 🙂 


Discussion:
luhn: Can I get feedback from a maintainer on whether or not these proposed changes would be accepted once properly documented and tested?  @Viicos?  @jpadilla?
Viicos: I like the idea, I'm not an official maintainer so I'll let @jpadilla or @auvipy decide here :)
luhn: Ready for review 👍 
auvipy: restarted the CI, lets hope all are green
luhn: Okay, I fixed tests w/o cryptography installed.
luhn: Ping.  Any more feedback on this?

@auvipy, it looks like you need to approve my changes.
luhn: Bummed to see this was closed as stale.  @jpadilla @Viicos @auvipy would it be possible to reopen and get merged?  I'm happy to make any changes necessary.

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index b6a538a..aa29add 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -54,9 +54,11 @@ jobs:
 
       - name: "Upload coverage to Codecov"
         if: "contains(env.USING_COVERAGE, matrix.python-version) && matrix.platform == 'ubuntu-latest'"
-        uses: "codecov/codecov-action@v3"
+        uses: "codecov/codecov-action@v4"
         with:
           fail_ci_if_error: true
+          token: ${{ secrets.CODECOV_TOKEN }}
+          verbose: true
 
   package:
     name: "Build & verify package"
diff --git a/.github/workflows/pypi-package.yml b/.github/workflows/pypi-package.yml
index 1b97c54..7d45039 100644
--- a/.github/workflows/pypi-package.yml
+++ b/.github/workflows/pypi-package.yml
@@ -40,7 +40,7 @@ jobs:
 
     steps:
       - name: Download packages built by build-and-inspect-python-package
-        uses: actions/download-artifact@v3
+        uses: actions/download-artifact@v4
         with:
           name: Packages
           path: dist
@@ -60,7 +60,7 @@ jobs:
 
     steps:
       - name: Download packages built by build-and-inspect-python-package
-        uses: actions/download-artifact@v3
+        uses: actions/download-artifact@v4
         with:
           name: Packages
           path: dist
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ac45a70..bcc2ffc 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
   - repo: https://github.com/psf/black
-    rev: 23.11.0
+    rev: 24.4.2
     hooks:
       - id: black
         args: ["--target-version=py38"]
@@ -12,18 +12,18 @@ repos:
         args: ["--target-version=py38"]
 
   - repo: https://github.com/PyCQA/flake8
-    rev: 6.1.0
+    rev: 7.0.0
     hooks:
       - id: flake8
         language_version: python3.9
 
   - repo: https://github.com/PyCQA/isort
-    rev: 5.12.0
+    rev: 5.13.2
     hooks:
       - id: isort
 
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.5.0
+    rev: v4.6.0
     hooks:
       - id: trailing-whitespace
       - id: end-of-file-fixer
@@ -36,7 +36,7 @@ repos:
         args: [--no-build-isolation]
 
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: "v1.7.0"
+    rev: "v1.10.0"
     hooks:
       - id: mypy
         additional_dependencies: [cryptography>=3.4.0]
diff --git a/docs/conf.py b/docs/conf.py
index 938631f..b0b23f4 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -69,7 +69,7 @@ version = release.rsplit(".", 1)[0]
 #
 # This is also used if you do content translation via gettext catalogs.
 # Usually you set "language" from the command line for these cases.
-language = None
+# language = None
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
diff --git a/docs/usage.rst b/docs/usage.rst
index 9a673c3..55097e8 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -291,8 +291,7 @@ Retrieve RSA signing keys from a JWKS endpoint
     >>> signing_key = jwks_client.get_signing_key_from_jwt(token)
     >>> data = jwt.decode(
     ...     token,
-    ...     signing_key.key,
-    ...     algorithms=["RS256"],
+    ...     signing_key,
     ...     audience="https://expenses-api",
     ...     options={"verify_exp": False},
     ... )
@@ -356,8 +355,7 @@ is not built into pyjwt.
     # now, decode_complete to get payload + header
     data = jwt.decode_complete(
         id_token,
-        key=signing_key.key,
-        algorithms=signing_algos,
+        key=signing_key,
         audience=client_id,
     )
     payload, header = data["payload"], data["header"]
diff --git a/jwt/algorithms.py b/jwt/algorithms.py
index ece86b4..c348a7d 100644
--- a/jwt/algorithms.py
+++ b/jwt/algorithms.py
@@ -187,14 +187,12 @@ class Algorithm(ABC):
     @overload
     @staticmethod
     @abstractmethod
-    def to_jwk(key_obj, as_dict: Literal[True]) -> JWKDict:
-        ...  # pragma: no cover
+    def to_jwk(key_obj, as_dict: Literal[True]) -> JWKDict: ...  # pragma: no cover
 
     @overload
     @staticmethod
     @abstractmethod
-    def to_jwk(key_obj, as_dict: Literal[False] = False) -> str:
-        ...  # pragma: no cover
+    def to_jwk(key_obj, as_dict: Literal[False] = False) -> str: ...  # pragma: no cover
 
     @staticmethod
     @abstractmethod
@@ -267,13 +265,15 @@ class HMACAlgorithm(Algorithm):
 
     @overload
     @staticmethod
-    def to_jwk(key_obj: str | bytes, as_dict: Literal[True]) -> JWKDict:
-        ...  # pragma: no cover
+    def to_jwk(
+        key_obj: str | bytes, as_dict: Literal[True]
+    ) -> JWKDict: ...  # pragma: no cover
 
     @overload
     @staticmethod
-    def to_jwk(key_obj: str | bytes, as_dict: Literal[False] = False) -> str:
-        ...  # pragma: no cover
+    def to_jwk(
+        key_obj: str | bytes, as_dict: Literal[False] = False
+    ) -> str: ...  # pragma: no cover
 
     @staticmethod
     def to_jwk(key_obj: str | bytes, as_dict: bool = False) -> JWKDict | str:
@@ -347,13 +347,15 @@ if has_crypto:
 
         @overload
         @staticmethod
-        def to_jwk(key_obj: AllowedRSAKeys, as_dict: Literal[True]) -> JWKDict:
-            ...  # pragma: no cover
+        def to_jwk(
+            key_obj: AllowedRSAKeys, as_dict: Literal[True]
+        ) -> JWKDict: ...  # pragma: no cover
 
         @overload
         @staticmethod
-        def to_jwk(key_obj: AllowedRSAKeys, as_dict: Literal[False] = False) -> str:
-            ...  # pragma: no cover
+        def to_jwk(
+            key_obj: AllowedRSAKeys, as_dict: Literal[False] = False
+        ) -> str: ...  # pragma: no cover
 
         @staticmethod
         def to_jwk(key_obj: AllowedRSAKeys, as_dict: bool = False) -> JWKDict | str:
@@ -543,13 +545,15 @@ if has_crypto:
 
         @overload
         @staticmethod
-        def to_jwk(key_obj: AllowedECKeys, as_dict: Literal[True]) -> JWKDict:
-            ...  # pragma: no cover
+        def to_jwk(
+            key_obj: AllowedECKeys, as_dict: Literal[True]
+        ) -> JWKDict: ...  # pragma: no cover
 
         @overload
         @staticmethod
-        def to_jwk(key_obj: AllowedECKeys, as_dict: Literal[False] = False) -> str:
-            ...  # pragma: no cover
+        def to_jwk(
+            key_obj: AllowedECKeys, as_dict: Literal[False] = False
+        ) -> str: ...  # pragma: no cover
 
         @staticmethod
         def to_jwk(key_obj: AllowedECKeys, as_dict: bool = False) -> JWKDict | str:
@@ -760,13 +764,15 @@ if has_crypto:
 
         @overload
         @staticmethod
-        def to_jwk(key: AllowedOKPKeys, as_dict: Literal[True]) -> JWKDict:
-            ...  # pragma: no cover
+        def to_jwk(
+            key: AllowedOKPKeys, as_dict: Literal[True]
+        ) -> JWKDict: ...  # pragma: no cover
 
         @overload
         @staticmethod
-        def to_jwk(key: AllowedOKPKeys, as_dict: Literal[False] = False) -> str:
-            ...  # pragma: no cover
+        def to_jwk(
+            key: AllowedOKPKeys, as_dict: Literal[False] = False
+        ) -> str: ...  # pragma: no cover
 
         @staticmethod
         def to_jwk(key: AllowedOKPKeys, as_dict: bool = False) -> JWKDict | str:
diff --git a/jwt/api_jwk.py b/jwt/api_jwk.py
index af74578..d35b955 100644
--- a/jwt/api_jwk.py
+++ b/jwt/api_jwk.py
@@ -52,9 +52,11 @@ class PyJWK:
         if not has_crypto and algorithm in requires_cryptography:
             raise PyJWKError(f"{algorithm} requires 'cryptography' to be installed.")
 
-        self.Algorithm = self._algorithms.get(algorithm)
+        self.algorithm_name = algorithm
 
-        if not self.Algorithm:
+        if algorithm in self._algorithms:
+            self.Algorithm = self._algorithms[algorithm]
+        else:
             raise PyJWKError(f"Unable to find an algorithm for key: {self._jwk_data}")
 
         self.key = self.Algorithm.from_jwk(self._jwk_data)
diff --git a/jwt/api_jws.py b/jwt/api_jws.py
index fa6708c..5822ebf 100644
--- a/jwt/api_jws.py
+++ b/jwt/api_jws.py
@@ -11,6 +11,7 @@ from .algorithms import (
     has_crypto,
     requires_cryptography,
 )
+from .api_jwk import PyJWK
 from .exceptions import (
     DecodeError,
     InvalidAlgorithmError,
@@ -172,7 +173,7 @@ class PyJWS:
     def decode_complete(
         self,
         jwt: str | bytes,
-        key: AllowedPublicKeys | str | bytes = "",
+        key: AllowedPublicKeys | PyJWK | str | bytes = "",
         algorithms: list[str] | None = None,
         options: dict[str, Any] | None = None,
         detached_payload: bytes | None = None,
@@ -190,7 +191,7 @@ class PyJWS:
         merged_options = {**self.options, **options}
         verify_signature = merged_options["verify_signature"]
 
-        if verify_signature and not algorithms:
+        if verify_signature and not algorithms and not isinstance(key, PyJWK):
             raise DecodeError(
                 'It is required that you pass in a value for the "algorithms" argument when calling decode().'
             )
@@ -217,7 +218,7 @@ class PyJWS:
     def decode(
         self,
         jwt: str | bytes,
-        key: AllowedPublicKeys | str | bytes = "",
+        key: AllowedPublicKeys | PyJWK | str | bytes = "",
         algorithms: list[str] | None = None,
         options: dict[str, Any] | None = None,
         detached_payload: bytes | None = None,
@@ -289,9 +290,11 @@ class PyJWS:
         signing_input: bytes,
         header: dict[str, Any],
         signature: bytes,
-        key: AllowedPublicKeys | str | bytes = "",
+        key: AllowedPublicKeys | PyJWK | str | bytes = "",
         algorithms: list[str] | None = None,
     ) -> None:
+        if algorithms is None and isinstance(key, PyJWK):
+            algorithms = [key.algorithm_name]
         try:
             alg = header["alg"]
         except KeyError:
@@ -300,11 +303,15 @@ class PyJWS:
         if not alg or (algorithms is not None and alg not in algorithms):
             raise InvalidAlgorithmError("The specified alg value is not allowed")
 
-        try:
-            alg_obj = self.get_algorithm_by_name(alg)
-        except NotImplementedError as e:
-            raise InvalidAlgorithmError("Algorithm not supported") from e
-        prepared_key = alg_obj.prepare_key(key)
+        if isinstance(key, PyJWK):
+            alg_obj = key.Algorithm
+            prepared_key = key.key
+        else:
+            try:
+                alg_obj = self.get_algorithm_by_name(alg)
+            except NotImplementedError as e:
+                raise InvalidAlgorithmError("Algorithm not supported") from e
+            prepared_key = alg_obj.prepare_key(key)
 
         if not alg_obj.verify(signing_input, prepared_key, signature):
             raise InvalidSignatureError("Signature verification failed")
diff --git a/jwt/api_jwt.py b/jwt/api_jwt.py
index 87883bc..7a07c33 100644
--- a/jwt/api_jwt.py
+++ b/jwt/api_jwt.py
@@ -21,6 +21,7 @@ from .warnings import RemovedInPyjwt3Warning
 
 if TYPE_CHECKING:
     from .algorithms import AllowedPrivateKeys, AllowedPublicKeys
+    from .api_jwk import PyJWK
 
 
 class PyJWT:
@@ -100,7 +101,7 @@ class PyJWT:
     def decode_complete(
         self,
         jwt: str | bytes,
-        key: AllowedPublicKeys | str | bytes = "",
+        key: AllowedPublicKeys | PyJWK | str | bytes = "",
         algorithms: list[str] | None = None,
         options: dict[str, Any] | None = None,
         # deprecated arg, remove in pyjwt3
@@ -185,7 +186,7 @@ class PyJWT:
     def decode(
         self,
         jwt: str | bytes,
-        key: AllowedPublicKeys | str | bytes = "",
+        key: AllowedPublicKeys | PyJWK | str | bytes = "",
         algorithms: list[str] | None = None,
         options: dict[str, Any] | None = None,
         # deprecated arg, remove in pyjwt3
diff --git a/jwt/utils.py b/jwt/utils.py
index 81c5ee4..d469139 100644
--- a/jwt/utils.py
+++ b/jwt/utils.py
@@ -131,26 +131,15 @@ def is_pem_format(key: bytes) -> bool:
 
 
 # Based on https://github.com/pyca/cryptography/blob/bcb70852d577b3f490f015378c75cba74986297b/src/cryptography/hazmat/primitives/serialization/ssh.py#L40-L46
-_CERT_SUFFIX = b"-cert-v01@openssh.com"
-_SSH_PUBKEY_RC = re.compile(rb"\A(\S+)[ \t]+(\S+)")
-_SSH_KEY_FORMATS = [
+_SSH_KEY_FORMATS = (
     b"ssh-ed25519",
     b"ssh-rsa",
     b"ssh-dss",
     b"ecdsa-sha2-nistp256",
     b"ecdsa-sha2-nistp384",
     b"ecdsa-sha2-nistp521",
-]
+)
 
 
 def is_ssh_key(key: bytes) -> bool:
-    if any(string_value in key for string_value in _SSH_KEY_FORMATS):
-        return True
-
-    ssh_pubkey_match = _SSH_PUBKEY_RC.match(key)
-    if ssh_pubkey_match:
-        key_type = ssh_pubkey_match.group(1)
-        if _CERT_SUFFIX == key_type[-len(_CERT_SUFFIX) :]:
-            return True
-
-    return False
+    return key.startswith(_SSH_KEY_FORMATS)
diff --git a/pyproject.toml b/pyproject.toml
index 613b91d..320777c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,7 @@ strict = true
 # TODO: remove these strict loosenings when possible
 allow_incomplete_defs = true
 allow_untyped_defs = true
+warn_return_any = false
 
 [[tool.mypy.overrides]]
 module = "tests.*"
diff --git a/setup.cfg b/setup.cfg
index e5f0171..e6d9f3f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -41,7 +41,7 @@ packages = find:
 
 [options.extras_require]
 docs =
-    sphinx>=4.5.0,<5.0.0
+    sphinx
     sphinx-rtd-theme
     zope.interface
 crypto =
@@ -50,7 +50,7 @@ tests =
     pytest>=6.0.0,<7.0.0
     coverage[toml]==5.0.4
 dev =
-    sphinx>=4.5.0,<5.0.0
+    sphinx
     sphinx-rtd-theme
     zope.interface
     cryptography>=3.4.0


Test patch:
diff --git a/tests/test_api_jwk.py b/tests/test_api_jwk.py
index 4754db6..326e012 100644
--- a/tests/test_api_jwk.py
+++ b/tests/test_api_jwk.py
@@ -65,6 +65,7 @@ class TestPyJWK:
         assert jwk.key_type == "RSA"
         assert isinstance(jwk.Algorithm, RSAAlgorithm)
         assert jwk.Algorithm.hash_alg == RSAAlgorithm.SHA256
+        assert jwk.algorithm_name == "RS256"
 
     @crypto_required
     def test_should_load_key_from_dict_with_algorithm(self):
@@ -76,6 +77,7 @@ class TestPyJWK:
         assert jwk.key_type == "RSA"
         assert isinstance(jwk.Algorithm, RSAAlgorithm)
         assert jwk.Algorithm.hash_alg == RSAAlgorithm.SHA256
+        assert jwk.algorithm_name == "RS256"
 
     @crypto_required
     def test_should_load_key_ec_p256_from_dict(self):
@@ -87,6 +89,7 @@ class TestPyJWK:
         assert jwk.key_type == "EC"
         assert isinstance(jwk.Algorithm, ECAlgorithm)
         assert jwk.Algorithm.hash_alg == ECAlgorithm.SHA256
+        assert jwk.algorithm_name == "ES256"
 
     @crypto_required
     def test_should_load_key_ec_p384_from_dict(self):
@@ -98,6 +101,7 @@ class TestPyJWK:
         assert jwk.key_type == "EC"
         assert isinstance(jwk.Algorithm, ECAlgorithm)
         assert jwk.Algorithm.hash_alg == ECAlgorithm.SHA384
+        assert jwk.algorithm_name == "ES384"
 
     @crypto_required
     def test_should_load_key_ec_p521_from_dict(self):
@@ -109,6 +113,7 @@ class TestPyJWK:
         assert jwk.key_type == "EC"
         assert isinstance(jwk.Algorithm, ECAlgorithm)
         assert jwk.Algorithm.hash_alg == ECAlgorithm.SHA512
+        assert jwk.algorithm_name == "ES512"
 
     @crypto_required
     def test_should_load_key_ec_secp256k1_from_dict(self):
@@ -120,6 +125,7 @@ class TestPyJWK:
         assert jwk.key_type == "EC"
         assert isinstance(jwk.Algorithm, ECAlgorithm)
         assert jwk.Algorithm.hash_alg == ECAlgorithm.SHA256
+        assert jwk.algorithm_name == "ES256K"
 
     @crypto_required
     def test_should_load_key_hmac_from_dict(self):
@@ -131,6 +137,7 @@ class TestPyJWK:
         assert jwk.key_type == "oct"
         assert isinstance(jwk.Algorithm, HMACAlgorithm)
         assert jwk.Algorithm.hash_alg == HMACAlgorithm.SHA256
+        assert jwk.algorithm_name == "HS256"
 
     @crypto_required
     def test_should_load_key_hmac_without_alg_from_dict(self):
@@ -143,6 +150,7 @@ class TestPyJWK:
         assert jwk.key_type == "oct"
         assert isinstance(jwk.Algorithm, HMACAlgorithm)
         assert jwk.Algorithm.hash_alg == HMACAlgorithm.SHA256
+        assert jwk.algorithm_name == "HS256"
 
     @crypto_required
     def test_should_load_key_okp_without_alg_from_dict(self):
@@ -153,6 +161,7 @@ class TestPyJWK:
 
         assert jwk.key_type == "OKP"
         assert isinstance(jwk.Algorithm, OKPAlgorithm)
+        assert jwk.algorithm_name == "EdDSA"
 
     @crypto_required
     def test_from_dict_should_throw_exception_if_arg_is_invalid(self):
diff --git a/tests/test_api_jws.py b/tests/test_api_jws.py
index 3385716..c764f09 100644
--- a/tests/test_api_jws.py
+++ b/tests/test_api_jws.py
@@ -4,6 +4,7 @@ from decimal import Decimal
 import pytest
 
 from jwt.algorithms import NoneAlgorithm, has_crypto
+from jwt.api_jwk import PyJWK
 from jwt.api_jws import PyJWS
 from jwt.exceptions import (
     DecodeError,
@@ -250,6 +251,59 @@ class TestJWS:
             ),
         }
 
+    def test_decodes_with_jwk(self, jws, payload):
+        jwk = PyJWK(
+            {
+                "kty": "oct",
+                "alg": "HS256",
+                "k": "c2VjcmV0",  # "secret"
+            }
+        )
+        example_jws = (
+            b"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9."
+            b"aGVsbG8gd29ybGQ."
+            b"gEW0pdU4kxPthjtehYdhxB9mMOGajt1xCKlGGXDJ8PM"
+        )
+
+        decoded_payload = jws.decode(example_jws, jwk, algorithms=["HS256"])
+
+        assert decoded_payload == payload
+
+    def test_decodes_with_jwk_and_no_algorithm(self, jws, payload):
+        jwk = PyJWK(
+            {
+                "kty": "oct",
+                "alg": "HS256",
+                "k": "c2VjcmV0",  # "secret"
+            }
+        )
+        example_jws = (
+            b"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9."
+            b"aGVsbG8gd29ybGQ."
+            b"gEW0pdU4kxPthjtehYdhxB9mMOGajt1xCKlGGXDJ8PM"
+        )
+
+        decoded_payload = jws.decode(example_jws, jwk)
+
+        assert decoded_payload == payload
+
+    def test_decodes_with_jwk_and_mismatched_algorithm(self, jws, payload):
+        jwk = PyJWK(
+            {
+                "kty": "oct",
+                "alg": "HS512",
+                "k": "c2VjcmV0",  # "secret"
+            }
+        )
+        example_jws = (
+            b"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9."
+            b"aGVsbG8gd29ybGQ."
+            b"gEW0pdU4kxPthjtehYdhxB9mMOGajt1xCKlGGXDJ8PM"
+        )
+
+        with pytest.raises(InvalidAlgorithmError):
+            jws.decode(example_jws, jwk)
+
     # 'Control' Elliptic Curve jws created by another library.
     # Used to test for regressions that could affect both
     # encoding / decoding operations equally (causing tests
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 122dcb4..d8d0d6c 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,6 +1,6 @@
 import pytest
 
-from jwt.utils import force_bytes, from_base64url_uint, to_base64url_uint
+from jwt.utils import force_bytes, from_base64url_uint, is_ssh_key, to_base64url_uint
 
 
 @pytest.mark.parametrize(
@@ -37,3 +37,19 @@ def test_from_base64url_uint(inputval, expected):
 def test_force_bytes_raises_error_on_invalid_object():
     with pytest.raises(TypeError):
         force_bytes({})  # type: ignore[arg-type]
+
+
+@pytest.mark.parametrize(
+    "key_format",
+    (
+        b"ssh-ed25519",
+        b"ssh-rsa",
+        b"ssh-dss",
+        b"ecdsa-sha2-nistp256",
+        b"ecdsa-sha2-nistp384",
+        b"ecdsa-sha2-nistp521",
+    ),
+)
+def test_is_ssh_key(key_format):
+    assert is_ssh_key(key_format + b" any") is True
+    assert is_ssh_key(b"not a ssh key") is False