diff roundup/cgi/client.py @ 7809:be6cb2e0d471

feat: add support for rotating jwt keys This allows jwt_secret to have multiple ',' separated secrets. The first/leftmost should be used to sign new JWTs. All of them are used (starting from left/newest) to try to verify a JWT. If the first secret is < 32 chars in length JWTs are disabled. If any of the other secrets are < 32 chars, the configuration code causes the software to exit. This prevents insecure (too short) secrets from being used. Updated doc examples and tests.
author John Rouillard <rouilj@ieee.org>
date Thu, 14 Mar 2024 19:04:19 -0400
parents cc4b11ab2f22
children 928c20d4344b
line wrap: on
line diff
--- a/roundup/cgi/client.py	Wed Mar 13 18:25:59 2024 -0400
+++ b/roundup/cgi/client.py	Thu Mar 14 19:04:19 2024 -0400
@@ -1111,23 +1111,42 @@
             self.setHeader("WWW-Authenticate", "Basic")
             raise LoginError('Support for jwt disabled.')
 
-        secret = self.db.config.WEB_JWT_SECRET
-        if len(secret) < 32:
+
+        # If first ',' separated token is < 32, jwt is disabled.
+        # If second or later tokens are < 32 chars, the config system
+        # stops the tracker from starting so insecure tokens can not
+        # be used.
+        if len(self.db.config.WEB_JWT_SECRET[0]) < 32:
             # no support for jwt, this is fine.
             self.setHeader("WWW-Authenticate", "Basic")
             raise LoginError('Support for jwt disabled by admin.')
 
-        try:  # handle jwt exceptions
-            token = jwt.decode(challenge, secret,
-                               algorithms=['HS256'],
-                               audience=self.db.config.TRACKER_WEB,
-                               issuer=self.db.config.TRACKER_WEB)
-        except jwt.exceptions.InvalidTokenError as err:
-            self.setHeader("WWW-Authenticate", "Basic, Bearer")
-            self.make_user_anonymous()
-            raise LoginError(str(err))
-
-        return (token)
+        last_error = "Unknown error validating bearer token."
+
+        for secret in self.db.config.WEB_JWT_SECRET:
+            try:  # handle jwt exceptions
+                token = jwt.decode(challenge, secret,
+                                   algorithms=['HS256'],
+                                   audience=self.db.config.TRACKER_WEB,
+                                   issuer=self.db.config.TRACKER_WEB)
+                return (token)
+
+            except jwt.exceptions.InvalidSignatureError as err:
+                # Try more signatures.
+                # If all signatures generate InvalidSignatureError,
+                # we exhaust the loop and last_error is used to
+                # report the final (but not only) InvalidSignatureError
+                last_error = str(err)  # preserve for end of loop
+            except jwt.exceptions.InvalidTokenError as err:
+                self.setHeader("WWW-Authenticate", "Basic, Bearer")
+                self.make_user_anonymous()
+                raise LoginError(str(err))
+
+        # reach here only if no valid signature was found
+        self.setHeader("WWW-Authenticate", "Basic, Bearer")
+        self.make_user_anonymous()
+        raise LoginError(last_error)
+
 
     def determine_user(self, is_api=False):
         """Determine who the user is"""

Roundup Issue Tracker: http://roundup-tracker.org/