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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user