From 101bffbfc633fe351d7bcd831b90ca3543d035fc Mon Sep 17 00:00:00 2001 From: Logan Bertram Date: Tue, 29 Oct 2024 08:56:58 -0400 Subject: [PATCH] BB2-3321: Oauth revoke endpoint (#1251) * Initial attempt * Added test * Added to well-known configs and fhir capability statement and in swagger. * Fix openapi yaml typo * Added 404 condition for token not found * Improved 404 and error handling * Fix code scanning alert no. 51: Reflected server-side cross-site scripting Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Empty change to kick jenkins test flake * Fix token escape * Conform to Oauth expectations better --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: bwang-icf --- README.md | 2 +- apps/dot_ext/tests/test_authorization.py | 55 ++++++++++++++++++++++++ apps/dot_ext/urls.py | 1 + apps/dot_ext/v2/urls.py | 1 + apps/dot_ext/views/__init__.py | 2 +- apps/dot_ext/views/authorization.py | 40 +++++++++++++++-- apps/fhir/bluebutton/utils.py | 5 +++ apps/wellknown/views/openid.py | 1 + static/openapi.yaml | 10 +++++ 9 files changed, 112 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 903baa2b2..e8285f23b 100644 --- a/README.md +++ b/README.md @@ -167,4 +167,4 @@ by their respective authors. License ------- -This project is free and open source software under the Apache 2 license. See LICENSE for more information. \ No newline at end of file +This project is free and open source software under the Apache 2 license. See LICENSE for more information. diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index 3cc3ee263..e159074c1 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -578,6 +578,61 @@ def test_dag_expiration_exists(self): expiration_date_string = strftime('%Y-%m-%d %H:%M:%SZ', expiration_date.timetuple()) self.assertEqual(tkn["access_grant_expiration"][:-4], expiration_date_string[:-4]) + def test_revoke_endpoint(self): + redirect_uri = 'http://localhost' + # create a user + self._create_user('anna', '123456') + capability_a = self._create_capability('Capability A', []) + capability_b = self._create_capability('Capability B', []) + # create an application and add capabilities + application = self._create_application( + 'an app', + grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_type=Application.CLIENT_CONFIDENTIAL, + redirect_uris=redirect_uri) + application.scope.add(capability_a, capability_b) + # user logs in + request = HttpRequest() + self.client.login(request=request, username='anna', password='123456') + # post the authorization form with only one scope selected + payload = { + 'client_id': application.client_id, + 'response_type': 'code', + 'redirect_uri': redirect_uri, + 'scope': ['capability-a'], + 'expires_in': 86400, + 'allow': True, + } + response = self.client.post(reverse('oauth2_provider:authorize'), data=payload) + self.client.logout() + self.assertEqual(response.status_code, 302) + # now extract the authorization code and use it to request an access_token + query_dict = parse_qs(urlparse(response['Location']).query) + authorization_code = query_dict.pop('code') + token_request_data = { + 'grant_type': 'authorization_code', + 'code': authorization_code, + 'redirect_uri': redirect_uri, + 'client_id': application.client_id, + 'client_secret': application.client_secret_plain, + } + c = Client() + response = c.post('/v1/o/token/', data=token_request_data) + self.assertEqual(response.status_code, 200) + # extract token and use it to make a revoke request + tkn = response.json()['access_token'] + revoke_request_data = f"token={tkn}&client_id={application.client_id}&client_secret={application.client_secret_plain}" + content_type = "application/x-www-form-urlencoded" + c = Client() + rev_response = c.post('/v1/o/revoke/', data=revoke_request_data, content_type=content_type) + self.assertEqual(rev_response.status_code, 200) + # check DAG deletion + dags_count = DataAccessGrant.objects.count() + self.assertEqual(dags_count, 0) + # check token deletion + tkn_count = AccessToken.objects.filter(token=tkn).count() + self.assertEqual(tkn_count, 0) + def test_refresh_with_revoked_token(self): redirect_uri = 'http://localhost' # create a user diff --git a/apps/dot_ext/urls.py b/apps/dot_ext/urls.py index 16a7ba683..99fbed41d 100644 --- a/apps/dot_ext/urls.py +++ b/apps/dot_ext/urls.py @@ -15,6 +15,7 @@ ), path("token/", views.TokenView.as_view(), name="token"), path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"), + path("revoke/", views.RevokeView.as_view(), name="revoke"), path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"), ] diff --git a/apps/dot_ext/v2/urls.py b/apps/dot_ext/v2/urls.py index b02112ba3..91ba471b3 100755 --- a/apps/dot_ext/v2/urls.py +++ b/apps/dot_ext/v2/urls.py @@ -15,6 +15,7 @@ ), path("token/", views.TokenView.as_view(), name="token-v2"), path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token-v2"), + path("revoke/", views.RevokeView.as_view(), name="revoke-v2"), path("introspect/", views.IntrospectTokenView.as_view(), name="introspect-v2"), ] diff --git a/apps/dot_ext/views/__init__.py b/apps/dot_ext/views/__init__.py index bce5a9c09..da038284b 100644 --- a/apps/dot_ext/views/__init__.py +++ b/apps/dot_ext/views/__init__.py @@ -1,2 +1,2 @@ from .application import ApplicationRegistration, ApplicationUpdate, ApplicationDelete # NOQA -from .authorization import AuthorizationView, ApprovalView, TokenView, RevokeTokenView, IntrospectTokenView # NOQA +from .authorization import AuthorizationView, ApprovalView, TokenView, RevokeTokenView, RevokeView, IntrospectTokenView # NOQA diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 770fa395e..f0abc7e26 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -22,7 +22,7 @@ from oauth2_provider.models import get_application_model from oauthlib.oauth2.rfc6749.errors import InvalidClientError, InvalidGrantError from urllib.parse import urlparse, parse_qs - +import html from apps.dot_ext.scopes import CapabilitiesScopes import apps.logging.request_logger as bb2logging @@ -120,12 +120,12 @@ def sensitive_info_check(self, request): def get_template_names(self): flag = get_waffle_flag_model().get("limit_data_access") if waffle.switch_is_active('require-scopes'): - if flag.rollout or (flag.id is not None and flag.is_active_for_user(self.application.user)): + if flag.rollout or (flag.id is not None and self.application and flag.is_active_for_user(self.application.user)): return ["design_system/new_authorize_v2.html"] else: return ["design_system/authorize_v2.html"] else: - if flag.rollout or (flag.id is not None and flag.is_active_for_user(self.user)): + if flag.rollout or (flag.id is not None and self.user and flag.is_active_for_user(self.user)): return ["design_system/new_authorize_v2.html"] else: return ["design_system/authorize.html"] @@ -354,6 +354,40 @@ def post(self, request, *args, **kwargs): return super().post(request, args, kwargs) +@method_decorator(csrf_exempt, name="dispatch") +class RevokeView(DotRevokeTokenView): + + @method_decorator(sensitive_post_parameters("password")) + def post(self, request, *args, **kwargs): + at_model = get_access_token_model() + try: + app = validate_app_is_active(request) + except (InvalidClientError, InvalidGrantError) as error: + return json_response_from_oauth2_error(error) + + tkn = request.POST.get('token') + if tkn is not None: + escaped_tkn = html.escape(tkn) + else: + escaped_tkn = "" + + try: + token = at_model.objects.get(token=tkn) + except at_model.DoesNotExist: + log.debug(f"Token {escaped_tkn} was not found.") + + try: + dag = DataAccessGrant.objects.get( + beneficiary=token.user, + application=app + ) + dag.delete() + except Exception: + log.debug(f"DAG lookup failed for token {escaped_tkn}.") + + return super().post(request, args, kwargs) + + @method_decorator(csrf_exempt, name="dispatch") class IntrospectTokenView(DotIntrospectTokenView): diff --git a/apps/fhir/bluebutton/utils.py b/apps/fhir/bluebutton/utils.py index d5fcbb438..291bb95cf 100644 --- a/apps/fhir/bluebutton/utils.py +++ b/apps/fhir/bluebutton/utils.py @@ -636,12 +636,16 @@ def build_oauth_resource(request, v2=False, format_type="json"): %s + + %s + """ % ( endpoints["token_endpoint"], endpoints["authorization_endpoint"], + endpoints["revocation_endpoint"], ) else: # json @@ -680,6 +684,7 @@ def build_oauth_resource(request, v2=False, format_type="json"): "url": "authorize", "valueUri": endpoints["authorization_endpoint"], }, + {"url": "revoke", "valueUri": endpoints["revocation_endpoint"]}, ], } ] diff --git a/apps/wellknown/views/openid.py b/apps/wellknown/views/openid.py index ef54710ad..68f3ac9b2 100644 --- a/apps/wellknown/views/openid.py +++ b/apps/wellknown/views/openid.py @@ -61,6 +61,7 @@ def build_endpoint_info(data=OrderedDict(), v2=False, issuer=""): data["issuer"] = issuer data["authorization_endpoint"] = issuer + \ reverse('oauth2_provider:authorize' if not v2 else 'oauth2_provider_v2:authorize-v2') + data["revocation_endpoint"] = issuer + reverse('oauth2_provider:revoke') data["token_endpoint"] = issuer + \ reverse('oauth2_provider:token' if not v2 else 'oauth2_provider_v2:token-v2') data["userinfo_endpoint"] = issuer + \ diff --git a/static/openapi.yaml b/static/openapi.yaml index 21e36a4fa..646955f26 100755 --- a/static/openapi.yaml +++ b/static/openapi.yaml @@ -717,6 +717,8 @@ components: type: string authorization_endpoint: type: string + revocation_endpoint: + type: string token_endpoint: type: string userinfo_endpoint: @@ -746,6 +748,8 @@ components: type: string authorization_endpoint: type: string + revocation_endpoint: + type: string token_endpoint: type: string userinfo_endpoint: @@ -812,6 +816,7 @@ components: value: issuer: 'https://sandbox.bluebutton.cms.gov' authorization_endpoint: 'https://sandbox.bluebutton.cms.gov/v1/o/authorize/' + revocation_endpoint: 'https://sandbox.bluebutton.cms.gov/v1/o/revoke/' token_endpoint: 'https://sandbox.bluebutton.cms.gov/v1/o/token/' userinfo_endpoint: 'https://sandbox.bluebutton.cms.gov/v1/connect/userinfo' ui_locales_supported: @@ -830,6 +835,7 @@ components: value: issuer: 'https://sandbox.bluebutton.cms.gov' authorization_endpoint: 'https://sandbox.bluebutton.cms.gov/v2/o/authorize/' + revocation_endpoint: 'https://sandbox.bluebutton.cms.gov/v2/o/revoke/' token_endpoint: 'https://sandbox.bluebutton.cms.gov/v2/o/token/' userinfo_endpoint: 'https://sandbox.bluebutton.cms.gov/v2/connect/userinfo' ui_locales_supported: @@ -1230,6 +1236,8 @@ components: valueUri: 'https://sandbox.bluebutton.cms.gov/v1/o/token/' - url: authorize valueUri: 'https://sandbox.bluebutton.cms.gov/v1/o/authorize/' + - url: revoke + valueUri: 'https://sandbox.bluebutton.cms.gov/v1/o/revoke/' V2FhirMetadataExample: value: @@ -1665,6 +1673,8 @@ components: valueUri: 'https://sandbox.bluebutton.cms.gov/v2/o/token/' - url: authorize valueUri: 'https://sandbox.bluebutton.cms.gov/v2/o/authorize/' + - url: revoke + valueUri: 'https://sandbox.bluebutton.cms.gov/v2/o/revoke/' V1UserInfoExample: value: