Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1287195
intermediary commit
bwang-icf Nov 28, 2025
de48bf1
getting properly failing test on refreshs
bwang-icf Nov 28, 2025
29635b0
moving functionality to a base get_and_update_user call. Putting in c…
bwang-icf Nov 28, 2025
3e02801
intermediary commit
bwang-icf Dec 1, 2025
d4fb87f
unit tests currently fail but functionality for refresh works
bwang-icf Dec 5, 2025
aa3583f
Merge branch 'master' into brandon/BB2-4294-token-refresh-fhir-update
bwang-icf Dec 5, 2025
c9d8852
having crosswalk checking on POST only on refresh and logging updates
bwang-icf Dec 9, 2025
842d025
Merge remote-tracking branch 'refs/remotes/origin/brandon/BB2-4294-to…
bwang-icf Dec 9, 2025
f6cd0c4
Merge branch 'master' into brandon/BB2-4294-token-refresh-fhir-update
JamesDemeryNava Dec 9, 2025
e313983
Initial cleanup - fix a test by fixing import, modify a conditional (…
JamesDemeryNava Dec 9, 2025
0899596
Merge branch 'master' into brandon/BB2-4294-token-refresh-fhir-update
JamesDemeryNava Dec 10, 2025
a7725fa
Update comments, remove print
JamesDemeryNava Dec 10, 2025
7cb6464
Modify test_refresh_token so it demonstrates the change in functional…
JamesDemeryNava Dec 10, 2025
4ac598c
Add tests for get_and_update_from_refresh
JamesDemeryNava Dec 10, 2025
ed62d1e
Modify comment, throw 404 if crosswalk DNE during token flow
JamesDemeryNava Dec 10, 2025
306d91e
Update what function a splunk dashboard is looking for
JamesDemeryNava Dec 10, 2025
5268501
Address PR feedback
JamesDemeryNava Dec 11, 2025
cb961ce
Merge branch 'master' into brandon/BB2-4294-token-refresh-fhir-update
JamesDemeryNava Dec 11, 2025
a676f51
Remove gitignore change
JamesDemeryNava Dec 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,6 @@ docker-compose/tmp/
# dev tools dir for placing scripts you don't want checked in
dev-tools/
test_report.txt

# Snyk Security Extension - AI Rules (auto-generated)
.github/instructions/snyk_rules.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ def _assert_call_token_refresh_endpoint(
"client_id": application.client_id,
"client_secret": application.client_secret_plain,
}
response = self.client.post("/v1/o/token/", data=refresh_post_data)
response = self.client.post("/v2/o/token/", data=refresh_post_data)
content = json.loads(response.content)

self.assertEqual(response.status_code, expected_response_code)
Expand Down
44 changes: 40 additions & 4 deletions apps/dot_ext/tests/test_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
from urllib.parse import parse_qs, urlencode, urlparse
import uuid
from waffle.testutils import override_switch
from apps.fhir.bluebutton.models import Crosswalk

from apps.test import BaseApiTest
from ..models import Application, ArchivedToken
from apps.versions import Versions
from apps.dot_ext.models import Application, ArchivedToken
from apps.dot_ext.views import AuthorizationView, TokenView
from apps.authorization.models import DataAccessGrant, ArchivedDataAccessGrant
from http import HTTPStatus
Expand Down Expand Up @@ -235,10 +237,22 @@ def test_post_with_invalid_non_standard_scheme_granttype_authcode_clienttype_con
response = self.client.post(reverse('oauth2_provider:authorize'), data=payload)
self.assertEqual(response.status_code, 400)

def search_fhir_id_by_identifier_side_effect(self, search_identifier, request, version) -> str:
# Would try to retrieve these values via os envvars, but not sure what those look like in the jenkins pipeline
if version == Versions.V1:
return '-20140000008325'
elif version == Versions.V2:
return '-20140000008325'
elif version == Versions.V3:
return '-30250000008325'
return '-20140000008325'

def test_refresh_token(self):
redirect_uri = 'http://localhost'
# create a user
self._create_user('anna', '123456')
user = self._create_user('anna', '123456')
crosswalk = Crosswalk.objects.get(user=user)

capability_a = self._create_capability('Capability A', [])
capability_b = self._create_capability('Capability B', [])
# create an application and add capabilities
Expand Down Expand Up @@ -275,7 +289,7 @@ def test_refresh_token(self):
'client_secret': application.client_secret_plain,
}
c = Client()
response = c.post('/v1/o/token/', data=token_request_data)
response = c.post('/v2/o/token/', data=token_request_data)
self.assertEqual(response.status_code, 200)
# Now we have a token and refresh token
tkn = response.json()['access_token']
Expand All @@ -287,7 +301,29 @@ def test_refresh_token(self):
'client_id': application.client_id,
'client_secret': application.client_secret_plain,
}
response = self.client.post(reverse('oauth2_provider:token'), data=refresh_request_data)
body = urlencode(refresh_request_data)

# BB2-4294: Null out fhir_id_v2, then run a refresh token call, make sure fhir_id_v2 is then populated
# Update fhir_id_v3 to a random value to make sure it is updated
crosswalk.fhir_id_v2 = None
crosswalk.fhir_id_v3 = 'randomvalue'
crosswalk.save()

with patch(
'apps.fhir.server.authentication.search_fhir_id_by_identifier',
side_effect=self.search_fhir_id_by_identifier_side_effect
):
response = self.client.post(
reverse('oauth2_provider:token'),
data=body,
content_type='application/x-www-form-urlencoded'
)

# refresh crosswalk to see if it was properly updated
crosswalk.refresh_from_db()

self.assertEqual(crosswalk.fhir_id_v2, '-20140000008325')
self.assertEqual(crosswalk.fhir_id_v3, '-30250000008325')
self.assertEqual(response.status_code, 200)
self.assertNotEqual(response.json()['access_token'], tkn)

Expand Down
22 changes: 20 additions & 2 deletions apps/dot_ext/views/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,23 @@
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 apps.fhir.bluebutton.models import Crosswalk
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
from oauth2_provider.views.introspect import (
IntrospectTokenView as DotIntrospectTokenView,
)
from waffle import switch_is_active, get_waffle_flag_model
from oauth2_provider.models import get_application_model
from oauth2_provider.models import get_access_token_model, get_application_model, get_refresh_token_model
from oauthlib.oauth2 import AccessDeniedError
from oauthlib.oauth2.rfc6749.errors import InvalidClientError, InvalidGrantError, InvalidRequestError
from urllib.parse import urlparse, parse_qs
import uuid
import html
from apps.dot_ext.scopes import CapabilitiesScopes
from apps.mymedicare_cb.models import get_and_update_from_refresh
import apps.logging.request_logger as bb2logging
from apps.versions import Versions

Expand Down Expand Up @@ -543,6 +544,23 @@ def post(self, request, *args, **kwargs):
elif app.data_access_type == "RESEARCH_STUDY":
dag_expiry = ""

# Get the crosswalk for the user from token.user
# This gets us the mbi and other info we need from the crosswalk
# TODO: Should we throw an error if mbi is null and it's v3? Will we always have an mbi in that situation?
if grant_type[0] == 'refresh_token':
try:
crosswalk = Crosswalk.objects.get(user=token.user)
get_and_update_from_refresh(
crosswalk.user_mbi,
crosswalk.user.username,
crosswalk.user_hicn_hash,
request,
)
except Crosswalk.DoesNotExist:
# TODO: Should we raise an error in this case? If refresh token is happening and there is no
# corresponding crosswalk record, that's a bad data state
crosswalk = None

body['access_grant_expiration'] = dag_expiry
body = json.dumps(body)

Expand Down
2 changes: 1 addition & 1 deletion apps/fhir/bluebutton/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ class ArchivedCrosswalk(models.Model):
This model is used to keep an audit copy of a Crosswalk record's
previous values when there are changes to the original.

