Repository: privacyidea/privacyidea

Issue:
jwt rsplit jwt.exceptions.DecodeError: Not enough segments
I guess same as #2601, closed but not solved.

### Top-level intent

What did you try to achieve? Set up push, enroll an user.


### Steps to reproduce

1. Fetching the token /token/?serial=P.... from the browser instead of the app.

### Expected outcome

It should not crash.

### Actual outcome

```
ERROR:privacyidea.app:Exception on /token/ [GET]
Traceback (most recent call last):
  File "/home/skinkie/.local/lib/python3.10/site-packages/jwt/api_jws.py", line 180, in _load
    signing_input, crypto_segment = jwt.rsplit(b'.', 1)
ValueError: not enough values to unpack (expected 2, got 1)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/skinkie/.local/lib/python3.10/site-packages/flask/app.py", line 2447, in wsgi_app
    response = self.full_dispatch_request()
  File "/home/skinkie/.local/lib/python3.10/site-packages/flask/app.py", line 1952, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/home/skinkie/.local/lib/python3.10/site-packages/flask/app.py", line 1821, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/home/skinkie/.local/lib/python3.10/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/home/skinkie/.local/lib/python3.10/site-packages/flask/app.py", line 1948, in full_dispatch_request
    rv = self.preprocess_request()
  File "/home/skinkie/.local/lib/python3.10/site-packages/flask/app.py", line 2242, in preprocess_request
    rv = func()
  File "/home/skinkie/.local/lib/python3.10/site-packages/privacyidea/api/auth.py", line 403, in decorated_function
    check_auth_token(required_role=["user", "admin"])
  File "/home/skinkie/.local/lib/python3.10/site-packages/privacyidea/api/auth.py", line 423, in check_auth_token
    r = verify_auth_token(auth_token, required_role)
  File "/home/skinkie/.local/lib/python3.10/site-packages/privacyidea/api/lib/utils.py", line 293, in verify_auth_token
    headers = jwt.get_unverified_header(auth_token)
  File "/home/skinkie/.local/lib/python3.10/site-packages/jwt/api_jws.py", line 166, in get_unverified_header
    headers = self._load(jwt)[2]
  File "/home/skinkie/.local/lib/python3.10/site-packages/jwt/api_jws.py", line 183, in _load
    raise DecodeError('Not enough segments')
jwt.exceptions.DecodeError: Not enough segments
```

### Configuration

* **privacyIDEA version**: 3.6.3
* **Installation method**: pip
* **Python version**: 3.10
* **Operating system**: Linux
* **Webserver**: werkzeug
* **Token database**: PostgreSQL


Discussion:
Thank you for filing an issue and sharing your observations or ideas. Please be sure to provide as many information as possible to help us working on this issue.
Thanks for filing this issue. 
This is not reproducable with the given information.
Currently we do not support Python 3.10. So it might be an issue with 3.10.
We will keep this in mind.
@cornelinux I am happy to try to reproduce it on Python 3.9 on my local machine.

Resolving patch:
diff --git a/privacyidea/api/lib/utils.py b/privacyidea/api/lib/utils.py
--- a/privacyidea/api/lib/utils.py
+++ b/privacyidea/api/lib/utils.py
@@ -63,7 +63,7 @@
 def getParam(param, key, optional=True, default=None, allow_empty=True, allowed_values=None):
     """
     returns a parameter from the request parameters.
-    
+
     :param param: the dictionary of parameters
     :type param: dict
     :param key: the name of the parameter
@@ -78,7 +78,7 @@ def getParam(param, key, optional=True, default=None, allow_empty=True, allowed_
     :param allowed_values: A list of allowed values. If another value is given,
         then the default value is returned
     :type allow_empty: bool
-    
+
     :return: the value (literal) of the parameter if exists or nothing
              in case the parameter is optional, otherwise throw an exception
     """
