Repository: authlib/joserfc

Issue:
Support `none` alg
The OpenID Connect certification suite performs tests with OIDC request objects (also described in RFC9101) that use the 'none' alg. Here is an example of request object built by the test suite:

```
eyJhbGciOiJub25lIn0.eyJzY29wZSI6Im9wZW5pZCIsInJlc3BvbnNlX3R5cGUiOiJjb2RlIiwicmVkaXJlY3RfdXJpIjoiaHR0cHM6Ly93d3cuY2VydGlmaWNhdGlvbi5vcGVuaWQubmV0L3Rlc3QvYS9DYW5haWxsZS9jYWxsYmFjayIsInN0YXRlIjoib09pTVNMZ1VNOCIsIm5vbmNlIjoiR2Jaa2JwZkpIZCIsImNsaWVudF9pZCI6Im9hQnQ3dFN5UElJaFE4Mk9HZEpFVlZ1ektDa1JvS0dINmJjMXZiYzk0cSJ9.
```

Currently, joserfc does not allow to read such a JWT:

```python
>>> payload = "eyJhbGciOiJub25lIn0.eyJzY29wZSI6Im9wZW5pZCIsInJlc3BvbnNlX3R5cGUiOiJjb2RlIiwicmVkaXJlY3RfdXJpIjoiaHR0cHM6Ly93d3cuY2VydGlmaWNhdGlvbi5vcGVuaWQubmV0L3Rlc3QvYS9DYW\
5haWxsZS9jYWxsYmFjayIsInN0YXRlIjoib09pTVNMZ1VNOCIsIm5vbmNlIjoiR2Jaa2JwZkpIZCIsImNsaWVudF9pZCI6Im9hQnQ3dFN5UElJaFE4Mk9HZEpFVlZ1ektDa1JvS0dINmJjMXZiYzk0cSJ9."
>>> from joserfc import jwt
>>> jwt.decode(payload, None)
Traceback (most recent call last):
  File "<python-input-2>", line 1, in <module>
    jwt.decode(payload, None)
    ~~~~~~~~~~^^^^^^^^^^^^^^^
  File "/home/eloi/dev/joserfc/src/joserfc/jwt.py", line 96, in decode
    header, payload = _decode_jws(_value, key, algorithms, registry)
                      ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/eloi/dev/joserfc/src/joserfc/jwt.py", line 121, in _decode_jws
    jws_obj = deserialize_compact(value, key, algorithms, registry)
  File "/home/eloi/dev/joserfc/src/joserfc/jws.py", line 171, in deserialize_compact
    if not validate_compact(obj, public_key, algorithms, registry):
           ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/eloi/dev/joserfc/src/joserfc/jws.py", line 139, in validate_compact
    key: Key = guess_key(public_key, obj)
               ~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "/home/eloi/dev/joserfc/src/joserfc/jwk.py", line 77, in guess_key
    raise ValueError("Invalid key")
ValueError: Invalid key
```

It is neither possible to build a JWT with the `none` alg:

```python
>>> jwt.encode({"alg": "none"}, {"k": "value"}, None)
Traceback (most recent call last):
  File "<python-input-3>", line 1, in <module>
    jwt.encode({"alg": "none"}, {"k": "value"}, None)
    ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/eloi/dev/joserfc/src/joserfc/jwt.py", line 71, in encode
    return serialize_compact(_header, payload, key, algorithms, registry)
  File "/home/eloi/dev/joserfc/src/joserfc/jws.py", line 112, in serialize_compact
    alg: JWSAlgModel = registry.get_alg(protected["alg"])
                       ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
  File "/home/eloi/dev/joserfc/src/joserfc/rfc7515/registry.py", line 60, in get_alg
    raise UnsupportedAlgorithmError(f"Algorithm of '{name}' is not recommended")
joserfc.errors.UnsupportedAlgorithmError: unsupported_algorithm: Algorithm of 'none' is not recommended
```

Hints from issue comments:
@lepture: According to https://datatracker.ietf.org/doc/draft-ietf-jose-deprecate-none-rsa15/

`none` will be deprecated. How about adding a warning when using `none`?@azmeuk: Why not, but I think users should be able to disable the warning. The none alg is still required for OIDC certification, so until the OIDC certification requirements evolve, the warning might be annoying.
What do you think?

Solution patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index edceaa2..5a6199b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -71,7 +71,7 @@ jobs:
           name: GitHub
 
       - name: SonarCloud Scan
