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: