Skip to content

Commit fd4f209

Browse files
committed
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.
1 parent a2918d4 commit fd4f209

File tree

6 files changed

+255
-37
lines changed

6 files changed

+255
-37
lines changed

CHANGES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ Features:
141141
- the roundup-admin history command now dumps the journal entries
142142
in a more human readable format. Use the raw option to get the older
143143
machine parsible output. (John Rouillard)
144+
- Multiple JWT secrets are supported to allow key rotation. See
145+
an updated config.ini for details. (John Rouillard)
144146

145147

146148
2023-07-13 2.3.0

doc/rest.txt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2071,7 +2071,9 @@ only been tested with python3)::
20712071
claim['roles'] = newroles
20722072
else:
20732073
claim['roles'] = user_roles
2074-
secret = self.db.config.WEB_JWT_SECRET
2074+
2075+
# Sign with newest/first secret.
2076+
secret = self.db.config.WEB_JWT_SECRET[0]
20752077
myjwt = jwt.encode(claim, secret, algorithm='HS256')
20762078

20772079
# if jwt.__version__ >= 2.0.0 jwt.encode() returns string
@@ -2090,7 +2092,10 @@ only been tested with python3)::
20902092

20912093
myjwt = input['jwt'].value
20922094

2093-
secret = self.db.config.WEB_JWT_SECRET
2095+
secret = self.db.config.WEB_JWT_SECRET[0]
2096+
2097+
# only return decoded result if the newest signing key
2098+
# is used. Have older keys report an invalid signature.
20942099
try:
20952100
result = jwt.decode(myjwt, secret,
20962101
algorithms=['HS256'],

roundup/cgi/client.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,23 +1111,42 @@ def authenticate_bearer_token(self, challenge):
11111111
self.setHeader("WWW-Authenticate", "Basic")
11121112
raise LoginError('Support for jwt disabled.')
11131113

1114-
secret = self.db.config.WEB_JWT_SECRET
1115-
if len(secret) < 32:
1114+
1115+
# If first ',' separated token is < 32, jwt is disabled.
1116+
# If second or later tokens are < 32 chars, the config system
1117+
# stops the tracker from starting so insecure tokens can not
1118+
# be used.
1119+
if len(self.db.config.WEB_JWT_SECRET[0]) < 32:
11161120
# no support for jwt, this is fine.
11171121
self.setHeader("WWW-Authenticate", "Basic")
11181122
raise LoginError('Support for jwt disabled by admin.')
11191123

1120-
try: # handle jwt exceptions
1121-
token = jwt.decode(challenge, secret,
1122-
algorithms=['HS256'],
1123-
audience=self.db.config.TRACKER_WEB,
1124-
issuer=self.db.config.TRACKER_WEB)
1125-
except jwt.exceptions.InvalidTokenError as err:
1126-
self.setHeader("WWW-Authenticate", "Basic, Bearer")
1127-
self.make_user_anonymous()
1128-
raise LoginError(str(err))
1124+
last_error = "Unknown error validating bearer token."
1125+
1126+
for secret in self.db.config.WEB_JWT_SECRET:
1127+
try: # handle jwt exceptions
1128+
token = jwt.decode(challenge, secret,
1129+
algorithms=['HS256'],
1130+
audience=self.db.config.TRACKER_WEB,
1131+
issuer=self.db.config.TRACKER_WEB)
1132+
return (token)
1133+
1134+
except jwt.exceptions.InvalidSignatureError as err:
1135+
# Try more signatures.
1136+
# If all signatures generate InvalidSignatureError,
1137+
# we exhaust the loop and last_error is used to
1138+
# report the final (but not only) InvalidSignatureError
1139+
last_error = str(err) # preserve for end of loop
1140+
except jwt.exceptions.InvalidTokenError as err:
1141+
self.setHeader("WWW-Authenticate", "Basic, Bearer")
1142+
self.make_user_anonymous()
1143+
raise LoginError(str(err))
1144+
1145+
# reach here only if no valid signature was found
1146+
self.setHeader("WWW-Authenticate", "Basic, Bearer")
1147+
self.make_user_anonymous()
1148+
raise LoginError(last_error)
11291149

1130-
return (token)
11311150

11321151
def determine_user(self, is_api=False):
11331152
"""Determine who the user is"""

roundup/configuration.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,25 @@ class SecretNullableOption(NullableOption, SecretOption):
826826
get = SecretOption.get
827827
class_description = SecretOption.class_description
828828

829+
class ListSecretOption(SecretOption):
830+
# use get from SecretOption
831+
def get(self):
832+
value = SecretOption.get(self)
833+
return [x.lstrip() for x in value.split(',')]
834+
835+
class_description = SecretOption.class_description
836+
837+
def validate(self, options):
838+
if self.name == "WEB_JWT_SECRET":
839+
secrets = self.get()
840+
invalid_secrets = [ x for x in secrets[1:] if len(x) < 32]
841+
if invalid_secrets:
842+
raise OptionValueError(
843+
self, ", ".join(secrets),
844+
"One or more secrets less then 32 characters in length\n"
845+
"found: %s" % ', '.join(invalid_secrets))
846+
else:
847+
self.get()
829848

830849
class RedisUrlOption(SecretNullableOption):
831850
"""Do required check to make sure known bad parameters are not
@@ -1437,14 +1456,20 @@ def str2value(self, value):
14371456
"(Note the default value changes every time\n"
14381457
" roundup-admin updateconfig\n"
14391458
"is run, so it must be explicitly set to a non-empty string.\n"),
1440-
(SecretNullableOption, "jwt_secret", "disabled",
1441-
"This is used to generate/validate json web tokens (jwt).\n"
1442-
"Even if you don't use jwts it must not be empty.\n"
1443-
"If less than 256 bits (32 characters) in length it will\n"
1444-
"disable use of jwt. Changing this invalidates all jwts\n"
1445-
"issued by the roundup instance requiring *all* users to\n"
1446-
"generate new jwts. This is experimental and disabled by\n"
1447-
"default. It must be persistent across application restarts.\n"),
1459+
(ListSecretOption, "jwt_secret", "disabled",
1460+
"This is used to sign/validate json web tokens\n"
1461+
"(JWT). Even if you don't use JWTs it must not be\n"
1462+
"empty. You can use multiple secrets separated by a\n"
1463+
"comma ','. This allows for secret rotation. The newest\n"
1464+
"secret should be placed first and used for signing. The\n"
1465+
"rest of the secrets are used for validating an old JWT.\n"
1466+
"If the first secret is less than 256 bits (32\n"
1467+
"characters) in length JWTs are disabled. If other secrets\n"
1468+
"are less than 32 chars, the application will exit. Removing\n"
1469+
"a secret from this list invalidates all JWTs signed with\n"
1470+
"the secret. JWT support is experimental and disabled by\n"
1471+
"default. The secrets must be persistent across\n"
1472+
"application restarts.\n"),
14481473
)),
14491474
("rdbms", (
14501475
(DatabaseBackend, 'backend', NODEFAULT,

0 commit comments

Comments
 (0)