-        if: ${{ secrets.SONAR_TOKEN }}
         uses: SonarSource/sonarqube-scan-action@v4
+        continue-on-error: true
         env:
           SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 3eece50..a90405c 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -17,6 +17,7 @@ Unreleased
 
 - Use "import as" to prioritize the modules for editors.
 - Added parameter ``encoder_cls`` for ``jwt.encode`` and ``decoder_cls`` for ``jwt.decode``.
+- Added ``none`` algorithm for JWS.
 
 **Breaking changes**:
 
diff --git a/src/joserfc/errors.py b/src/joserfc/errors.py
index e7eba21..7885ed1 100644
--- a/src/joserfc/errors.py
+++ b/src/joserfc/errors.py
@@ -72,6 +72,10 @@ class UnsupportedAlgorithmError(JoseError):
     error = "unsupported_algorithm"
 
 
+class MissingKeyError(JoseError):
+    error = "missing_key"
+
+
 class UnsupportedHeaderError(JoseError):
     error = "unsupported_header"
 
diff --git a/src/joserfc/jws.py b/src/joserfc/jws.py
index b819d0c..23648e7 100644
--- a/src/joserfc/jws.py
+++ b/src/joserfc/jws.py
@@ -34,7 +34,7 @@ from .rfc7515.types import (
 from .rfc7518.jws_algs import JWS_ALGORITHMS
 from .rfc8037.jws_eddsa import EdDSA
 from .rfc8812 import ES256K
-from .errors import BadSignatureError
+from .errors import BadSignatureError, MissingKeyError
 from .jwk import Key, KeyFlexible, KeySet, guess_key
 from .util import to_bytes
 from .registry import Header
@@ -84,7 +84,7 @@ register_algorithms()
 def serialize_compact(
     protected: Header,
     payload: bytes | str,
-    private_key: KeyFlexible,
+    private_key: KeyFlexible | None,
     algorithms: list[str] | None = None,
     registry: JWSRegistry | None = None,
 ) -> str:
@@ -111,6 +111,15 @@ def serialize_compact(
     registry.check_header(protected)
     obj = CompactSignature(protected, to_bytes(payload))
     alg: JWSAlgModel = registry.get_alg(protected["alg"])
+
+    # "none" algorithm requires no key
+    if alg.name == "none":
+        out = sign_compact(obj, alg, None)
+        return out.decode("utf-8")
+
+    if private_key is None:
+        raise MissingKeyError()
+
     key: Key = guess_key(private_key, obj, True)
     key.check_use("sig")
     alg.check_key_type(key)
@@ -121,7 +130,7 @@ def serialize_compact(
 
 def validate_compact(
     obj: CompactSignature,
-    public_key: KeyFlexible,
+    public_key: KeyFlexible | None,
     algorithms: list[str] | None = None,
     registry: JWSRegistry | None = None,
 ) -> bool:
@@ -138,16 +147,24 @@ def validate_compact(
 
     headers = obj.headers()
     registry.check_header(headers)
+    alg: JWSAlgModel = registry.get_alg(headers["alg"])
+
+    # "none" algorithm requires no key
+    if headers["alg"] == "none":
+        return verify_compact(obj, alg, None)
+
+    if public_key is None:
+        raise MissingKeyError()
+
     key: Key = guess_key(public_key, obj)
     key.check_use("sig")
-    alg: JWSAlgModel = registry.get_alg(headers["alg"])
     alg.check_key_type(key)
     return verify_compact(obj, alg, key)
 
 
 def deserialize_compact(
     value: bytes | str,
-    public_key: KeyFlexible,
+    public_key: KeyFlexible | None,
     algorithms: list[str] | None = None,
     registry: JWSRegistry | None = None,
 ) -> CompactSignature:
diff --git a/src/joserfc/rfc7518/jws_algs.py b/src/joserfc/rfc7518/jws_algs.py
index b4f4c00..7ed271a 100644
--- a/src/joserfc/rfc7518/jws_algs.py
+++ b/src/joserfc/rfc7518/jws_algs.py
@@ -35,7 +35,7 @@ class NoneAlgModel(JWSAlgModel):
         return b""
 
     def verify(self, msg: bytes, sig: bytes, key: t.Any) -> bool:
-        return False
+        return sig == b""
 
 
 class HMACAlgModel(JWSAlgModel):


Test patch:
diff --git a/tests/jws/test_errors.py b/tests/jws/test_errors.py
index f75c472..6c9d287 100644
--- a/tests/jws/test_errors.py
+++ b/tests/jws/test_errors.py
@@ -4,6 +4,7 @@ from joserfc.jwk import RSAKey, OctKey
 from joserfc.registry import HeaderParameter
 from joserfc.errors import (
     BadSignatureError,
+    MissingKeyError,
     UnsupportedKeyUseError,
     UnsupportedKeyAlgorithmError,
     UnsupportedKeyOperationError,
@@ -17,27 +18,32 @@ from tests.base import load_key
 
 
 class TestJWSErrors(TestCase):
+    key = OctKey.import_key("secret")
+
     def test_without_alg(self):
-        key = OctKey.import_key("secret")
-        # missing alg
-        self.assertRaises(MissingHeaderError, jws.serialize_compact, {"kid": "123"}, "i", key)
+        self.assertRaises(MissingHeaderError, jws.serialize_compact, {"kid": "123"}, "i", self.key)
+
+    def test_without_key(self):
+        self.assertRaises(MissingKeyError, jws.serialize_compact, {"alg": "HS256"}, "i", None)
 
     def test_none_alg(self):
         header = {"alg": "none"}
-        key = OctKey.import_key("secret")
-        text = jws.serialize_compact(header, "i", key, algorithms=["none"])
-        self.assertRaises(BadSignatureError, jws.deserialize_compact, text, key, algorithms=["none"])
+        text = jws.serialize_compact(header, "i", None, algorithms=["none"])
+        obj = jws.deserialize_compact(text, None, algorithms=["none"])
+        self.assertEqual(obj.payload, b"i")
+        # none alg has no signature
+        text += 'aQ'
+        self.assertRaises(BadSignatureError, jws.deserialize_compact, text, None, algorithms=["none"])
 
     def test_header_invalid_type(self):
         # kid should be a string
         header = {"alg": "HS256", "kid": 123}
-        key = OctKey.import_key("secret")
         self.assertRaises(
             ValueError,
             jws.serialize_compact,
             header,
             "i",
-            key,
+            self.key,
         )
 
         # jwk should be a dict
@@ -47,7 +53,7 @@ class TestJWSErrors(TestCase):
             jws.serialize_compact,
             header,
             "i",
-            key,
+            self.key,
         )
 
         # jku should be a URL
@@ -57,7 +63,7 @@ class TestJWSErrors(TestCase):
             jws.serialize_compact,
             header,
             "i",
-            key,
+            self.key,
         )
 
         # x5c should be a chain of string
@@ -67,7 +73,7 @@ class TestJWSErrors(TestCase):
             jws.serialize_compact,
             header,
             "i",
-            key,
+            self.key,
         )
         header = {"alg": "HS256", "x5c": [1, 2]}
         self.assertRaises(
@@ -75,42 +81,40 @@ class TestJWSErrors(TestCase):
             jws.serialize_compact,
             header,
             "i",
-            key,
+            self.key,
         )
 
     def test_crit_header(self):
         header = {"alg": "HS256", "crit": ["kid"]}
-        key = OctKey.import_key("secret")
         # missing kid header
         self.assertRaises(
             MissingCritHeaderError,
             jws.serialize_compact,
             header,
             "i",
-            key,
+            self.key,
         )
 
         header = {"alg": "HS256", "kid": "1", "crit": ["kid"]}
-        jws.serialize_compact(header, "i", key)
+        jws.serialize_compact(header, "i", self.key)
 
     def test_extra_header(self):
         header = {"alg": "HS256", "extra": "hi"}
-        key = OctKey.import_key("secret")
         self.assertRaises(
             UnsupportedHeaderError,
             jws.serialize_compact,
             header,
             "i",
-            key,
+            self.key,
         )
         # bypass extra header
         registry = jws.JWSRegistry(strict_check_header=False)
-        jws.serialize_compact(header, "i", key, registry=registry)
+        jws.serialize_compact(header, "i", self.key, registry=registry)
 
         # or use a header registry
         extra_header = {"extra": HeaderParameter("Extra header", "str", False)}
         registry = jws.JWSRegistry(header_registry=extra_header)
-        jws.serialize_compact(header, "i", key, registry=registry)
+        jws.serialize_compact(header, "i", self.key, registry=registry)
 
     def test_rsa_invalid_signature(self):
         key1 = RSAKey.generate_key()