From 4271e24eb90cd2e113eaa2250d807ff67e77a99d Mon Sep 17 00:00:00 2001 From: Moeez Zahid Date: Mon, 17 Oct 2022 16:57:11 +0500 Subject: [PATCH] feat: Waffle switch to disable JWT for mobile (#31096) * feat: Waffle switch to disable JWT for mobile --- .../oauth_dispatch/tests/test_views.py | 130 ++++++++++++++++++ .../core/djangoapps/oauth_dispatch/toggles.py | 24 ++++ .../core/djangoapps/oauth_dispatch/views.py | 16 ++- 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 openedx/core/djangoapps/oauth_dispatch/toggles.py diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py index 76a6209abc..d6ed9ca30c 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py @@ -13,11 +13,13 @@ from Cryptodome.PublicKey import RSA from django.conf import settings from django.test import RequestFactory, TestCase from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_switch from jwkest import jwk from oauth2_provider import models as dot_models from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.third_party_auth.tests.utils import ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinGoogle +from openedx.core.djangoapps.oauth_dispatch.toggles import DISABLE_JWT_FOR_MOBILE from . import mixins @@ -349,6 +351,55 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa """ self._test_jwt_access_token('dot_app', token_type='jwt', grant_type='password', asymmetric_jwt=True) + @override_waffle_switch(DISABLE_JWT_FOR_MOBILE, False) + @patch('openedx.core.djangoapps.oauth_dispatch.views.is_request_from_mobile_app') + def test_disable_jwt_waffle_switch_disabled_non_mobile(self, mock_is_request_from_mobile_app): + """ + DISABLE_JWT_FOR_MOBILE disabled + Non-mobile request + Test JWT token is returned + """ + mock_is_request_from_mobile_app.return_value = False + self._test_jwt_access_token('dot_app', token_type='jwt', grant_type='password') + + @override_waffle_switch(DISABLE_JWT_FOR_MOBILE, True) + @patch('openedx.core.djangoapps.oauth_dispatch.views.is_request_from_mobile_app') + def test_disable_jwt_waffle_switch_enabled_non_mobile(self, mock_is_request_from_mobile_app): + """ + DISABLE_JWT_FOR_MOBILE enabled + Non-mobile request + Test JWT token is returned + """ + mock_is_request_from_mobile_app.return_value = False + self._test_jwt_access_token('dot_app', token_type='jwt', grant_type='password') + + @override_waffle_switch(DISABLE_JWT_FOR_MOBILE, True) + @patch('openedx.core.djangoapps.oauth_dispatch.views.is_request_from_mobile_app') + def test_disable_jwt_waffle_switch_enabled_mobile(self, mock_is_request_from_mobile_app): + """ + DISABLE_JWT_FOR_MOBILE enabled + Mobile request + Test JWT token is not returned, and that Bearer token is returned instead + """ + mock_is_request_from_mobile_app.return_value = True + response = self._post_request(self.user, self.dot_app, token_type='jwt', asymmetric_jwt=True) + data = json.loads(response.content.decode('utf-8')) + access_token = data['access_token'] + assert response.status_code == 200 + assert data['token_type'] == "Bearer" + assert dot_models.AccessToken.objects.filter(token=access_token).exists() is True + + @override_waffle_switch(DISABLE_JWT_FOR_MOBILE, False) + @patch('openedx.core.djangoapps.oauth_dispatch.views.is_request_from_mobile_app') + def test_disable_jwt_waffle_switch_disabled_mobile(self, mock_is_request_from_mobile_app): + """ + DISABLE_JWT_FOR_MOBILE disabled + Mobile request + Test JWT token is returned + """ + mock_is_request_from_mobile_app.return_value = True + self._test_jwt_access_token('dot_app', token_type='jwt', grant_type='password', asymmetric_jwt=True) + @ddt.ddt @httpretty.activate @@ -376,6 +427,29 @@ class TestAccessTokenExchangeView(ThirdPartyOAuthTestMixinGoogle, ThirdPartyOAut return body + def _test_jwt_access_token(self, client_attr, token_type=None, headers=None, grant_type=None, asymmetric_jwt=False): + """ + Test response for JWT token. + """ + client = getattr(self, client_attr) + self.oauth_client = client + self._setup_provider_response(success=True) + response = self._post_request(self.user, client, token_type=token_type, + headers=headers or {}, asymmetric_jwt=asymmetric_jwt) + assert response.status_code == 200 + data = json.loads(response.content.decode('utf-8')) + assert 'expires_in' in data + assert data['expires_in'] > 0 + assert data['token_type'] == 'JWT' + + self.assert_valid_jwt_access_token( + data['access_token'], + self.user, + data['scope'].split(' '), + grant_type=grant_type, + should_be_asymmetric_key=asymmetric_jwt + ) + @ddt.data('dot_app') def test_access_token_exchange_calls_dispatched_view(self, client_attr): client = getattr(self, client_attr) @@ -435,6 +509,62 @@ class TestAccessTokenExchangeView(ThirdPartyOAuthTestMixinGoogle, ThirdPartyOAut data = json.loads(response.content.decode('utf-8')) assert data['error'] == 'account_disabled' + @override_waffle_switch(DISABLE_JWT_FOR_MOBILE, False) + @patch('openedx.core.djangoapps.oauth_dispatch.views.is_request_from_mobile_app') + @ddt.data('dot_app') + def test_disable_jwt_waffle_switch_disabled_non_mobile(self, client_attr, mock_is_request_from_mobile_app): + """ + DISABLE_JWT_FOR_MOBILE disabled + Non-mobile request + Test JWT token is returned + """ + mock_is_request_from_mobile_app.return_value = False + self._test_jwt_access_token(client_attr, token_type='jwt', grant_type='password') + + @override_waffle_switch(DISABLE_JWT_FOR_MOBILE, True) + @patch('openedx.core.djangoapps.oauth_dispatch.views.is_request_from_mobile_app') + @ddt.data('dot_app') + def test_disable_jwt_waffle_switch_enabled_non_mobile(self, client_attr, mock_is_request_from_mobile_app): + """ + DISABLE_JWT_FOR_MOBILE enabled + Non-mobile request + Test JWT token is returned + """ + mock_is_request_from_mobile_app.return_value = False + self._test_jwt_access_token(client_attr, token_type='jwt', grant_type='password') + + @override_waffle_switch(DISABLE_JWT_FOR_MOBILE, True) + @patch('openedx.core.djangoapps.oauth_dispatch.views.is_request_from_mobile_app') + @ddt.data('dot_app') + def test_disable_jwt_waffle_switch_enabled_mobile(self, client_attr, mock_is_request_from_mobile_app): + """ + DISABLE_JWT_FOR_MOBILE enabled + Mobile request + Test JWT token is not returned, and that Bearer token is returned instead + """ + mock_is_request_from_mobile_app.return_value = True + client = getattr(self, client_attr) + self.oauth_client = client + self._setup_provider_response(success=True) + response = self._post_request(self.user, client, token_type='jwt', asymmetric_jwt=True) + data = json.loads(response.content.decode('utf-8')) + access_token = data['access_token'] + assert response.status_code == 200 + assert data['token_type'] == "Bearer" + assert dot_models.AccessToken.objects.filter(token=access_token).exists() is True + + @override_waffle_switch(DISABLE_JWT_FOR_MOBILE, False) + @patch('openedx.core.djangoapps.oauth_dispatch.views.is_request_from_mobile_app') + @ddt.data('dot_app') + def test_disable_jwt_waffle_switch_disabled_mobile(self, client_attr, mock_is_request_from_mobile_app): + """ + DISABLE_JWT_FOR_MOBILE disabled + Mobile request + Test JWT token is returned + """ + mock_is_request_from_mobile_app.return_value = True + self._test_jwt_access_token(client_attr, token_type='jwt', grant_type='password', asymmetric_jwt=True) + # pylint: disable=abstract-method @ddt.ddt diff --git a/openedx/core/djangoapps/oauth_dispatch/toggles.py b/openedx/core/djangoapps/oauth_dispatch/toggles.py new file mode 100644 index 0000000000..1e6904a277 --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/toggles.py @@ -0,0 +1,24 @@ +""" +Toggles for Oauth Dispatch. +""" + + +from edx_toggles.toggles import WaffleSwitch + +OAUTH_DISPATCH_WAFFLE_SWITCH_NAMESPACE = 'oauth_dispatch' + +# .. toggle_name: DISABLE_JWT_FOR_MOBILE +# .. toggle_implementation: WaffleSwitch +# .. toggle_default: False +# .. toggle_description: Set toggle to True to ignore mobile requests +# for JWTs, and instead provide the legacy opaque Bearer token. This +# toggle can be used as a temporary kill switch as the mobile app +# attempts to rollout the transition to JWT authentication for the +# first time. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2022-10-14 +# .. toggle_target_removal_date: 2022-12-30 +DISABLE_JWT_FOR_MOBILE = WaffleSwitch( + f'{OAUTH_DISPATCH_WAFFLE_SWITCH_NAMESPACE}.disable_jwt_for_mobile', + __name__ +) diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py index dbdebfae3f..19b842f087 100644 --- a/openedx/core/djangoapps/oauth_dispatch/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/views.py @@ -18,6 +18,8 @@ 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_token_dict +from openedx.core.djangoapps.oauth_dispatch.toggles import DISABLE_JWT_FOR_MOBILE +from openedx.core.lib.mobile_utils import is_request_from_mobile_app class _DispatchingView(View): @@ -104,8 +106,13 @@ class AccessTokenView(_DispatchingView): response = super().dispatch(request, *args, **kwargs) monitoring_utils.set_custom_attribute('oauth_grant_type', request.POST.get('grant_type', 'not-supplied')) token_type = _get_token_type(request) + is_jwt_disabled = False - if response.status_code == 200 and token_type == 'jwt': + # Temporarily add control to disable jwt on mobile if needed + if is_request_from_mobile_app(request): + is_jwt_disabled = DISABLE_JWT_FOR_MOBILE.is_enabled() + + if response.status_code == 200 and token_type == 'jwt' and not is_jwt_disabled: response.content = self._get_jwt_content_from_access_token_content(request, response) return response @@ -139,8 +146,13 @@ class AccessTokenExchangeView(_DispatchingView): def dispatch(self, request, *args, **kwargs): response = super().dispatch(request, *args, **kwargs) token_type = _get_token_type(request) + is_jwt_disabled = False - if response.status_code == 200 and token_type == 'jwt': + # Temporarily add control to disable jwt on mobile if needed + if is_request_from_mobile_app(request): + is_jwt_disabled = DISABLE_JWT_FOR_MOBILE.is_enabled() + + if response.status_code == 200 and token_type == 'jwt' and not is_jwt_disabled: response.data = self._get_jwt_data_from_access_token_data(request, response) return response