This is performed via code in the 'get_and_update_user()' function
This is performed via code in the '_get_and_update_user()' function
in apps/mymedicare_cb/models.py
Attributes:
user: auth_user.id
Expand Down
2 changes: 1 addition & 1 deletion apps/fhir/server/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def match_fhir_id(mbi, hicn_hash, request=None, version=Versions.NOT_AN_API_VERS

# Perform secondary lookup using HICN_HASH
# WE CANNOT DO A HICN HASH LOOKUP FOR V3, but there are tests that rely on a null MBI
# and populated hicn_hash, which now execute on v3 (due to updates in get_and_update_user)
# and populated hicn_hash, which now execute on v3 (due to updates in _get_and_update_user)
# so we need to leave this conditional as is for now, until the test is modified and/or hicn_hash is removed
# if version in [Versions.V1, Versions.V2] and hicn_hash:
if hicn_hash:
Expand Down
2 changes: 1 addition & 1 deletion apps/logging/tests/audit_logger_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ def get_pre_fetch_fhir_log_entry_schema(version):
'title': 'MyMedicareCbGetUpdateBeneLogSchema',
'type': 'object',
'properties': {
'type': {'type': 'string', 'pattern': '^mymedicare_cb:get_and_update_user$'},
'type': {'type': 'string', 'pattern': '^mymedicare_cb:get_and_update_user_(initial_auth|refresh)$'},
'status': {'type': 'string', 'pattern': '^OK$'},
'subject': {
'type': 'string',
Expand Down
134 changes: 98 additions & 36 deletions apps/mymedicare_cb/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from apps.accounts.models import UserProfile
from apps.fhir.bluebutton.models import ArchivedCrosswalk, Crosswalk
from apps.fhir.server.authentication import match_fhir_id
from rest_framework.exceptions import NotFound
from apps.dot_ext.utils import get_api_version_number_from_url

from .authorization import OAuth2ConfigSLSx, MedicareCallbackExceptionType

Expand All @@ -29,20 +31,28 @@ class BBMyMedicareCallbackCrosswalkUpdateException(APIException):
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR


def get_and_update_user(slsx_client: OAuth2ConfigSLSx, request):
def _get_and_update_user(mbi, user_id, hicn_hash, request, auth_type, slsx_client=None):
"""
Find or create the user associated
with the identity information from the ID provider.

Args:
slsx_client = OAuth2ConfigSLSx encapsulates all slsx exchanges and user info values as listed below:
mbi = corresponds to user_mbi column of the bluebutton_crosswalk table, used to populate or update that column
during auth flow if SLS has a different value than the database
user_id = corresponds to username column of the auth_user table. Used to try and retrieve the auth_user record.
If the user does not exist (first auth), a new one is created.
hicn_hash = corresponds to user_id_hash column of the bluebutton_crosswalk table. Used in v2 flows but not v3
though it can't be nulled out in bluebutton_crosswalk or an IntegrityError is raised.
request = request from caller to pass along for logging info.
auth_type = This value is either refresh or initial_auth. Used for logging
slsx_client = OAuth2ConfigSLSx encapsulates all slsx exchanges and user info values as listed below
though as part of BB2-4294, we are now passing those values as parameters to account
for refresh token flow:
subject = ID provider's sub or username
mbi = MBI from SLSx
hicn_hash = Previously hashed hicn
first_name
last_name
email
request = request from caller to pass along for logging info.
Returns:
The user that was existing or newly created
crosswalk_type = Type of crosswalk activity:
Expand All @@ -54,24 +64,28 @@ def get_and_update_user(slsx_client: OAuth2ConfigSLSx, request):
AssertionError: If a user is matched but not all identifiers match.
"""

version = request.session['version']
try:
version = request.session['version']
except KeyError:
path_info = request.__dict__.get('path_info')
version = get_api_version_number_from_url(path_info)
logger = logging.getLogger(logging.AUDIT_AUTHN_MED_CALLBACK_LOGGER, request)

# Match a patient identifier via the backend FHIR server
if version == Versions.V3:
hicn_hash = None
else:
hicn_hash = slsx_client.hicn_hash

# Always attempt to get fresh FHIR ids from the backend for supported versions
# so that FHIR ids are refreshed on every token/refresh operation. If the
# backend reports a problem (UpstreamServerException) for the requested
# version, bubble that error. If the backend simply returns no match
# (NotFound), treat that as no FHIR id available and continue.

versioned_fhir_ids = {}
# Perform fhir_id lookup for all supported versions
# If the lookup for the requested version fails, raise the exception
# This is wrapped in the case that if the requested version fails, match_fhir_id
# will still bubble up UpstreamServerException
hash_lookup_type = None
for supported_version in Versions.latest_versions():
try:
fhir_id, hash_lookup_type = match_fhir_id(
mbi=slsx_client.mbi,
mbi=mbi,
hicn_hash=hicn_hash,
request=request,
version=supported_version,
Expand All @@ -80,29 +94,32 @@ def get_and_update_user(slsx_client: OAuth2ConfigSLSx, request):
except UpstreamServerException as e:
if supported_version == version:
raise e
# otherwise continue without raising; no fhir id for this version
except NotFound:
# No matching beneficiary in backend for this identifier/version
versioned_fhir_ids[supported_version] = None

bfd_fhir_id_v2 = versioned_fhir_ids.get(Versions.V2, None)
bfd_fhir_id_v3 = versioned_fhir_ids.get(Versions.V3, None)

log_dict = {
'type': 'mymedicare_cb:get_and_update_user',
'subject': slsx_client.user_id,
'type': f'mymedicare_cb:get_and_update_user_{auth_type}',
'subject': user_id,
'fhir_id_v2': bfd_fhir_id_v2,
'fhir_id_v3': bfd_fhir_id_v3,
'hicn_hash': slsx_client.hicn_hash,
'hicn_hash': hicn_hash,
'hash_lookup_type': hash_lookup_type,
'crosswalk': {},
'crosswalk_before': {},
}

# Init for hicn crosswalk updates.
hicn_updated = False
try:
# Does an existing user and crosswalk exist for SLSx username?
user = User.objects.get(username=slsx_client.user_id)
# Does an existing user and crosswalk exist for this username?
user = User.objects.get(username=user_id)

# Did the hicn change?
if user.crosswalk.user_hicn_hash != slsx_client.hicn_hash:
if user.crosswalk.user_hicn_hash != hicn_hash:
hicn_updated = True

update_fhir_id = False
Expand All @@ -115,11 +132,13 @@ def get_and_update_user(slsx_client: OAuth2ConfigSLSx, request):
update_fhir_id = True
# Update Crosswalk if the user_mbi is null, but we have an mbi value from SLSx or
# if the saved user_mbi value is different than what SLSx has
# Possibly will need to add checking user.crosswalk.user_id_type != hash_lookup_type or hicn_updated
# again if this is not sufficient to cover all cases
if (
(user.crosswalk.user_mbi is None and slsx_client.mbi is not None)
or (user.crosswalk.user_mbi is not None and user.crosswalk.user_mbi != slsx_client.mbi)
or (user.crosswalk.user_id_type != hash_lookup_type or hicn_updated)
or update_fhir_id
update_fhir_id
or (user.crosswalk.user_mbi is None and mbi is not None)
or (user.crosswalk.user_mbi is not None and user.crosswalk.user_mbi != mbi)
or hicn_updated
):
# Log crosswalk before state
log_dict.update({
Expand All @@ -139,9 +158,17 @@ def get_and_update_user(slsx_client: OAuth2ConfigSLSx, request):
user.crosswalk.fhir_id_v2 = bfd_fhir_id_v2
user.crosswalk.fhir_id_v3 = bfd_fhir_id_v3
# Update crosswalk per changes
user.crosswalk.user_id_type = hash_lookup_type
user.crosswalk.user_hicn_hash = slsx_client.hicn_hash
user.crosswalk.user_mbi = slsx_client.mbi
# Only update user_id_type if we have a valid hash_lookup_type from FHIR match
if hash_lookup_type is not None:
user.crosswalk.user_id_type = hash_lookup_type
# Only update the HICN hash if we actually have a value.
# Some flows (e.g. v3 lookups) intentionally set hicn_hash to None
# so writing None into the non-nullable DB column would cause
# an integrity error. Only assign when non-None.
if hicn_hash is not None:
user.crosswalk.user_hicn_hash = hicn_hash
if mbi is not None:
user.crosswalk.user_mbi = mbi
user.crosswalk.save()

# Beneficiary has been successfully matched!
Expand All @@ -163,15 +190,29 @@ def get_and_update_user(slsx_client: OAuth2ConfigSLSx, request):

return user, 'R'
except User.DoesNotExist:
pass

user = create_beneficiary_record(
slsx_client,
fhir_id_v2=bfd_fhir_id_v2,
fhir_id_v3=bfd_fhir_id_v3,
user_id_type=hash_lookup_type,
request=request
)
# If we don't have an slsx_client, this is likely a refresh flow.
# Do NOT attempt to create a beneficiary record here — creation requires
# data from an SLSx client and is only valid during initial auth.
if slsx_client is None:
log_dict.update({
'status': 'FAIL',
'user_id': user_id,
'mesg': 'User not found on refresh; not creating new beneficiary record',
})
logger.info(log_dict)
return None, 'NF'

# This should only happen if no user exists which would mean this is an initial auth
# In this case, slsx_client would be provided
# Always pass the discovered fhir_id_v3 when available so the created crosswalk
# will populate `fhir_id_v3` even if the session/API version was v2.
user = create_beneficiary_record(
slsx_client,
fhir_id_v2=bfd_fhir_id_v2,
fhir_id_v3=bfd_fhir_id_v3,
user_id_type=hash_lookup_type,
request=request
)

log_dict.update({
'status': 'OK',
Expand All @@ -192,6 +233,27 @@ def get_and_update_user(slsx_client: OAuth2ConfigSLSx, request):
return user, 'C'


def get_and_update_from_refresh(mbi, user_id, hicn_hash, request):
return _get_and_update_user(
mbi,
user_id,
hicn_hash,
request,
'refresh'
)


def get_and_update_user_from_initial_auth(slsx_client: OAuth2ConfigSLSx, request):
return _get_and_update_user(
slsx_client.mbi,
slsx_client.user_id,
slsx_client.hicn_hash,
request,
'initial_auth',
slsx_client=slsx_client
)


def create_beneficiary_record(slsx_client: OAuth2ConfigSLSx,
fhir_id_v2=None, fhir_id_v3=None,
user_id_type='H', request=None) -> User:
Expand Down
Loading
Loading