Repository: zulip/zulip

Issue:
JWT auth: deprecated calls to jwt.decode with no algorithms argument
`python3 -Wd tools/test-backend zerver.tests.test_auth_backends.TestJWTLogin` shows these warnings:

```
/srv/zulip-py3-venv/lib/python3.6/site-packages/jwt/api_jwt.py:81: DeprecationWarning: It is strongly recommended that you pass in a value for the "algorithms" argument when calling decode(). This argument will be mandatory in a future version.
  DeprecationWarning
/srv/zulip-py3-venv/lib/python3.6/site-packages/jwt/api_jws.py:145: DeprecationWarning: It is strongly recommended that you pass in a value for the "algorithms" argument when calling decode(). This argument will be mandatory in a future version.
  DeprecationWarning
```

See https://github.com/jpadilla/pyjwt/blob/1.5.1/CHANGELOG.md#fixed-1. We’ll probably need to make this a mandatory setting for JWT auth users; this is for protecting against symmetric/asymmetric key confusion attacks.


Discussion:
Hello @zulip/server-authentication members, this issue was labeled with the "area: authentication" label, so you may want to check it out!

<!-- areaLabelAddition -->
There are no current JWT auth users, so can freely change this.  

(AFAIK this system has not been used since 2014).  

Resolving patch:
diff --git a/zerver/views/auth.py b/zerver/views/auth.py
--- a/zerver/views/auth.py
+++ b/zerver/views/auth.py
@@ -381,14 +381,15 @@ def remote_user_sso(
 def remote_user_jwt(request: HttpRequest) -> HttpResponse:
     subdomain = get_subdomain(request)
     try:
-        auth_key = settings.JWT_AUTH_KEYS[subdomain]
+        key = settings.JWT_AUTH_KEYS[subdomain]["key"]
+        algorithms = settings.JWT_AUTH_KEYS[subdomain]["algorithms"]
     except KeyError:
         raise JsonableError(_("Auth key for this subdomain not found."))
 
     try:
         json_web_token = request.POST["json_web_token"]
         options = {'verify_signature': True}
-        payload = jwt.decode(json_web_token, auth_key, options=options)
+        payload = jwt.decode(json_web_token, key, algorithms=algorithms, options=options)
     except KeyError:
         raise JsonableError(_("No JSON web token passed in request"))
     except jwt.InvalidTokenError:
diff --git a/zproject/default_settings.py b/zproject/default_settings.py
--- a/zproject/default_settings.py
+++ b/zproject/default_settings.py
@@ -2,6 +2,7 @@
 
 if TYPE_CHECKING:
     from django_auth_ldap.config import LDAPSearch
+    from typing_extensions import TypedDict
 
 from .config import PRODUCTION, DEVELOPMENT, get_secret
 if PRODUCTION:
@@ -316,7 +317,14 @@
 STATSD_HOST = ''
 
 # Configuration for JWT auth.
-JWT_AUTH_KEYS: Dict[str, str] = {}
+if TYPE_CHECKING:
+    class JwtAuthKey(TypedDict):
+        key: str
+        # See https://pyjwt.readthedocs.io/en/latest/algorithms.html for a list
+        # of supported algorithms.
+        algorithms: List[str]
+
+JWT_AUTH_KEYS: Dict[str, "JwtAuthKey"] = {}
 
 # https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-SERVER_EMAIL
 # Django setting for what from address to use in error emails.


Test patch:
diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py
--- a/zerver/tests/test_auth_backends.py
+++ b/zerver/tests/test_auth_backends.py
@@ -3356,11 +3356,12 @@ class TestJWTLogin(ZulipTestCase):
 
     def test_login_success(self) -> None:
         payload = {'user': 'hamlet', 'realm': 'zulip.com'}
-        with self.settings(JWT_AUTH_KEYS={'zulip': 'key'}):
+        with self.settings(JWT_AUTH_KEYS={'zulip': {'key': 'key', 'algorithms': ['HS256']}}):
             email = self.example_email("hamlet")
             realm = get_realm('zulip')
-            auth_key = settings.JWT_AUTH_KEYS['zulip']
-            web_token = jwt.encode(payload, auth_key).decode('utf8')
+            key = settings.JWT_AUTH_KEYS['zulip']['key']
+            [algorithm] = settings.JWT_AUTH_KEYS['zulip']['algorithms']
+            web_token = jwt.encode(payload, key, algorithm).decode('utf8')
 
             user_profile = get_user_by_delivery_email(email, realm)
             data = {'json_web_token': web_token}
@@ -3370,18 +3371,20 @@ def test_login_success(self) -> None:
 
     def test_login_failure_when_user_is_missing(self) -> None:
         payload = {'realm': 'zulip.com'}
-        with self.settings(JWT_AUTH_KEYS={'zulip': 'key'}):
-            auth_key = settings.JWT_AUTH_KEYS['zulip']
-            web_token = jwt.encode(payload, auth_key).decode('utf8')
+        with self.settings(JWT_AUTH_KEYS={'zulip': {'key': 'key', 'algorithms': ['HS256']}}):
+            key = settings.JWT_AUTH_KEYS['zulip']['key']
+            [algorithm] = settings.JWT_AUTH_KEYS['zulip']['algorithms']
+            web_token = jwt.encode(payload, key, algorithm).decode('utf8')
             data = {'json_web_token': web_token}
             result = self.client_post('/accounts/login/jwt/', data)
             self.assert_json_error_contains(result, "No user specified in JSON web token claims", 400)
 
     def test_login_failure_when_realm_is_missing(self) -> None:
         payload = {'user': 'hamlet'}
-        with self.settings(JWT_AUTH_KEYS={'zulip': 'key'}):
-            auth_key = settings.JWT_AUTH_KEYS['zulip']
-            web_token = jwt.encode(payload, auth_key).decode('utf8')
+        with self.settings(JWT_AUTH_KEYS={'zulip': {'key': 'key', 'algorithms': ['HS256']}}):
+            key = settings.JWT_AUTH_KEYS['zulip']['key']
+            [algorithm] = settings.JWT_AUTH_KEYS['zulip']['algorithms']
+            web_token = jwt.encode(payload, key, algorithm).decode('utf8')
             data = {'json_web_token': web_token}
             result = self.client_post('/accounts/login/jwt/', data)
             self.assert_json_error_contains(result, "No organization specified in JSON web token claims", 400)
@@ -3392,12 +3395,12 @@ def test_login_failure_when_key_does_not_exist(self) -> None:
         self.assert_json_error_contains(result, "Auth key for this subdomain not found.", 400)
 
     def test_login_failure_when_key_is_missing(self) -> None:
-        with self.settings(JWT_AUTH_KEYS={'zulip': 'key'}):
+        with self.settings(JWT_AUTH_KEYS={'zulip': {'key': 'key', 'algorithms': ['HS256']}}):
             result = self.client_post('/accounts/login/jwt/')
             self.assert_json_error_contains(result, "No JSON web token passed in request", 400)
 
     def test_login_failure_when_bad_token_is_passed(self) -> None:
-        with self.settings(JWT_AUTH_KEYS={'zulip': 'key'}):
+        with self.settings(JWT_AUTH_KEYS={'zulip': {'key': 'key', 'algorithms': ['HS256']}}):
             result = self.client_post('/accounts/login/jwt/')
             self.assert_json_error_contains(result, "No JSON web token passed in request", 400)
             data = {'json_web_token': 'bad token'}
@@ -3406,9 +3409,10 @@ def test_login_failure_when_bad_token_is_passed(self) -> None:
 
     def test_login_failure_when_user_does_not_exist(self) -> None:
         payload = {'user': 'nonexisting', 'realm': 'zulip.com'}
-        with self.settings(JWT_AUTH_KEYS={'zulip': 'key'}):
-            auth_key = settings.JWT_AUTH_KEYS['zulip']
-            web_token = jwt.encode(payload, auth_key).decode('utf8')
+        with self.settings(JWT_AUTH_KEYS={'zulip': {'key': 'key', 'algorithms': ['HS256']}}):
+            key = settings.JWT_AUTH_KEYS['zulip']['key']
+            [algorithm] = settings.JWT_AUTH_KEYS['zulip']['algorithms']
+            web_token = jwt.encode(payload, key, algorithm).decode('utf8')
             data = {'json_web_token': web_token}
             result = self.client_post('/accounts/login/jwt/', data)
             self.assertEqual(result.status_code, 200)  # This should ideally be not 200.
@@ -3416,11 +3420,12 @@ def test_login_failure_when_user_does_not_exist(self) -> None:
 
     def test_login_failure_due_to_wrong_subdomain(self) -> None:
         payload = {'user': 'hamlet', 'realm': 'zulip.com'}
-        with self.settings(JWT_AUTH_KEYS={'acme': 'key'}):
+        with self.settings(JWT_AUTH_KEYS={'acme': {'key': 'key', 'algorithms': ['HS256']}}):
             with mock.patch('zerver.views.auth.get_subdomain', return_value='acme'), \
                     mock.patch('logging.warning'):
-                auth_key = settings.JWT_AUTH_KEYS['acme']
-                web_token = jwt.encode(payload, auth_key).decode('utf8')
+                key = settings.JWT_AUTH_KEYS['acme']['key']
+                [algorithm] = settings.JWT_AUTH_KEYS['acme']['algorithms']
+                web_token = jwt.encode(payload, key, algorithm).decode('utf8')
 
                 data = {'json_web_token': web_token}
                 result = self.client_post('/accounts/login/jwt/', data)
@@ -3429,11 +3434,12 @@ def test_login_failure_due_to_wrong_subdomain(self) -> None:
 
     def test_login_failure_due_to_empty_subdomain(self) -> None:
         payload = {'user': 'hamlet', 'realm': 'zulip.com'}
-        with self.settings(JWT_AUTH_KEYS={'': 'key'}):
+        with self.settings(JWT_AUTH_KEYS={'': {'key': 'key', 'algorithms': ['HS256']}}):
             with mock.patch('zerver.views.auth.get_subdomain', return_value=''), \
                     mock.patch('logging.warning'):
-                auth_key = settings.JWT_AUTH_KEYS['']
-                web_token = jwt.encode(payload, auth_key).decode('utf8')
+                key = settings.JWT_AUTH_KEYS['']['key']
+                [algorithm] = settings.JWT_AUTH_KEYS['']['algorithms']
+                web_token = jwt.encode(payload, key, algorithm).decode('utf8')
 
                 data = {'json_web_token': web_token}
                 result = self.client_post('/accounts/login/jwt/', data)
@@ -3442,10 +3448,11 @@ def test_login_failure_due_to_empty_subdomain(self) -> None:
 
     def test_login_success_under_subdomains(self) -> None:
         payload = {'user': 'hamlet', 'realm': 'zulip.com'}
-        with self.settings(JWT_AUTH_KEYS={'zulip': 'key'}):
+        with self.settings(JWT_AUTH_KEYS={'zulip': {'key': 'key', 'algorithms': ['HS256']}}):
             with mock.patch('zerver.views.auth.get_subdomain', return_value='zulip'):
-                auth_key = settings.JWT_AUTH_KEYS['zulip']
-                web_token = jwt.encode(payload, auth_key).decode('utf8')
+                key = settings.JWT_AUTH_KEYS['zulip']['key']
+                [algorithm] = settings.JWT_AUTH_KEYS['zulip']['algorithms']
+                web_token = jwt.encode(payload, key, algorithm).decode('utf8')
 
                 data = {'json_web_token': web_token}
                 result = self.client_post('/accounts/login/jwt/', data)