@@ -95,14 +95,14 @@ def getParam(param, key, optional=True, default=None, allow_empty=True, allowed_
         raise ParameterError("Parameter {0!r} must not be empty".format(key), id=905)
 
     if allowed_values and ret not in allowed_values:
-            ret = default
+        ret = default
 
     return ret
 
 
 def send_result(obj, rid=1, details=None):
-    '''
-    sendResult - return an json result document
+    """
+    sendResult - return a json result document
 
     :param obj: simple result object like dict, sting or list
     :type obj: dict or list or string/unicode
@@ -113,7 +113,7 @@ def send_result(obj, rid=1, details=None):
 
     :return: json rendered sting result
     :rtype: string
-    '''
+    """
     return jsonify(prepare_result(obj, rid, details))
 
 
@@ -163,7 +163,7 @@ def send_error(errstring, rid=1, context=None, error_code=-311, details=None):
 
 def send_html(output):
     """
-    Send the ouput as HTML to the client with the correct mimetype.
+    Send the output as HTML to the client with the correct mimetype.
 
     :param output: The HTML to send to the client
     :type output: str
@@ -177,7 +177,8 @@ def send_file(output, filename, content_type='text/csv'):
     """
     Send the output to the client with the "Content-disposition" header to
     declare it as a downloadable file.
-    :param output: The data that should be send as a file
+
+    :param output: The data that should be sent as a file
     :type output: str
     :param filename: The proposed filename
     :type filename: str
@@ -196,7 +197,7 @@ def send_csv_result(obj, data_key="tokens",
     """
     returns a CSV document of the input data (like in /token/list)
 
-    It takes a obj as a dict like:
+    It takes an obj as a dict like:
     { "tokens": [ { ...token1... }, { ...token2....}, ... ],
       "count": 100,
       "....": .... }
@@ -253,7 +254,7 @@ def check_unquote(request, data):
     https://httpwg.org/specs/rfc9110.html#field.user-agent
 
     :param request: The Flask request context
-    :type request: flask.Request
+    :type request: Flask.Request
     :param data: The dictionary containing the requested data
     :type data: dict
     :return: New dictionary with the possibly unquoted values
@@ -283,13 +284,13 @@ def get_all_params(request):
     return_param = {}
     if param:
         log.debug("Update params in request {0!s} {1!s} with values.".format(request.method,
-                                                                              request.base_url))
+                                                                             request.base_url))
         # Add the unquoted HTML and form parameters
         return_param = check_unquote(request, request.values)
 
     if request.is_json:
         log.debug("Update params in request {0!s} {1!s} with JSON data.".format(request.method,
-                                                                                 request.base_url))
+                                                                                request.base_url))
         # Add the original JSON data
         return_param.update(request.json)
     elif body:
@@ -303,7 +304,7 @@ def get_all_params(request):
 
     if request.view_args:
         log.debug("Update params in request {0!s} {1!s} with view_args.".format(request.method,
-                                                                                 request.base_url))
+                                                                                request.base_url))
         # We add the unquoted view_args
         return_param.update(check_unquote(request, request.view_args))
 
@@ -335,6 +336,7 @@ def verify_auth_token(auth_token, required_role=None):
     :param auth_token: The Auth Token
     :param required_role: list of "user" and "admin"
     :return: dict with authtype, realm, rights, role, username, exp, nonce
+    :rtype: dict
     """
     r = None
     if required_role is None:
@@ -343,11 +345,15 @@ def verify_auth_token(auth_token, required_role=None):
         raise AuthError(_("Authentication failure. Missing Authorization header."),
                         id=ERROR.AUTHENTICATE_AUTH_HEADER)
 
-    headers = jwt.get_unverified_header(auth_token)
+    try:
+        headers = jwt.get_unverified_header(auth_token)
+    except jwt.DecodeError as err:
+        raise AuthError(_("Authentication failure. Error during decoding your token: {0!s}").format(err),
+                        id=ERROR.AUTHENTICATE_DECODING_ERROR)
     algorithm = headers.get("alg")
     wrong_username = None
     if algorithm in TRUSTED_JWT_ALGOS:
-        # The trusted JWTs are RSA, PSS or eliptic curve signed
+        # The trusted JWTs are RSA, PSS or elliptic curve signed
         trusted_jwts = current_app.config.get("PI_TRUSTED_JWT", [])
         for trusted_jwt in trusted_jwts:
             try:
@@ -364,7 +370,7 @@ def verify_auth_token(auth_token, required_role=None):
                             r = wrong_username = j.get("username")
                 else:
                     log.warning("Unsupported JWT algorithm in PI_TRUSTED_JWT.")
-            except jwt.DecodeError as err:
+            except jwt.DecodeError as _e:
                 log.info("A given JWT definition does not match.")
             except jwt.ExpiredSignatureError as err:
                 # We have the correct token. It expired, so we raise an error
@@ -387,8 +393,8 @@ def verify_auth_token(auth_token, required_role=None):
         # If we require a certain role like "admin", but the users role does
         # not match
         raise AuthError(_("Authentication failure. "
-                        "You do not have the necessary role ({0!s}) to access "
-                        "this resource!").format(required_role),
+                          "You do not have the necessary role ({0!s}) to access "
+                          "this resource!").format(required_role),
                         id=ERROR.AUTHENTICATE_MISSING_RIGHT)
     return r
 


Test patch:
diff --git a/tests/test_api_lib_utils.py b/tests/test_api_lib_utils.py
--- a/tests/test_api_lib_utils.py
+++ b/tests/test_api_lib_utils.py
@@ -55,6 +55,11 @@ def test_02_check_policy_name(self):
         check_policy_name("pi-update-policysomething")
 
     def test_03_verify_auth_token(self):
+        # check broken JWT
+        self.assertRaises(AuthError, verify_auth_token,
+                          auth_token='not_valid_jwt',
+                          required_role="user")
+
         # create a jwt with a trusted private key
         with open("tests/testdata/jwt_sign.key", "r") as f:
             key = f.read()