From eb64ae958310a91f5531909aad6c214706007cda Mon Sep 17 00:00:00 2001 From: James Demery Date: Fri, 21 Nov 2025 09:18:18 -0500 Subject: [PATCH 01/14] BB2-4250: Initial commit - sorting out how to use the flag in different places --- apps/dot_ext/views/authorization.py | 17 ++++++++++++++++- apps/fhir/bluebutton/views/generic.py | 1 + apps/fhir/bluebutton/views/read.py | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 9a372fe2d..69fddaa1e 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -4,10 +4,12 @@ from functools import wraps from time import strftime +from django.contrib.auth import get_user_model from django.contrib.auth.views import redirect_to_login from django.http import JsonResponse from django.http.response import HttpResponse, HttpResponseBadRequest from django.template.response import TemplateResponse +from django.core.exceptions import ObjectDoesNotExist from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters @@ -20,7 +22,7 @@ from oauth2_provider.views.introspect import ( IntrospectTokenView as DotIntrospectTokenView, ) -from waffle import switch_is_active +from waffle import switch_is_active, get_waffle_flag_model from oauth2_provider.models import get_application_model from oauthlib.oauth2 import AccessDeniedError from oauthlib.oauth2.rfc6749.errors import InvalidClientError, InvalidGrantError, InvalidRequestError @@ -102,6 +104,19 @@ def _has_param(self, request, key): def _check_for_required_params(self, request): missing_params = [] v3 = True if request.path.startswith('/v3/o/authorize') else False + flag = get_waffle_flag_model().get("v3_early_adopter") + req_meta = request.META + url_query = parse_qs(req_meta.get('QUERY_STRING')) + client_id = url_query.get('client_id', [None]) + try: + app = get_application_model().objects.get(client_id=client_id[0]) + application_user = get_user_model().objects.get(id=app.user_id) + if flag.id is not None and flag.is_active_for_user(application_user): + print("flag is active for this user") + else: + print("flag is not active for this user") + except ObjectDoesNotExist: + print("object not found") if switch_is_active('require_pkce'): if not request.GET.get('code_challenge', None): diff --git a/apps/fhir/bluebutton/views/generic.py b/apps/fhir/bluebutton/views/generic.py index 6cb6e6cb0..d6831461b 100644 --- a/apps/fhir/bluebutton/views/generic.py +++ b/apps/fhir/bluebutton/views/generic.py @@ -96,6 +96,7 @@ def initial(self, request, resource_type, *args, **kwargs): if "HTTP_AUTHORIZATION" in req_meta: access_token = req_meta["HTTP_AUTHORIZATION"].split(" ")[1] try: + # TODO-4250 is this a place we need a flag check as well? at = AccessToken.objects.get(token=access_token) log_message = { "name": "FHIR Endpoint AT Logging", diff --git a/apps/fhir/bluebutton/views/read.py b/apps/fhir/bluebutton/views/read.py index 2513d30b9..420b4c01b 100644 --- a/apps/fhir/bluebutton/views/read.py +++ b/apps/fhir/bluebutton/views/read.py @@ -31,6 +31,7 @@ def initial(self, request, *args, **kwargs): return super().initial(request, self.resource_type, *args, **kwargs) def get(self, request, *args, **kwargs): + # 4250-TODO: Do we check for the flag here as well? Implement the same thing in search? In case of refresh token? return super().get(request, self.resource_type, *args, **kwargs) def build_parameters(self, *args, **kwargs): From 499d00aefafeb357f3d08ab6894c6bd59e59e5b5 Mon Sep 17 00:00:00 2001 From: James Demery Date: Fri, 21 Nov 2025 14:52:13 -0500 Subject: [PATCH 02/14] Ensure 403s are thrown if an application is not in the v3_early_adopter waffle_flag. Working for read/search v3 calls, v3 auth/token flows (still need to add to some other auth views) --- apps/dot_ext/views/authorization.py | 49 +++++++++++++++++++++++++++- apps/fhir/bluebutton/permissions.py | 27 ++++++++++++++- apps/fhir/bluebutton/views/read.py | 8 ++++- apps/fhir/bluebutton/views/search.py | 10 ++++-- hhs_oauth_server/settings/base.py | 7 ++++ 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 69fddaa1e..6bd62074e 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -4,6 +4,7 @@ from functools import wraps from time import strftime +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.views import redirect_to_login from django.http import JsonResponse @@ -15,7 +16,8 @@ from django.views.decorators.debug import sensitive_post_parameters from apps.dot_ext.constants import TOKEN_ENDPOINT_V3_KEY from oauth2_provider.exceptions import OAuthToolkitError -from oauth2_provider.views.base import app_authorized, get_access_token_model +from oauth2_provider.views.base import app_authorized +from oauth2_provider.models import get_refresh_token_model, get_access_token_model from oauth2_provider.views.base import AuthorizationView as DotAuthorizationView from oauth2_provider.views.base import TokenView as DotTokenView from oauth2_provider.views.base import RevokeTokenView as DotRevokeTokenView @@ -30,6 +32,7 @@ import html from apps.dot_ext.scopes import CapabilitiesScopes import apps.logging.request_logger as bb2logging +from apps.versions import Versions from ..signals import beneficiary_authorized_application from ..forms import SimpleAllowForm @@ -43,6 +46,7 @@ ) from ..models import Approval from ..utils import ( + get_api_version_number_from_url, remove_application_user_pair_tokens_data_access, validate_app_is_active, json_response_from_oauth2_error, @@ -75,8 +79,50 @@ def _wrapped(request, *args, **kwargs): return _wrapped +def check_v3_endpoint_access(view_func): + @wraps(view_func) + def _wrapped(request, *args, **kwargs): + # 4250-TODO how do we not call this so many times? + path_info = request.__dict__.get('path_info') + version = get_api_version_number_from_url(path_info) + if version != Versions.V3: + return view_func(request, *args, **kwargs) + + flag = get_waffle_flag_model().get('v3_early_adopter') + req_meta = request.META + url_query = parse_qs(req_meta.get('QUERY_STRING')) + client_id = url_query.get('client_id', [None]) + try: + if client_id[0]: + application = get_application_model().objects.get(client_id=client_id[0]) + else: + url_query = parse_qs(request._body.decode('utf-8')) + refresh_token_from_request = url_query.get('refresh_token', [None]) + refresh_token = get_refresh_token_model().objects.get(token=refresh_token_from_request[0]) + application = get_application_model().objects.get(id=refresh_token.application_id) + + application_user = get_user_model().objects.get(id=application.user_id) + + if flag.id is not None and flag.is_active_for_user(application_user): + return view_func(request, *args, **kwargs) + else: + return JsonResponse( + {'status_code': 403, 'message': settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name)}, + status=403, + ) + except ObjectDoesNotExist: + # 4250-TODO Do we need this? + return JsonResponse( + {'status_code': 500, 'message': 'Error retrieving data'}, + status=500, + ) + + return _wrapped + + @method_decorator(csrf_exempt, name="dispatch") @method_decorator(require_post_state_decorator, name="dispatch") +@method_decorator(check_v3_endpoint_access, name="dispatch") class AuthorizationView(DotAuthorizationView): """ Override the base authorization view from dot to @@ -407,6 +453,7 @@ def dispatch(self, request, uuid, *args, **kwargs): @method_decorator(csrf_exempt, name="dispatch") +@method_decorator(check_v3_endpoint_access, name="dispatch") class TokenView(DotTokenView): def validate_token_endpoint_request_body(self, request): diff --git a/apps/fhir/bluebutton/permissions.py b/apps/fhir/bluebutton/permissions.py index cf3a62022..25413df7f 100644 --- a/apps/fhir/bluebutton/permissions.py +++ b/apps/fhir/bluebutton/permissions.py @@ -2,8 +2,11 @@ from django.conf import settings from django.contrib.auth import get_user_model +from oauth2_provider.views.base import get_access_token_model +from oauth2_provider.models import get_application_model from rest_framework import permissions, exceptions -from rest_framework.exceptions import AuthenticationFailed +from rest_framework.exceptions import AuthenticationFailed, PermissionDenied +from waffle import get_waffle_flag_model from .constants import ALLOWED_RESOURCE_TYPES from apps.versions import Versions, VersionNotMatched @@ -106,3 +109,25 @@ def has_permission(self, request, view): ) return True + + +class V3EarlyAdopterPermission(permissions.BasePermission): + def has_permission(self, request, view): + print("IN HAS_PERMISSION OF V3EarlyAdopterPermission") + print("V3EarlyAdopterPermission request: ", request.__dict__) + print("V3EarlyAdopterPermission view: ", view.__dict__) + # if it is not version 3, we do not need to check the waffle switch or flag + if view.version < Versions.V3: + return True + + token = get_access_token_model().objects.get(token=request._auth) + application = get_application_model().objects.get(id=token.application_id) + application_user = get_user_model().objects.get(id=application.user_id) + flag = get_waffle_flag_model().get('v3_early_adopter') + + if flag.id is not None and flag.is_active_for_user(application_user): + return True + else: + raise PermissionDenied( + settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name) + ) diff --git a/apps/fhir/bluebutton/views/read.py b/apps/fhir/bluebutton/views/read.py index 420b4c01b..697e199cb 100644 --- a/apps/fhir/bluebutton/views/read.py +++ b/apps/fhir/bluebutton/views/read.py @@ -2,7 +2,12 @@ from apps.authorization.permissions import DataAccessGrantPermission from apps.capabilities.permissions import TokenHasProtectedCapability -from ..permissions import (ReadCrosswalkPermission, ResourcePermission, ApplicationActivePermission) +from ..permissions import ( + ReadCrosswalkPermission, + ResourcePermission, + ApplicationActivePermission, + V3EarlyAdopterPermission +) from apps.fhir.bluebutton.views.generic import FhirDataView @@ -21,6 +26,7 @@ class ReadView(FhirDataView): ReadCrosswalkPermission, DataAccessGrantPermission, TokenHasProtectedCapability, + V3EarlyAdopterPermission, ] def __init__(self, version=1): diff --git a/apps/fhir/bluebutton/views/search.py b/apps/fhir/bluebutton/views/search.py index b1cd87ec4..0492ff7b7 100644 --- a/apps/fhir/bluebutton/views/search.py +++ b/apps/fhir/bluebutton/views/search.py @@ -16,7 +16,12 @@ from apps.fhir.bluebutton.views.generic import FhirDataView from apps.authorization.permissions import DataAccessGrantPermission from apps.capabilities.permissions import TokenHasProtectedCapability -from ..permissions import (SearchCrosswalkPermission, ResourcePermission, ApplicationActivePermission) +from ..permissions import ( + SearchCrosswalkPermission, + ResourcePermission, + ApplicationActivePermission, + V3EarlyAdopterPermission +) class HasSearchScope(permissions.BasePermission): @@ -42,7 +47,8 @@ class SearchView(FhirDataView): SearchCrosswalkPermission, DataAccessGrantPermission, TokenHasProtectedCapability, - HasSearchScope + HasSearchScope, + V3EarlyAdopterPermission, ] # Regex to match a valid _lastUpdated value that can begin with lt, le, gt and ge operators diff --git a/hhs_oauth_server/settings/base.py b/hhs_oauth_server/settings/base.py index 8ef49b02d..9b0ada280 100644 --- a/hhs_oauth_server/settings/base.py +++ b/hhs_oauth_server/settings/base.py @@ -610,6 +610,13 @@ 'and consent to share their data.' ) +APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET = ( + 'This application, {}, does not yet have access to v3 endpoints.' + ' If you are the app maintainer, please contact the Blue Button API team.' + ' If you are a Medicare Beneficiary and need assistance, please contact' + ' the support team for the application you are trying to access.' +) + FHIR_CLIENT_CERTSTORE = env( "DJANGO_FHIR_CERTSTORE", os.path.join(BASE_DIR, os.environ.get("DJANGO_FHIR_CERTSTORE_REL", "../certstore")), From 0699b7d443f3df0e6f806129b4ae9120ab8c0d46 Mon Sep 17 00:00:00 2001 From: James Demery Date: Fri, 21 Nov 2025 17:11:07 -0500 Subject: [PATCH 03/14] Some comments, prevent v3/userinfo from returning successfully if the flag is not enabled for that app --- apps/accounts/views/oauth2_profile.py | 19 +++++++++++++++++++ apps/dot_ext/views/authorization.py | 17 +++-------------- apps/fhir/bluebutton/views/search.py | 2 ++ 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/accounts/views/oauth2_profile.py b/apps/accounts/views/oauth2_profile.py index f79c5eb3c..449a91149 100644 --- a/apps/accounts/views/oauth2_profile.py +++ b/apps/accounts/views/oauth2_profile.py @@ -1,8 +1,12 @@ from collections import OrderedDict +from django.conf import settings +from django.contrib.auth import get_user_model from django.http import JsonResponse from oauth2_provider.contrib.rest_framework import OAuth2Authentication from oauth2_provider.decorators import protected_resource +from oauth2_provider.models import get_access_token_model, get_application_model from rest_framework.decorators import api_view, permission_classes, authentication_classes +from waffle import get_waffle_flag_model from apps.authorization.permissions import DataAccessGrantPermission from apps.capabilities.permissions import TokenHasProtectedCapability @@ -45,6 +49,19 @@ def _get_userinfo(user, version=Versions.NOT_AN_API_VERSION): @protected_resource() # Django OAuth Toolkit -> resource_owner = AccessToken def _openidconnect_userinfo(request, version=Versions.NOT_AN_API_VERSION): # NOTE: The **kwargs are not used anywhere down the callchain, and are being ignored. + # 4250: Handling to ensure this only returns successfully if the flag is enabled for the application + # associated with the user making the call + if version == Versions.V3: + user = get_user_model().objects.get(username=request.resource_owner) + access_token = get_access_token_model().objects.get(user_id=user.id) + application = get_application_model().objects.get(id=access_token.application_id) + application_user = get_user_model().objects.get(id=application.user_id) + flag = get_waffle_flag_model().get('v3_early_adopter') + if flag.id is None or not flag.is_active_for_user(application_user): + return JsonResponse( + {'status_code': 403, 'message': settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name)}, + status=403, + ) return JsonResponse(_get_userinfo(request.resource_owner, version)) @@ -58,6 +75,8 @@ def openidconnect_userinfo_v2(request): def openidconnect_userinfo_v3(request): + print("openidconnect_userinfo_v3 request: ", request.__dict__) + print("openidconnect_userinfo_v3 user: ", request.user.__dict__) return _openidconnect_userinfo(request, version=Versions.V3) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 6bd62074e..78664f6eb 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -150,19 +150,6 @@ def _has_param(self, request, key): def _check_for_required_params(self, request): missing_params = [] v3 = True if request.path.startswith('/v3/o/authorize') else False - flag = get_waffle_flag_model().get("v3_early_adopter") - req_meta = request.META - url_query = parse_qs(req_meta.get('QUERY_STRING')) - client_id = url_query.get('client_id', [None]) - try: - app = get_application_model().objects.get(client_id=client_id[0]) - application_user = get_user_model().objects.get(id=app.user_id) - if flag.id is not None and flag.is_active_for_user(application_user): - print("flag is active for this user") - else: - print("flag is not active for this user") - except ObjectDoesNotExist: - print("object not found") if switch_is_active('require_pkce'): if not request.GET.get('code_challenge', None): @@ -177,6 +164,8 @@ def _check_for_required_params(self, request): error_message = "State parameter should have a minimum of 16 characters" return JsonResponse({"status_code": 400, "message": error_message}, status=400) + # BB2-4250: This code will not execute if the application is not in the v3_early_adopter flag + # so it will not be modified as part of BB2-4250 if switch_is_active('v3_endpoints') and v3: if 'scope' not in request.GET: missing_params.append("scope") @@ -452,8 +441,8 @@ def dispatch(self, request, uuid, *args, **kwargs): return result +# @method_decorator(check_v3_endpoint_access, name="dispatch") @method_decorator(csrf_exempt, name="dispatch") -@method_decorator(check_v3_endpoint_access, name="dispatch") class TokenView(DotTokenView): def validate_token_endpoint_request_body(self, request): diff --git a/apps/fhir/bluebutton/views/search.py b/apps/fhir/bluebutton/views/search.py index 0492ff7b7..42601164a 100644 --- a/apps/fhir/bluebutton/views/search.py +++ b/apps/fhir/bluebutton/views/search.py @@ -186,6 +186,8 @@ def filter_parameters(self, request): query_schema = getattr(self, "QUERY_SCHEMA", {}) + # BB2-4250: Does not seem that this code will execute given the new permission class + # so leaving it as is if waffle.switch_is_active('v3_endpoints'): query_schema['_tag'] = self.validate_tag() # _tag if presents, is a string value From 423bb30e11f64a07efcd89cb6cd0dbec6bee0802 Mon Sep 17 00:00:00 2001 From: James Demery Date: Mon, 24 Nov 2025 13:25:30 -0500 Subject: [PATCH 04/14] Ensure token and auth flows function or throw 403s depending on values in the flag. Add 403 handling for userinfo v3 --- apps/accounts/views/oauth2_profile.py | 23 +----- apps/dot_ext/views/authorization.py | 107 +++++++++++++++----------- apps/fhir/bluebutton/permissions.py | 3 - apps/wellknown/permissions.py | 36 +++++++++ 4 files changed, 102 insertions(+), 67 deletions(-) create mode 100644 apps/wellknown/permissions.py diff --git a/apps/accounts/views/oauth2_profile.py b/apps/accounts/views/oauth2_profile.py index 449a91149..cd1050e58 100644 --- a/apps/accounts/views/oauth2_profile.py +++ b/apps/accounts/views/oauth2_profile.py @@ -1,12 +1,8 @@ from collections import OrderedDict -from django.conf import settings -from django.contrib.auth import get_user_model from django.http import JsonResponse from oauth2_provider.contrib.rest_framework import OAuth2Authentication from oauth2_provider.decorators import protected_resource -from oauth2_provider.models import get_access_token_model, get_application_model from rest_framework.decorators import api_view, permission_classes, authentication_classes -from waffle import get_waffle_flag_model from apps.authorization.permissions import DataAccessGrantPermission from apps.capabilities.permissions import TokenHasProtectedCapability @@ -14,6 +10,7 @@ from apps.fhir.bluebutton.permissions import ApplicationActivePermission from apps.versions import Versions +from apps.wellknown.permissions import V3EarlyAdopterWellKnownPermission def _get_userinfo(user, version=Versions.NOT_AN_API_VERSION): @@ -45,23 +42,11 @@ def _get_userinfo(user, version=Versions.NOT_AN_API_VERSION): @authentication_classes([OAuth2Authentication]) @permission_classes([ApplicationActivePermission, TokenHasProtectedCapability, - DataAccessGrantPermission]) + DataAccessGrantPermission, + V3EarlyAdopterWellKnownPermission]) @protected_resource() # Django OAuth Toolkit -> resource_owner = AccessToken def _openidconnect_userinfo(request, version=Versions.NOT_AN_API_VERSION): # NOTE: The **kwargs are not used anywhere down the callchain, and are being ignored. - # 4250: Handling to ensure this only returns successfully if the flag is enabled for the application - # associated with the user making the call - if version == Versions.V3: - user = get_user_model().objects.get(username=request.resource_owner) - access_token = get_access_token_model().objects.get(user_id=user.id) - application = get_application_model().objects.get(id=access_token.application_id) - application_user = get_user_model().objects.get(id=application.user_id) - flag = get_waffle_flag_model().get('v3_early_adopter') - if flag.id is None or not flag.is_active_for_user(application_user): - return JsonResponse( - {'status_code': 403, 'message': settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name)}, - status=403, - ) return JsonResponse(_get_userinfo(request.resource_owner, version)) @@ -75,8 +60,6 @@ def openidconnect_userinfo_v2(request): def openidconnect_userinfo_v3(request): - print("openidconnect_userinfo_v3 request: ", request.__dict__) - print("openidconnect_userinfo_v3 user: ", request.user.__dict__) return _openidconnect_userinfo(request, version=Versions.V3) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 78664f6eb..5310b3eaa 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -10,11 +10,12 @@ from django.http import JsonResponse from django.http.response import HttpResponse, HttpResponseBadRequest from django.template.response import TemplateResponse -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters from apps.dot_ext.constants import TOKEN_ENDPOINT_V3_KEY +from oauthlib.oauth2.rfc6749.errors import AccessDeniedError as AccessDeniedTokenCustomError from oauth2_provider.exceptions import OAuthToolkitError from oauth2_provider.views.base import app_authorized from oauth2_provider.models import get_refresh_token_model, get_access_token_model @@ -79,50 +80,8 @@ def _wrapped(request, *args, **kwargs): return _wrapped -def check_v3_endpoint_access(view_func): - @wraps(view_func) - def _wrapped(request, *args, **kwargs): - # 4250-TODO how do we not call this so many times? - path_info = request.__dict__.get('path_info') - version = get_api_version_number_from_url(path_info) - if version != Versions.V3: - return view_func(request, *args, **kwargs) - - flag = get_waffle_flag_model().get('v3_early_adopter') - req_meta = request.META - url_query = parse_qs(req_meta.get('QUERY_STRING')) - client_id = url_query.get('client_id', [None]) - try: - if client_id[0]: - application = get_application_model().objects.get(client_id=client_id[0]) - else: - url_query = parse_qs(request._body.decode('utf-8')) - refresh_token_from_request = url_query.get('refresh_token', [None]) - refresh_token = get_refresh_token_model().objects.get(token=refresh_token_from_request[0]) - application = get_application_model().objects.get(id=refresh_token.application_id) - - application_user = get_user_model().objects.get(id=application.user_id) - - if flag.id is not None and flag.is_active_for_user(application_user): - return view_func(request, *args, **kwargs) - else: - return JsonResponse( - {'status_code': 403, 'message': settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name)}, - status=403, - ) - except ObjectDoesNotExist: - # 4250-TODO Do we need this? - return JsonResponse( - {'status_code': 500, 'message': 'Error retrieving data'}, - status=500, - ) - - return _wrapped - - @method_decorator(csrf_exempt, name="dispatch") @method_decorator(require_post_state_decorator, name="dispatch") -@method_decorator(check_v3_endpoint_access, name="dispatch") class AuthorizationView(DotAuthorizationView): """ Override the base authorization view from dot to @@ -274,6 +233,27 @@ def get(self, request, *args, **kwargs): kwargs['code_challenge_method'] = request.GET.get('code_challenge_method', None) return super().get(request, *args, **kwargs) + def validate_v3_authorization_request(self): + flag = get_waffle_flag_model().get('v3_early_adopter') + req_meta = self.request.META + url_query = parse_qs(req_meta.get('QUERY_STRING')) + client_id = url_query.get('client_id', [None]) + try: + application = get_application_model().objects.get(client_id=client_id[0]) + application_user = get_user_model().objects.get(id=application.user_id) + if flag.id is not None and flag.is_active_for_user(application_user): + return + else: + raise AccessDeniedTokenCustomError( + description=settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name) + ) + except ObjectDoesNotExist: + # 4250-TODO Do we need this? + return JsonResponse( + {'status_code': 500, 'message': 'Error retrieving data'}, + status=500, + ) + def form_valid(self, form): client_id = form.cleaned_data["client_id"] application = get_application_model().objects.get(client_id=client_id) @@ -312,6 +292,12 @@ def form_valid(self, form): refresh_token_delete_cnt = 0 try: + path_info = self.request.__dict__.get('path_info') + version = get_api_version_number_from_url(path_info) + # If it is not version 3, we don't need to check anything, just return + if version == Versions.V3: + self.validate_v3_authorization_request() + if not scopes: # Since the create_authorization_response will re-inject scopes even when none are # valid, we want to pre-emptively treat this as an error case @@ -441,7 +427,6 @@ def dispatch(self, request, uuid, *args, **kwargs): return result -# @method_decorator(check_v3_endpoint_access, name="dispatch") @method_decorator(csrf_exempt, name="dispatch") class TokenView(DotTokenView): @@ -461,13 +446,47 @@ def validate_token_endpoint_request_body(self, request): description=f"Invalid parameters in request: {invalid_parameters}" ) + def validate_v3_token_call(self, request) -> None: + flag = get_waffle_flag_model().get('v3_early_adopter') + req_meta = request.META + url_query = parse_qs(req_meta.get('QUERY_STRING')) + try: + url_query = parse_qs(request._body.decode('utf-8')) + refresh_token_from_request = url_query.get('refresh_token', [None]) + refresh_token = get_refresh_token_model().objects.get(token=refresh_token_from_request[0]) + application = get_application_model().objects.get(id=refresh_token.application_id) + application_user = get_user_model().objects.get(id=application.user_id) + + if flag.id is not None and flag.is_active_for_user(application_user): + return + else: + raise PermissionDenied( + settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name) + ) + except ObjectDoesNotExist: + # 4250-TODO Do we need this? + return JsonResponse( + {'status_code': 500, 'message': 'Error retrieving data'}, + status=500, + ) + @method_decorator(sensitive_post_parameters("password")) def post(self, request, *args, **kwargs): + path_info = self.request.__dict__.get('path_info') + version = get_api_version_number_from_url(path_info) + # If it is not version 3, we don't need to check anything, just return try: + if version == Versions.V3: + self.validate_v3_token_call(request) self.validate_token_endpoint_request_body(request) app = validate_app_is_active(request) except (InvalidClientError, InvalidGrantError, InvalidRequestError) as error: return json_response_from_oauth2_error(error) + except PermissionDenied as e: + return JsonResponse( + {'status_code': 403, 'message': str(e)}, + status=403, + ) url, headers, body, status = self.create_token_response(request) diff --git a/apps/fhir/bluebutton/permissions.py b/apps/fhir/bluebutton/permissions.py index 25413df7f..f6db654a5 100644 --- a/apps/fhir/bluebutton/permissions.py +++ b/apps/fhir/bluebutton/permissions.py @@ -113,9 +113,6 @@ def has_permission(self, request, view): class V3EarlyAdopterPermission(permissions.BasePermission): def has_permission(self, request, view): - print("IN HAS_PERMISSION OF V3EarlyAdopterPermission") - print("V3EarlyAdopterPermission request: ", request.__dict__) - print("V3EarlyAdopterPermission view: ", view.__dict__) # if it is not version 3, we do not need to check the waffle switch or flag if view.version < Versions.V3: return True diff --git a/apps/wellknown/permissions.py b/apps/wellknown/permissions.py new file mode 100644 index 000000000..e877879d4 --- /dev/null +++ b/apps/wellknown/permissions.py @@ -0,0 +1,36 @@ +import logging + +from django.conf import settings +from django.contrib.auth import get_user_model +from oauth2_provider.views.base import get_access_token_model +from oauth2_provider.models import get_application_model +from rest_framework import permissions +from rest_framework.exceptions import PermissionDenied +from waffle import get_waffle_flag_model +from apps.versions import Versions + +import apps.logging.request_logger as bb2logging + +logger = logging.getLogger(bb2logging.HHS_SERVER_LOGNAME_FMT.format(__name__)) + + +class V3EarlyAdopterWellKnownPermission(permissions.BasePermission): + # BB2-4250: Handling to ensure this only returns successfully if the flag is enabled for the application + # associated with the user making the call + def has_permission(self, request, view): + version = view.kwargs.get('version') + # if it is not version 3, we do not need to check the waffle switch or flag + if version < Versions.V3: + return True + + token = get_access_token_model().objects.get(token=request._auth) + application = get_application_model().objects.get(id=token.application_id) + application_user = get_user_model().objects.get(id=application.user_id) + flag = get_waffle_flag_model().get('v3_early_adopter') + + if flag.id is not None and flag.is_active_for_user(application_user): + return True + else: + raise PermissionDenied( + settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name) + ) From 801d5ca8cd4217a74c5b06e3751097b9724282df Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 25 Nov 2025 10:13:30 -0500 Subject: [PATCH 05/14] Fix some failing unit tests, add integration tests for 403s --- .../test_data_access_grant_permissions.py | 3 +- apps/core/models.py | 3 + apps/dot_ext/tests/test_authorization.py | 66 ++++++++++++++++++- apps/dot_ext/views/authorization.py | 3 +- .../tests/test_wellknown_endpoints.py | 39 ++++++----- .../integration_test_fhir_resources.py | 61 +++++++++++++++++ 6 files changed, 151 insertions(+), 24 deletions(-) diff --git a/apps/authorization/tests/test_data_access_grant_permissions.py b/apps/authorization/tests/test_data_access_grant_permissions.py index 7d8d38505..f26b3d5d9 100644 --- a/apps/authorization/tests/test_data_access_grant_permissions.py +++ b/apps/authorization/tests/test_data_access_grant_permissions.py @@ -13,7 +13,7 @@ from apps.authorization.models import ( DataAccessGrant, ) -from waffle.testutils import override_switch +from waffle.testutils import override_switch, override_flag from apps.fhir.bluebutton.tests.test_fhir_resources_read_search_w_validation import ( get_response_json, ) @@ -145,6 +145,7 @@ def setUp(self): self.client = Client() @override_switch('v3_endpoints', active=True) + @override_flag('v3_early_adopter', active=True) def _assert_call_all_fhir_endpoints( self, access_token=None, diff --git a/apps/core/models.py b/apps/core/models.py index f9a3e9589..17f9c8627 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -1,9 +1,12 @@ from waffle.models import AbstractUserFlag +from django.db import models class Flag(AbstractUserFlag): """ Custom version of waffle feature Flag model """ """ This makes future extensions nicer """ + # Added as part of BB2-4250 testing + objects = models.Manager() def is_active_for_user(self, user): # use app_user which is the owner of the current app diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index 932571ff6..564111b5b 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -11,7 +11,8 @@ from django.http import HttpRequest from django.urls import reverse from django.test import Client -from waffle.testutils import override_switch +from apps.core.models import Flag +from waffle.testutils import override_switch, override_flag from apps.test import BaseApiTest from ..models import Application, ArchivedToken @@ -1020,3 +1021,66 @@ def _execute_token_endpoint(self, token_path): c = Client() response = c.post(token_path, data=token_request_data) self.assertEqual(response.status_code, 200) + + @override_switch('v3_endpoints', active=True) + @override_flag('v3_early_adopter', active=True) + def test_v3_token_endpoint_with_early_adopter_flag_enabled(self): + self._execute_token_endpoint('/v3/o/token/') + + @override_switch('v3_endpoints', active=True) + # @override_flag('v3_early_adopter', active=False) + def test_v3_token_endpoint_with_early_adopter_flag_disabled(self): + self._execute_token_endpoint_for_flag_test('/v3/o/token/') + + # @override_switch('v3_endpoints', active=True) + # @override_flag('v3_early_adopter', active=True) + # def test_v3_token_endpoint_without_trailling_slash(self): + # self._execute_token_endpoint('/v3/o/token') + + def _execute_token_endpoint_for_flag_test(self, token_path): + Flag.objects.create(name='v3_early_adopter', everyone=None) + redirect_uri = 'http://localhost' + # create a user + user = self._create_user('anna', '123456') + capability_a = self._create_capability('Capability A', []) + capability_b = self._create_capability('Capability B', []) + print("USER ID: ", user.id) + # 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, + user_id=user.id) + 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, + "state": "0123456789abcdef", + 'refresh_token': 'asdfj23h4q98wuafidj' + } + 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() + print("token path: ", token_path) + response = c.post(token_path, data=token_request_data) + self.assertEqual(response.status_code, 403) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 5310b3eaa..c68c32e8e 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -448,8 +448,7 @@ def validate_token_endpoint_request_body(self, request): def validate_v3_token_call(self, request) -> None: flag = get_waffle_flag_model().get('v3_early_adopter') - req_meta = request.META - url_query = parse_qs(req_meta.get('QUERY_STRING')) + try: url_query = parse_qs(request._body.decode('utf-8')) refresh_token_from_request = url_query.get('refresh_token', [None]) diff --git a/apps/fhir/bluebutton/tests/test_wellknown_endpoints.py b/apps/fhir/bluebutton/tests/test_wellknown_endpoints.py index 5611931ef..125e4066e 100644 --- a/apps/fhir/bluebutton/tests/test_wellknown_endpoints.py +++ b/apps/fhir/bluebutton/tests/test_wellknown_endpoints.py @@ -3,10 +3,9 @@ from collections import namedtuple as NT from django.conf import settings from django.test.client import Client -from httmock import all_requests, HTTMock from oauth2_provider.models import get_access_token_model from unittest import skipIf -from waffle.testutils import override_switch +from waffle.testutils import override_switch, override_flag # Introduced in bb2-4184 # Rudimentary tests to make sure endpoints exist and are returning @@ -52,29 +51,29 @@ def setUp(self): @skipIf((not settings.RUN_ONLINE_TESTS), 'Can\'t reach external sites.') @override_switch('v3_endpoints', active=True) + def test_userinfo_returns_403(self): + first_access_token = self.create_token('John', 'Smith', fhir_id_v2=FHIR_ID_V2) + ac = AccessToken.objects.get(token=first_access_token) + ac.save() + + response = self.client.get( + f'{BASEURL}/v3/connect/userinfo', + Authorization='Bearer %s' % (first_access_token)) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()['detail'], settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format('John_Smith_test')) + + @skipIf((not settings.RUN_ONLINE_TESTS), 'Can\'t reach external sites.') + @override_switch('v3_endpoints', active=True) + @override_flag('v3_early_adopter', active=True) def test_userinfo_returns_200(self): first_access_token = self.create_token('John', 'Smith', fhir_id_v2=FHIR_ID_V2) ac = AccessToken.objects.get(token=first_access_token) ac.save() - @all_requests - def catchall(url, req): - return {'status_code': 200, - 'content': { - 'sub': FHIR_ID_V2, - 'name': ' ', - 'given_name': '', - 'family_name': '', - 'email': '', - 'iat': '2025-10-14T18:01:01.660Z', - 'patient': FHIR_ID_V2 - }} - - with HTTMock(catchall): - response = self.client.get( - f'{BASEURL}/v3/connect/userinfo', - Authorization='Bearer %s' % (first_access_token)) - self.assertEqual(response.status_code, 200) + response = self.client.get( + f'{BASEURL}/v3/connect/userinfo', + Authorization='Bearer %s' % (first_access_token)) + self.assertEqual(response.status_code, 200) # This makes sure URLs return 200s. @skipIf((not settings.RUN_ONLINE_TESTS), "Can't reach external sites.") diff --git a/apps/integration_tests/integration_test_fhir_resources.py b/apps/integration_tests/integration_test_fhir_resources.py index dc68c5833..09b2633b5 100644 --- a/apps/integration_tests/integration_test_fhir_resources.py +++ b/apps/integration_tests/integration_test_fhir_resources.py @@ -2,10 +2,12 @@ from django.conf import settings from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from http import HTTPStatus from oauth2_provider.models import AccessToken from rest_framework.test import APIClient from waffle.testutils import override_switch +from apps.core.models import Flag from apps.test import BaseApiTest from apps.testclient.utils import extract_last_page_index from .common_utils import validate_json_schema @@ -36,6 +38,9 @@ FHIR_RES_TYPE_EOB = "ExplanationOfBenefit" FHIR_RES_TYPE_PATIENT = "Patient" FHIR_RES_TYPE_COVERAGE = "Coverage" +V3_403_DETAIL = 'This application, John_Doe_test, does not yet have access to v3 endpoints. ' \ + 'If you are the app maintainer, please contact the Blue Button API team. If you are a Medicare Beneficiary ' \ + 'and need assistance, please contact the support team for the application you are trying to access.' def dump_content(json_str, file_name): @@ -759,3 +764,59 @@ def _err_response_caused_by_illegalarguments(self, v2=False): # This now returns 400 after BB2-2063 work. # for both v1 and v2 self.assertEqual(response.status_code, 400) + + @override_switch('v3_endpoints', active=True) + def test_patient_read_endpoint_v3_403(self): + ''' + test patient read and search v2 + ''' + self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_PATIENT, settings.DEFAULT_SAMPLE_FHIR_ID_V3, False, None) + + @override_switch('v3_endpoints', active=True) + def test_coverage_read_endpoint_v3_403(self): + ''' + test patient read and search v2 + ''' + self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_COVERAGE, 'part-a-99999999999999', False, None) + + @override_switch('v3_endpoints', active=True) + def test_eob_read_endpoint_v3_403(self): + ''' + test patient read and search v2 + ''' + self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_EOB, 'outpatient--9999999999999', False, None) + + @override_switch('v3_endpoints', active=True) + def test_patient_search_endpoint_v3_403(self): + ''' + test patient read and search v2 + ''' + self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_PATIENT, settings.DEFAULT_SAMPLE_FHIR_ID_V3, True, '_id=') + + @override_switch('v3_endpoints', active=True) + def test_coverage_search_endpoint_v3_403(self): + ''' + test patient read and search v2 + ''' + self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_COVERAGE, settings.DEFAULT_SAMPLE_FHIR_ID_V3, True, 'beneficiary=') + + @override_switch('v3_endpoints', active=True) + def test_eob_search_endpoint_v3_403(self): + ''' + test patient read and search v2 + ''' + self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_EOB, settings.DEFAULT_SAMPLE_FHIR_ID_V3, True, 'patient=') + + def _call_v3_endpoint_to_assert_403(self, resource_type: str, resource_value: str, search: bool, search_param: str): + client = APIClient() + + # Authenticate + self._setup_apiclient(client) + Flag.objects.create(name='v3_early_adopter', everyone=None) + if search: + endpoint_url = "{}/v3/fhir/{}/?{}{}".format(self.live_server_url, resource_type, search_param, resource_value) + else: + endpoint_url = "{}/v3/fhir/{}/{}".format(self.live_server_url, resource_type, resource_value) + response = client.get(endpoint_url) + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + self.assertEqual(response.json()['detail'], V3_403_DETAIL) From 62c3d1ce78f52187b57ee7e77999476df570d2ff Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 25 Nov 2025 13:23:24 -0500 Subject: [PATCH 06/14] Add tests around specific errors being raised for functions added to Token and Authorization views --- apps/core/models.py | 4 +- apps/dot_ext/tests/test_authorization.py | 160 ++++++++++++++--------- 2 files changed, 101 insertions(+), 63 deletions(-) diff --git a/apps/core/models.py b/apps/core/models.py index 17f9c8627..852f1f029 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -1,12 +1,12 @@ from waffle.models import AbstractUserFlag -from django.db import models +# from django.db import models class Flag(AbstractUserFlag): """ Custom version of waffle feature Flag model """ """ This makes future extensions nicer """ # Added as part of BB2-4250 testing - objects = models.Manager() + # objects = models.Manager() def is_active_for_user(self, user): # use app_user which is the owner of the current app diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index 564111b5b..54732c9f7 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -6,16 +6,19 @@ from datetime import datetime from dateutil.relativedelta import relativedelta # from oauth2_provider.compat import parse_qs, urlparse -from urllib.parse import parse_qs, urlparse +from oauthlib.oauth2.rfc6749.errors import AccessDeniedError as AccessDeniedTokenCustomError from oauth2_provider.models import get_access_token_model, get_refresh_token_model +from django.core.exceptions import PermissionDenied from django.http import HttpRequest from django.urls import reverse from django.test import Client -from apps.core.models import Flag -from waffle.testutils import override_switch, override_flag +from unittest.mock import patch, MagicMock +from urllib.parse import parse_qs, urlencode, urlparse +from waffle.testutils import override_switch from apps.test import BaseApiTest from ..models import Application, ArchivedToken +from apps.dot_ext.views import AuthorizationView, TokenView from apps.authorization.models import DataAccessGrant, ArchivedDataAccessGrant from http import HTTPStatus @@ -1022,65 +1025,100 @@ def _execute_token_endpoint(self, token_path): response = c.post(token_path, data=token_request_data) self.assertEqual(response.status_code, 200) - @override_switch('v3_endpoints', active=True) - @override_flag('v3_early_adopter', active=True) - def test_v3_token_endpoint_with_early_adopter_flag_enabled(self): - self._execute_token_endpoint('/v3/o/token/') + @patch('apps.dot_ext.views.authorization.get_user_model') + @patch('apps.dot_ext.views.authorization.get_application_model') + @patch('apps.dot_ext.views.authorization.get_waffle_flag_model') + def test_permission_denied_raised_for_authorize_app_not_in_flag( + self, + mock_get_flag_model, + mock_get_application_model, + mock_get_user_model + ): + # Unit test to show that we will raise an AccessDeniedTokenCustomError + # when the validate_v3_authorization_request of AuthorizationView function is called + # when the v3_early_adopter flag is not active for an application_user + # set up a fake request + query_params = urlencode({'client_id': 'FAKE_CLIENT_ID'}) + request = HttpRequest() + request.META['QUERY_STRING'] = query_params - @override_switch('v3_endpoints', active=True) - # @override_flag('v3_early_adopter', active=False) - def test_v3_token_endpoint_with_early_adopter_flag_disabled(self): - self._execute_token_endpoint_for_flag_test('/v3/o/token/') + # Mock the required objects/queries around flag/application/user + fake_flag = MagicMock() + fake_flag.id = 123 + fake_flag.name = 'v3_early_adopter' + fake_flag.is_active_for_user.return_value = False + mock_get_flag_model.return_value.get.return_value = fake_flag - # @override_switch('v3_endpoints', active=True) - # @override_flag('v3_early_adopter', active=True) - # def test_v3_token_endpoint_without_trailling_slash(self): - # self._execute_token_endpoint('/v3/o/token') + fake_application = MagicMock() + fake_application.id = 42 + fake_application.user_id = 999 + fake_application.name = 'TestApp' + mock_manager_app = MagicMock() + mock_manager_app.get.return_value = fake_application + mock_get_application_model.return_value.objects = mock_manager_app - def _execute_token_endpoint_for_flag_test(self, token_path): - Flag.objects.create(name='v3_early_adopter', everyone=None) - redirect_uri = 'http://localhost' - # create a user - user = self._create_user('anna', '123456') - capability_a = self._create_capability('Capability A', []) - capability_b = self._create_capability('Capability B', []) - print("USER ID: ", user.id) - # 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, - user_id=user.id) - application.scope.add(capability_a, capability_b) - # user logs in + fake_user = MagicMock() + fake_user.id = 999 + mock_manager_user = MagicMock() + mock_manager_user.get.return_value = fake_user + mock_get_user_model.return_value.objects = mock_manager_user + + # Create an instance of the view + view_instance = AuthorizationView() + view_instance.request = request + + with self.assertRaises(AccessDeniedTokenCustomError): + view_instance.validate_v3_authorization_request() + + @patch('apps.dot_ext.views.authorization.get_user_model') + @patch('apps.dot_ext.views.authorization.get_application_model') + @patch('apps.dot_ext.views.authorization.get_refresh_token_model') + @patch('apps.dot_ext.views.authorization.get_waffle_flag_model') + def test_permission_denied_raised_for_refresh_token_app_not_in_flag( + self, + mock_get_flag_model, + mock_get_refresh_token_model, + mock_get_application_model, + mock_get_user_model + ): + # Unit test to show that we will raise an PermissionDenied + # when the validate_v3_token_call of TokenView function is called + # when the v3_early_adopter flag is not active for an application_user + token_value = 'FAKE_REFRESH_TOKEN' + body = urlencode({'refresh_token': token_value}).encode('utf-8') 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, - "state": "0123456789abcdef", - 'refresh_token': 'asdfj23h4q98wuafidj' - } - 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() - print("token path: ", token_path) - response = c.post(token_path, data=token_request_data) - self.assertEqual(response.status_code, 403) + request._body = body + + # Mock the required objects/queries around flag/refresh_token/application/user + fake_flag = MagicMock() + fake_flag.id = 123 + fake_flag.name = 'v3_early_adopter' + fake_flag.is_active_for_user.return_value = False + mock_get_flag_model.return_value.get.return_value = fake_flag + + fake_refresh_token = MagicMock() + fake_refresh_token.token = token_value + fake_refresh_token.application_id = 42 + mock_manager_refresh = MagicMock() + mock_manager_refresh.get.return_value = fake_refresh_token + mock_get_refresh_token_model.return_value.objects = mock_manager_refresh + + fake_application = MagicMock() + fake_application.id = 42 + fake_application.user_id = 999 + fake_application.name = 'TestApp' + mock_manager_app = MagicMock() + mock_manager_app.get.return_value = fake_application + mock_get_application_model.return_value.objects = mock_manager_app + + fake_user = MagicMock() + fake_user.id = 999 + mock_manager_user = MagicMock() + mock_manager_user.get.return_value = fake_user + mock_get_user_model.return_value.objects = mock_manager_user + + # Create an instance of the view + view_instance = TokenView() + + with self.assertRaises(PermissionDenied): + view_instance.validate_v3_token_call(request) From 916de161b04f12beca64a730f268dee23060e0cd Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 25 Nov 2025 14:39:34 -0500 Subject: [PATCH 07/14] Cleanup: Remove/add some TODOs --- apps/core/models.py | 3 --- apps/dot_ext/tests/test_authorization.py | 4 +++- apps/fhir/bluebutton/views/generic.py | 1 - apps/fhir/bluebutton/views/read.py | 1 - .../integration_test_fhir_resources.py | 18 ++++++++++++------ 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/core/models.py b/apps/core/models.py index 852f1f029..f9a3e9589 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -1,12 +1,9 @@ from waffle.models import AbstractUserFlag -# from django.db import models class Flag(AbstractUserFlag): """ Custom version of waffle feature Flag model """ """ This makes future extensions nicer """ - # Added as part of BB2-4250 testing - # objects = models.Manager() def is_active_for_user(self, user): # use app_user which is the owner of the current app diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index 54732c9f7..7f3a8ff4f 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -1038,6 +1038,7 @@ def test_permission_denied_raised_for_authorize_app_not_in_flag( # when the validate_v3_authorization_request of AuthorizationView function is called # when the v3_early_adopter flag is not active for an application_user # set up a fake request + # TODO: When we enable v3 endpoints for all applications, remove this test query_params = urlencode({'client_id': 'FAKE_CLIENT_ID'}) request = HttpRequest() request.META['QUERY_STRING'] = query_params @@ -1081,9 +1082,10 @@ def test_permission_denied_raised_for_refresh_token_app_not_in_flag( mock_get_application_model, mock_get_user_model ): - # Unit test to show that we will raise an PermissionDenied + # BB2-4250Unit test to show that we will raise an PermissionDenied # when the validate_v3_token_call of TokenView function is called # when the v3_early_adopter flag is not active for an application_user + # TODO: When we enable v3 endpoints for all applications, remove this test token_value = 'FAKE_REFRESH_TOKEN' body = urlencode({'refresh_token': token_value}).encode('utf-8') request = HttpRequest() diff --git a/apps/fhir/bluebutton/views/generic.py b/apps/fhir/bluebutton/views/generic.py index d6831461b..6cb6e6cb0 100644 --- a/apps/fhir/bluebutton/views/generic.py +++ b/apps/fhir/bluebutton/views/generic.py @@ -96,7 +96,6 @@ def initial(self, request, resource_type, *args, **kwargs): if "HTTP_AUTHORIZATION" in req_meta: access_token = req_meta["HTTP_AUTHORIZATION"].split(" ")[1] try: - # TODO-4250 is this a place we need a flag check as well? at = AccessToken.objects.get(token=access_token) log_message = { "name": "FHIR Endpoint AT Logging", diff --git a/apps/fhir/bluebutton/views/read.py b/apps/fhir/bluebutton/views/read.py index 697e199cb..2b1f9d689 100644 --- a/apps/fhir/bluebutton/views/read.py +++ b/apps/fhir/bluebutton/views/read.py @@ -37,7 +37,6 @@ def initial(self, request, *args, **kwargs): return super().initial(request, self.resource_type, *args, **kwargs) def get(self, request, *args, **kwargs): - # 4250-TODO: Do we check for the flag here as well? Implement the same thing in search? In case of refresh token? return super().get(request, self.resource_type, *args, **kwargs) def build_parameters(self, *args, **kwargs): diff --git a/apps/integration_tests/integration_test_fhir_resources.py b/apps/integration_tests/integration_test_fhir_resources.py index 09b2633b5..38a5f1e53 100644 --- a/apps/integration_tests/integration_test_fhir_resources.py +++ b/apps/integration_tests/integration_test_fhir_resources.py @@ -768,42 +768,48 @@ def _err_response_caused_by_illegalarguments(self, v2=False): @override_switch('v3_endpoints', active=True) def test_patient_read_endpoint_v3_403(self): ''' - test patient read and search v2 + test patient read v3 throwing a 403 when an app is not in the flag + TODO - Should be removed when v3_early_adopter flag is deleted and v3 is available for all apps ''' self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_PATIENT, settings.DEFAULT_SAMPLE_FHIR_ID_V3, False, None) @override_switch('v3_endpoints', active=True) def test_coverage_read_endpoint_v3_403(self): ''' - test patient read and search v2 + test coverage read v3 throwing a 403 when an app is not in the flag + TODO - Should be removed when v3_early_adopter flag is deleted and v3 is available for all apps ''' self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_COVERAGE, 'part-a-99999999999999', False, None) @override_switch('v3_endpoints', active=True) def test_eob_read_endpoint_v3_403(self): ''' - test patient read and search v2 + test eob read v3 throwing a 403 when an app is not in the flag + TODO - Should be removed when v3_early_adopter flag is deleted and v3 is available for all apps ''' self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_EOB, 'outpatient--9999999999999', False, None) @override_switch('v3_endpoints', active=True) def test_patient_search_endpoint_v3_403(self): ''' - test patient read and search v2 + test patient search v3 throwing a 403 when an app is not in the flag + TODO - Should be removed when v3_early_adopter flag is deleted and v3 is available for all apps ''' self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_PATIENT, settings.DEFAULT_SAMPLE_FHIR_ID_V3, True, '_id=') @override_switch('v3_endpoints', active=True) def test_coverage_search_endpoint_v3_403(self): ''' - test patient read and search v2 + test coverage search v3 throwing a 403 when an app is not in the flag + TODO - Should be removed when v3_early_adopter flag is deleted and v3 is available for all apps ''' self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_COVERAGE, settings.DEFAULT_SAMPLE_FHIR_ID_V3, True, 'beneficiary=') @override_switch('v3_endpoints', active=True) def test_eob_search_endpoint_v3_403(self): ''' - test patient read and search v2 + test eob search v3 throwing a 403 when an app is not in the flag + TODO - Should be removed when v3_early_adopter flag is deleted and v3 is available for all apps ''' self._call_v3_endpoint_to_assert_403(FHIR_RES_TYPE_EOB, settings.DEFAULT_SAMPLE_FHIR_ID_V3, True, 'patient=') From 8345457380a9aab2fe6956cc1f08bcd8f9251bed Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 2 Dec 2025 08:16:41 -0500 Subject: [PATCH 08/14] Address security concern raised by Github --- apps/dot_ext/views/authorization.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index c68c32e8e..8fd5c10f6 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -481,9 +481,12 @@ def post(self, request, *args, **kwargs): app = validate_app_is_active(request) except (InvalidClientError, InvalidGrantError, InvalidRequestError) as error: return json_response_from_oauth2_error(error) - except PermissionDenied as e: + except PermissionDenied: + log.exception('Permission denied during token endpoint processing.') + # This error will not match other errors thrown by this waffle_flag as Github raised + # a security concern about it, but only here. return JsonResponse( - {'status_code': 403, 'message': str(e)}, + {'status_code': 403, 'message': 'You do not have permission to perform this action.'}, status=403, ) From 1e184af5069746ddcbe0681d03139e7287a1a098 Mon Sep 17 00:00:00 2001 From: James Demery Date: Wed, 3 Dec 2025 16:14:48 -0500 Subject: [PATCH 09/14] Clean up and address PR feedback. Modify conditionals, throw 403 earlier in auth process --- apps/dot_ext/views/authorization.py | 20 ++++++++++++------- apps/fhir/bluebutton/permissions.py | 2 +- .../tests/test_wellknown_endpoints.py | 1 + apps/wellknown/permissions.py | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 8fd5c10f6..6ee9f657d 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -91,6 +91,8 @@ class AuthorizationView(DotAuthorizationView): # TODO: rename this so that it isn't the same as self.version (works but confusing) # this needs to be here for urls.py as_view(version) calls, but don't use it version = 0 + # Variable to help reduce the amount of times validate_v3_authorization_request is called + validate_v3_call = True form_class = SimpleAllowForm login_url = "/mymedicare/login" @@ -149,6 +151,11 @@ def dispatch(self, request, *args, **kwargs): initially create an AuthFlowUuid object for authorization flow tracing in logs. """ + path_info = self.request.__dict__.get('path_info') + version = get_api_version_number_from_url(path_info) + # If it is not version 3, we don't need to check anything, just return + if version == Versions.V3 and self.validate_v3_call: + self.validate_v3_authorization_request() # TODO: Should the client_id match a valid application here before continuing, instead of after matching to FHIR_ID? if not kwargs.get('is_subclass_approvalview', False): # Create new authorization flow trace UUID in session and AuthFlowUuid instance, if subclass is not ApprovalView @@ -241,7 +248,11 @@ def validate_v3_authorization_request(self): try: application = get_application_model().objects.get(client_id=client_id[0]) application_user = get_user_model().objects.get(id=application.user_id) - if flag.id is not None and flag.is_active_for_user(application_user): + + if flag.id is None or flag.is_active_for_user(application_user): + # Update the class variable to ensure subsequent calls to dispatch don't call this function + # more times than is needed + self.validate_v3_call = False return else: raise AccessDeniedTokenCustomError( @@ -292,11 +303,6 @@ def form_valid(self, form): refresh_token_delete_cnt = 0 try: - path_info = self.request.__dict__.get('path_info') - version = get_api_version_number_from_url(path_info) - # If it is not version 3, we don't need to check anything, just return - if version == Versions.V3: - self.validate_v3_authorization_request() if not scopes: # Since the create_authorization_response will re-inject scopes even when none are @@ -456,7 +462,7 @@ def validate_v3_token_call(self, request) -> None: application = get_application_model().objects.get(id=refresh_token.application_id) application_user = get_user_model().objects.get(id=application.user_id) - if flag.id is not None and flag.is_active_for_user(application_user): + if flag.id is None or flag.is_active_for_user(application_user): return else: raise PermissionDenied( diff --git a/apps/fhir/bluebutton/permissions.py b/apps/fhir/bluebutton/permissions.py index f6db654a5..957697c7d 100644 --- a/apps/fhir/bluebutton/permissions.py +++ b/apps/fhir/bluebutton/permissions.py @@ -122,7 +122,7 @@ def has_permission(self, request, view): application_user = get_user_model().objects.get(id=application.user_id) flag = get_waffle_flag_model().get('v3_early_adopter') - if flag.id is not None and flag.is_active_for_user(application_user): + if flag.id is None or flag.is_active_for_user(application_user): return True else: raise PermissionDenied( diff --git a/apps/fhir/bluebutton/tests/test_wellknown_endpoints.py b/apps/fhir/bluebutton/tests/test_wellknown_endpoints.py index d2a927a1e..505d73277 100644 --- a/apps/fhir/bluebutton/tests/test_wellknown_endpoints.py +++ b/apps/fhir/bluebutton/tests/test_wellknown_endpoints.py @@ -51,6 +51,7 @@ def setUp(self): @skipIf((not settings.RUN_ONLINE_TESTS), 'Can\'t reach external sites.') @override_switch('v3_endpoints', active=True) + @override_flag('v3_early_adopter', active=False) def test_userinfo_returns_403(self): first_access_token = self.create_token('John', 'Smith', fhir_id_v2=FHIR_ID_V2) ac = AccessToken.objects.get(token=first_access_token) diff --git a/apps/wellknown/permissions.py b/apps/wellknown/permissions.py index e877879d4..ccd92aa5c 100644 --- a/apps/wellknown/permissions.py +++ b/apps/wellknown/permissions.py @@ -28,7 +28,7 @@ def has_permission(self, request, view): application_user = get_user_model().objects.get(id=application.user_id) flag = get_waffle_flag_model().get('v3_early_adopter') - if flag.id is not None and flag.is_active_for_user(application_user): + if flag.id is None or flag.is_active_for_user(application_user): return True else: raise PermissionDenied( From eb306cedb81f41ef1dd7e6764709b057ed0155a0 Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 4 Dec 2025 08:35:54 -0500 Subject: [PATCH 10/14] Address PR feedback: Handle ObjectNotFound same as app not in flag, remove validate_v3_call variable from AuthorizationView --- apps/dot_ext/views/authorization.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 6ee9f657d..986909a34 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -92,7 +92,6 @@ class AuthorizationView(DotAuthorizationView): # this needs to be here for urls.py as_view(version) calls, but don't use it version = 0 # Variable to help reduce the amount of times validate_v3_authorization_request is called - validate_v3_call = True form_class = SimpleAllowForm login_url = "/mymedicare/login" @@ -154,7 +153,7 @@ def dispatch(self, request, *args, **kwargs): path_info = self.request.__dict__.get('path_info') version = get_api_version_number_from_url(path_info) # If it is not version 3, we don't need to check anything, just return - if version == Versions.V3 and self.validate_v3_call: + if version == Versions.V3: self.validate_v3_authorization_request() # TODO: Should the client_id match a valid application here before continuing, instead of after matching to FHIR_ID? if not kwargs.get('is_subclass_approvalview', False): @@ -252,17 +251,14 @@ def validate_v3_authorization_request(self): if flag.id is None or flag.is_active_for_user(application_user): # Update the class variable to ensure subsequent calls to dispatch don't call this function # more times than is needed - self.validate_v3_call = False return else: raise AccessDeniedTokenCustomError( description=settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name) ) except ObjectDoesNotExist: - # 4250-TODO Do we need this? - return JsonResponse( - {'status_code': 500, 'message': 'Error retrieving data'}, - status=500, + raise AccessDeniedTokenCustomError( + description='You do not have permission to perform this action.' ) def form_valid(self, form): @@ -469,10 +465,9 @@ def validate_v3_token_call(self, request) -> None: settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name) ) except ObjectDoesNotExist: - # 4250-TODO Do we need this? return JsonResponse( - {'status_code': 500, 'message': 'Error retrieving data'}, - status=500, + {'status_code': 403, 'message': 'You do not have permission to perform this action.'}, + status=403, ) @method_decorator(sensitive_post_parameters("password")) From 73e0cb5e3656b6937adbd4ce6119eaa631a79157 Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 4 Dec 2025 10:36:54 -0500 Subject: [PATCH 11/14] Ensure a 403 is actually returned not a 500 when debug is set to false --- apps/dot_ext/views/authorization.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 986909a34..ff01c384d 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -7,7 +7,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.views import redirect_to_login -from django.http import JsonResponse +from django.http import HttpResponseForbidden, JsonResponse from django.http.response import HttpResponse, HttpResponseBadRequest from django.template.response import TemplateResponse from django.core.exceptions import ObjectDoesNotExist, PermissionDenied @@ -154,7 +154,11 @@ def dispatch(self, request, *args, **kwargs): version = get_api_version_number_from_url(path_info) # If it is not version 3, we don't need to check anything, just return if version == Versions.V3: - self.validate_v3_authorization_request() + try: + self.validate_v3_authorization_request() + except AccessDeniedTokenCustomError as e: + return HttpResponseForbidden(e) + # TODO: Should the client_id match a valid application here before continuing, instead of after matching to FHIR_ID? if not kwargs.get('is_subclass_approvalview', False): # Create new authorization flow trace UUID in session and AuthFlowUuid instance, if subclass is not ApprovalView @@ -258,7 +262,7 @@ def validate_v3_authorization_request(self): ) except ObjectDoesNotExist: raise AccessDeniedTokenCustomError( - description='You do not have permission to perform this action.' + description='Unable to verify permission.' ) def form_valid(self, form): @@ -466,7 +470,7 @@ def validate_v3_token_call(self, request) -> None: ) except ObjectDoesNotExist: return JsonResponse( - {'status_code': 403, 'message': 'You do not have permission to perform this action.'}, + {'status_code': 403, 'message': 'Unable to verify permission.'}, status=403, ) From 314ab24a5e35a829a3b39d4002aa01cb141d0f58 Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 4 Dec 2025 10:56:11 -0500 Subject: [PATCH 12/14] Standardize error responses for token/auth flows - missing token error popped up --- apps/dot_ext/views/authorization.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index ff01c384d..7fae1686d 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -7,10 +7,10 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.views import redirect_to_login -from django.http import HttpResponseForbidden, JsonResponse +from django.http import JsonResponse from django.http.response import HttpResponse, HttpResponseBadRequest from django.template.response import TemplateResponse -from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.core.exceptions import ObjectDoesNotExist from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters @@ -157,7 +157,10 @@ def dispatch(self, request, *args, **kwargs): try: self.validate_v3_authorization_request() except AccessDeniedTokenCustomError as e: - return HttpResponseForbidden(e) + return JsonResponse( + {'status_code': 403, 'message': str(e)}, + status=403, + ) # TODO: Should the client_id match a valid application here before continuing, instead of after matching to FHIR_ID? if not kwargs.get('is_subclass_approvalview', False): @@ -465,13 +468,12 @@ def validate_v3_token_call(self, request) -> None: if flag.id is None or flag.is_active_for_user(application_user): return else: - raise PermissionDenied( - settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name) + raise AccessDeniedTokenCustomError( + description=settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name) ) except ObjectDoesNotExist: - return JsonResponse( - {'status_code': 403, 'message': 'Unable to verify permission.'}, - status=403, + raise AccessDeniedTokenCustomError( + description='Unable to verify permission.' ) @method_decorator(sensitive_post_parameters("password")) @@ -486,12 +488,9 @@ def post(self, request, *args, **kwargs): app = validate_app_is_active(request) except (InvalidClientError, InvalidGrantError, InvalidRequestError) as error: return json_response_from_oauth2_error(error) - except PermissionDenied: - log.exception('Permission denied during token endpoint processing.') - # This error will not match other errors thrown by this waffle_flag as Github raised - # a security concern about it, but only here. + except AccessDeniedTokenCustomError as e: return JsonResponse( - {'status_code': 403, 'message': 'You do not have permission to perform this action.'}, + {'status_code': 403, 'message': str(e)}, status=403, ) From 8b69aeaa04749db560811a92ca87310bf9960c50 Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 4 Dec 2025 12:04:20 -0500 Subject: [PATCH 13/14] Ensure validation for v3 in tokenView only happens on refresh token --- apps/dot_ext/views/authorization.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 7fae1686d..4d0dc08d9 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -480,9 +480,12 @@ def validate_v3_token_call(self, request) -> None: def post(self, request, *args, **kwargs): path_info = self.request.__dict__.get('path_info') version = get_api_version_number_from_url(path_info) + url_query = parse_qs(request._body.decode('utf-8')) + grant_type = url_query.get('grant_type', [None]) # If it is not version 3, we don't need to check anything, just return + # We only want to execute this on refresh_token grant types, not authorization_code try: - if version == Versions.V3: + if version == Versions.V3 and grant_type[0] and grant_type[0] == 'refresh_token': self.validate_v3_token_call(request) self.validate_token_endpoint_request_body(request) app = validate_app_is_active(request) From 1268a71fa7f585c7a48116800870250a85998a73 Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 4 Dec 2025 12:15:28 -0500 Subject: [PATCH 14/14] Modify comment and fix failing unit test (new error being raised) --- apps/dot_ext/tests/test_authorization.py | 3 +-- apps/dot_ext/views/authorization.py | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index 7f3a8ff4f..7626c06c7 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -8,7 +8,6 @@ # from oauth2_provider.compat import parse_qs, urlparse from oauthlib.oauth2.rfc6749.errors import AccessDeniedError as AccessDeniedTokenCustomError from oauth2_provider.models import get_access_token_model, get_refresh_token_model -from django.core.exceptions import PermissionDenied from django.http import HttpRequest from django.urls import reverse from django.test import Client @@ -1122,5 +1121,5 @@ def test_permission_denied_raised_for_refresh_token_app_not_in_flag( # Create an instance of the view view_instance = TokenView() - with self.assertRaises(PermissionDenied): + with self.assertRaises(AccessDeniedTokenCustomError): view_instance.validate_v3_token_call(request) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 4d0dc08d9..64a1edb68 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -482,9 +482,10 @@ def post(self, request, *args, **kwargs): version = get_api_version_number_from_url(path_info) url_query = parse_qs(request._body.decode('utf-8')) grant_type = url_query.get('grant_type', [None]) - # If it is not version 3, we don't need to check anything, just return - # We only want to execute this on refresh_token grant types, not authorization_code try: + # If it is not version 3, we don't need to check that the application is in the v3_early_adopter flag, + # just continue with standard validation. + # Also, we only want to execute this on refresh_token grant types, not authorization_code if version == Versions.V3 and grant_type[0] and grant_type[0] == 'refresh_token': self.validate_v3_token_call(request) self.validate_token_endpoint_request_body(request)