From d321ed5ccd79258488c6891ad9d2bcc5319d5fb3 Mon Sep 17 00:00:00 2001 From: Robert Raposa Date: Wed, 25 May 2022 08:53:36 -0400 Subject: [PATCH] refactor: extract shareable jwt methods (#30451) Extract some jwt related methods to enable reuse across views. This is in preparation for a change to AccessTokenExchangeView. Co-authored-by: jawad-khan --- openedx/core/djangoapps/oauth_dispatch/jwt.py | 28 +++++--- .../core/djangoapps/oauth_dispatch/views.py | 67 ++++++++++++++----- 2 files changed, 68 insertions(+), 27 deletions(-) diff --git a/openedx/core/djangoapps/oauth_dispatch/jwt.py b/openedx/core/djangoapps/oauth_dispatch/jwt.py index f0d4dafb8d..be0b71bfe9 100644 --- a/openedx/core/djangoapps/oauth_dispatch/jwt.py +++ b/openedx/core/djangoapps/oauth_dispatch/jwt.py @@ -61,27 +61,33 @@ 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, scopes=token_dict['scope'].split(' '), - expires_in=token_dict['expires_in'], + expires_in=get_jwt_access_token_expire_seconds(), use_asymmetric_key=use_asymmetric_key, is_restricted=oauth_adapter.is_client_restricted(client), filters=oauth_adapter.get_authorization_filters(client), ) +def get_jwt_access_token_expire_seconds(): + """ + Returns the number of seconds before a JWT access token expires. + + .. 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. + """ + return getattr(settings, 'JWT_ACCESS_TOKEN_EXPIRE_SECONDS', 60 * 60) + + def _create_jwt( user, scopes=None, diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py index ef5a1d93d7..4857280150 100644 --- a/openedx/core/djangoapps/oauth_dispatch/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/views.py @@ -17,7 +17,7 @@ from ratelimit.decorators import ratelimit from openedx.core.djangoapps.auth_exchange import views as auth_exchange_views from openedx.core.djangoapps.oauth_dispatch import adapters from openedx.core.djangoapps.oauth_dispatch.dot_overrides import views as dot_overrides_views -from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_from_token +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_from_token, get_jwt_access_token_expire_seconds class _DispatchingView(View): @@ -75,6 +75,39 @@ class _DispatchingView(View): return request.POST.get('client_id') +def _get_token_type(request): + """ + Get the token_type for the request. + + - Respects the HTTP_X_TOKEN_TYPE header if the token_type parameter is not supplied. + - Adds `oauth_token_type` custom attribute for monitoring. + """ + default_token_type = request.META.get('HTTP_X_TOKEN_TYPE', 'no_token_type_supplied') + token_type = request.POST.get('token_type', default_token_type).lower() + monitoring_utils.set_custom_attribute('oauth_token_type', token_type) + return token_type + + +def _get_jwt_dict_from_access_token_dict(token_dict, oauth_adapter): + """ + Returns a JWT token dict from the provided original (opaque) access token dict. + + Creates the new JWT, and then overrides various values in a copy of the + token dict with the JWT specific values. + """ + jwt_dict = token_dict.copy() + # TODO: It would be safer if create_jwt_from_token returned this + # dict directly, so it would not be possible for the dict and JWT + # to get out of sync, but that is a larger refactor to think through. + jwt = create_jwt_from_token(jwt_dict, oauth_adapter) + jwt_dict.update({ + 'access_token': jwt, + 'token_type': 'JWT', + 'expires_in': get_jwt_access_token_expire_seconds(), + }) + return jwt_dict + + @method_decorator( ratelimit( key='openedx.core.djangoapps.util.ratelimit.real_ip', rate=settings.RATELIMIT_RATE, @@ -89,26 +122,25 @@ class AccessTokenView(_DispatchingView): def dispatch(self, request, *args, **kwargs): response = super().dispatch(request, *args, **kwargs) - - token_type = request.POST.get('token_type', - request.META.get('HTTP_X_TOKEN_TYPE', 'no_token_type_supplied')).lower() - monitoring_utils.set_custom_attribute('oauth_token_type', token_type) - monitoring_utils.set_custom_attribute('oauth_grant_type', request.POST.get('grant_type', '')) + monitoring_utils.set_custom_attribute('oauth_grant_type', request.POST.get('grant_type', 'not-supplied')) + token_type = _get_token_type(request) if response.status_code == 200 and token_type == 'jwt': - response.content = self._build_jwt_response_from_access_token_response(request, response) + response.content = self._get_jwt_content_from_access_token_content(request, response) return response - def _build_jwt_response_from_access_token_response(self, request, response): - """ Builds the content of the response, including the JWT token. """ - token_dict = json.loads(response.content.decode('utf-8')) - jwt = create_jwt_from_token(token_dict, self.get_adapter(request)) - token_dict.update({ - 'access_token': jwt, - 'token_type': 'JWT', - }) - return json.dumps(token_dict) + def _get_jwt_content_from_access_token_content(self, request, response): + """ + Gets the JWT response content from the original (opaque) token response content. + + Includes the JWT token and token type in the response. + """ + opaque_token_dict = json.loads(response.content.decode('utf-8')) + jwt_token_dict = _get_jwt_dict_from_access_token_dict( + opaque_token_dict, self.get_adapter(request) + ) + return json.dumps(jwt_token_dict) class AuthorizationView(_DispatchingView): @@ -128,5 +160,8 @@ class AccessTokenExchangeView(_DispatchingView): class RevokeTokenView(_DispatchingView): """ Dispatch to the RevokeTokenView of django-oauth-toolkit + + Note: JWT access tokens are non-revocable, but you could still revoke + its associated refresh_token. """ dot_view = dot_views.RevokeTokenView