feat!: change JWT access token expires (#30432)

Introduces JWT_ACCESS_TOKEN_EXPIRE_SECONDS setting. This is the number
of seconds a JWT access token remains valid. We use this custom
setting for JWT formatted access tokens, rather than the
django-oauth-toolkit setting ACCESS_TOKEN_EXPIRE_SECONDS, because the
JWT is non-revocable and we want it to be shorter lived than the
legacy Bearer (opaque) access tokens, and thus to have a smaller
default.

BREAKING CHANGE: The thing that is breaking is that JWT access tokens
will now have a 1 hour default, instead of a 10 hours default. If
third-party scripts are appropriately checking/refreshing the access
token, this should be ok. However, you can always override with a
longer duration temporarily. From a security perspective, we don't
recommend a longer duration, and you may consider a shorter duration.

ARCHBOM-2099
This commit is contained in:
Robert Raposa
2022-05-19 09:46:17 -04:00
committed by GitHub
parent 7deea6ec6e
commit 3fc852f53c
4 changed files with 36 additions and 2 deletions

View File

@@ -61,6 +61,16 @@ def create_jwt_from_token(token_dict, oauth_adapter, use_asymmetric_key=None):
access_token = oauth_adapter.get_access_token(token_dict['access_token'])
client = oauth_adapter.get_client_for_token(access_token)
# .. setting_name: JWT_ACCESS_TOKEN_EXPIRE_SECONDS
# .. setting_default: 60 * 60
# .. setting_description: The number of seconds a JWT access token remains valid. We use this
# custom setting for JWT formatted access tokens, rather than the django-oauth-toolkit setting
# ACCESS_TOKEN_EXPIRE_SECONDS, because the JWT is non-revocable and we want it to be shorter
# lived than the legacy Bearer (opaque) access tokens, and thus to have a smaller default.
# .. setting_warning: For security purposes, 1 hour (the default) is the maximum recommended setting
# value. For tighter security, you can use a shorter amount of time.
token_dict['expires_in'] = getattr(settings, 'JWT_ACCESS_TOKEN_EXPIRE_SECONDS', 60 * 60)
# TODO (ARCH-204) put access_token as a JWT ID claim (jti)
return _create_jwt(
access_token.user,

View File

@@ -16,7 +16,8 @@ class AccessTokenMixin:
""" Mixin for tests dealing with OAuth 2 access tokens. """
def assert_valid_jwt_access_token(self, access_token, user, scopes=None, should_be_expired=False, filters=None,
should_be_asymmetric_key=False, should_be_restricted=None, aud=None, secret=None):
should_be_asymmetric_key=False, should_be_restricted=None, aud=None, secret=None,
expires_in=None):
"""
Verify the specified JWT access token is valid, and belongs to the specified user.
Returns:
@@ -96,6 +97,9 @@ class AccessTokenMixin:
self.assertDictContainsSubset(expected, payload)
if expires_in:
assert payload['exp'] == payload['iat'] + expires_in
# Since we suppressed checking of expiry
# in the claim in the above check, because we want
# to fully examine the claims outside of the expiry,

View File

@@ -4,6 +4,7 @@ from unittest.mock import patch
import ddt
from django.test import TestCase
from django.test.utils import override_settings
from django.utils.timezone import now
from openedx.core.djangoapps.oauth_dispatch import jwt as jwt_api
@@ -66,6 +67,23 @@ class TestCreateJWTs(AccessTokenMixin, TestCase):
jwt_token = self._create_jwt_for_token(DOTAdapter(), use_asymmetric_key=True)
self._assert_jwt_is_valid(jwt_token, should_be_asymmetric_key=True)
def test_create_jwt_for_token_default_expire_seconds(self):
oauth_adapter = DOTAdapter()
jwt_token = self._create_jwt_for_token(oauth_adapter, use_asymmetric_key=False)
expected_expires_in = 60 * 60
self.assert_valid_jwt_access_token(
jwt_token, self.user, self.default_scopes, expires_in=expected_expires_in,
)
def test_create_jwt_for_token_overridden_expire_seconds(self):
oauth_adapter = DOTAdapter()
expected_expires_in = 60
with override_settings(JWT_ACCESS_TOKEN_EXPIRE_SECONDS=expected_expires_in):
jwt_token = self._create_jwt_for_token(oauth_adapter, use_asymmetric_key=False)
self.assert_valid_jwt_access_token(
jwt_token, self.user, self.default_scopes, expires_in=expected_expires_in,
)
@ddt.data((True, False))
def test_dot_create_jwt_for_token(self, client_restricted):
jwt_token = self._create_jwt_for_token(

View File

@@ -179,13 +179,15 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
response = self._post_request(self.user, client, token_type=token_type, headers=headers or {})
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
assert 'expires_in' in data
expected_default_expires_in = 60 * 60
assert data['expires_in'] == expected_default_expires_in
assert data['token_type'] == 'JWT'
self.assert_valid_jwt_access_token(
data['access_token'],
self.user,
data['scope'].split(' '),
should_be_restricted=False,
expires_in=expected_default_expires_in,
)
@ddt.data('dot_app')