Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
client
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM python:3.9.6

LABEL version="1.0"
LABEL description="Demo of a Medicare claims data sample app"

WORKDIR /

COPY . .

RUN pip install --upgrade pip
RUN pip install -r requirements/req.dev.txt
RUN pip install debugpy

EXPOSE 3001

CMD ["sh", "-c", "python -m debugpy --listen 0.0.0.0:5678 app.py"]
71 changes: 62 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,45 @@ The configuration parameters are:
- The app's callback url
- The version number of the API
- The app's environment (the BB2.0 web location where the app is registered)
- The FHIR call retry settings
- Enable / Disable Token refresh if FHIR request processing detected the access token expired

| Parameter | Value | Comments |
| ------------ | ----------------------- | ------------------------------- |
| environment | "SANDBOX" or "PRODUCTION" | BB2 API environment (default="SANDBOX")
| version | 1 or 2 | BB2 API version (default=2) |
| client_id | "foo" | oauth2 client id of the app |
| client_secret | "bar" | oauth2 client secret of the app |
| callback_url | "https://www.fake.com/callback" | oauth2 callback URL of the app |
| Parameter | Value | Comments |
| ------------- | ------------------------------- | --------------------------------------- |
| environment | "SANDBOX" or "PRODUCTION" | BB2 API environment (default="SANDBOX") |
| version | 1 or 2 | BB2 API version (default=2) |
| client_id | "foo" | oauth2 client id of the app |
| client_secret | "bar" | oauth2 client secret of the app |
| callback_url | "https://www.fake.com/callback" | oauth2 callback URL of the app |

For application registration and client id and client secret, please refer to:
[Blue Button 2.0 API Docs - Try the API](https://bluebutton.cms.gov/developers/#try-the-api)

Auth Token Refresh on Expire:

SDK FHIR requests will check if the access token is expired before the data end point call, if the access token is expired, then a token refresh is performed with the refresh token in the current auth token object, this behavior can be disabled by setting configuration parameter as below:

"token_refresh_on_expire": false

By default, token_refresh_on_expire is true.

FHIR requests retry:

Retry is enabled by default for FHIR requests, retry_settings: parameters for exponential back off retry algorithm

| retry parameter | value (default) | Comments |
| ----------------- | -------------------- | -------------------------------- |
| backoff_factor | 5 | back off factor in seconds |
| total | 3 | max retries |
| status_forcelist | [500, 502, 503, 504] | error response codes to retry on |

the exponential back off factor (in seconds) is used to calculate interval between retries by below formular, where i starts from 0:

backoff factor * (2 ** (i - 1))

e.g. for backoff_factor is 5 seconds, it will generate wait intervals: 2.5, 5, 10, ...

to disable the retry: set total = 0

There are three ways to configure the SDK when instantiating a `BlueButton` class instance:

Expand All @@ -62,6 +89,11 @@ There are three ways to configure the SDK when instantiating a `BlueButton` clas
"client_secret": "bar",
"callback_url": "https://www.fake.com/callback",
"version": 2,
"retry_settings": {
"total": 3,
"backoff_factor": 5,
"status_forcelist": [500, 502, 503, 504]
}
}
```
* JSON config file:
Expand All @@ -79,7 +111,12 @@ There are three ways to configure the SDK when instantiating a `BlueButton` clas
"client_id": "foo",
"client_secret": "bar",
"callback_url": "https://www.fake.com/callback",
"version": 2
"version": 2,
"retry_settings": {
"total": 3,
"backoff_factor": 5,
"status_forcelist": [500, 502, 503, 504]
}
}
```

Expand Down Expand Up @@ -150,7 +187,7 @@ def authorization_callback():
Check the scope
of the current access token as shown below:
"""
scopes = auth_token.scope;
scopes = auth_token.scope

# iterate scope entries here or check if a permission is in the scope
if "patient/Patient.read" in scopes:
Expand Down Expand Up @@ -185,6 +222,22 @@ def authorization_callback():
try:
eob_data = bb.get_explaination_of_benefit_data(config)
result['eob_data'] = eob_data['response'].json()
eob_data = eob_data['response'].json()
result['eob_data'] = eob_data

# fhir search response can contain large number of resources,
# e.g. it is not unusual an EOB search of a beneficiary would result
# in hundreds of EOB resources, by default they are chunked into pages
# of 10 resources each, e.g. the above call bb.get_explaination_of_benefit_data(config)
# return the 1st page of EOBs, in the format of a FHIR bundle resource
# with a link section where page navigation urls with the link name as:
# 'first', 'last', 'self', 'next', 'previous', which indicating the
# pagination relation relative to the current page.

# Use bb.get_pages(data, config) to get all the pages
eob_pages = bb.get_pages(eob_data, config)
result['eob_pages'] = eob_pages['pages']
auth_token = eob_pages['auth_token']

pt_data = bb.get_patient_data(config)
result['patient_data'] = pt_data['response'].json()
Expand Down
94 changes: 94 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from flask import redirect, request, Flask
from cms_bluebutton.cms_bluebutton import BlueButton


app = Flask(__name__)
bb = BlueButton()

auth_data = bb.generate_auth_data()

# AuthorizationToken holds access grant info:
# access token, expire in, expire at, token type, scope, refreh token, etc.
# it is associated with current logged in user in real app,
# check SDK python docs for more details.

auth_token = None


@app.route('/', methods=['GET'])
def get_auth_url():
redirect_url = bb.generate_authorize_url(auth_data)
return redirect(redirect_url, code=302)


@app.route('/api/bluebutton/callback/', methods=['GET'])
def authorization_callback():
request_query = request.args
code = request_query.get('code')
state = request_query.get('state')

auth_token = bb.get_authorization_token(auth_data, code, state)

print("============== check auth token =================")
print(auth_token.get_dict())

# pre-emptively refresh token
print("============== pre-emptively refresh auth token =================")
auth_token = bb.refresh_auth_token(auth_token)

print("============== check refreshed auth token =================")
print(auth_token.get_dict())

config = {
"auth_token": auth_token,
"params": {},
"url": "to be overriden"
}

result = {}

print("============== before data requests =================")
# fetch eob, patient, coverage, profile
try:
eob_data = bb.get_explaination_of_benefit_data(config)
print("============== EOB pass auth token =================")
auth_token = eob_data['auth_token']
print("============== after EOB request =================")
eob_data = eob_data['response'].json()
result['eob_data'] = eob_data
# fhir search response could contain large number of resources,
# by default they are chunked into pages of 10 resources each,
# the response above might be the 1st page of EOBs, it is in the
# format of a FHIR bundle resource with a link section where
# page navigation urls such as 'first', 'last', 'self', 'next', 'previous'
# might present depending on the current page.

# Use bb.get_pages(data, config) to get all the pages

print("============== get pages EOB request =================")
eob_pages = bb.get_pages(eob_data, config)
result['eob_pages'] = eob_pages['pages']
auth_token = eob_pages['auth_token']
pt_data = bb.get_patient_data(config)
print("============== Patient pass auth token =================")
auth_token = pt_data['auth_token']
print("============== after Patient request =================")
result['patient_data'] = pt_data['response'].json()
coverage_data = bb.get_coverage_data(config)
print("============== Coverage pass auth token =================")
auth_token = coverage_data['auth_token']
print("============== after Coverage request =================")
result['coverage_data'] = coverage_data['response'].json()
profile_data = bb.get_profile_data(config)
print("============== Profile pass auth token =================")
auth_token = profile_data['auth_token']
print("============== after Profile request =================")
result['profile_data'] = profile_data['response'].json()
except Exception as ex:
print(ex)

return result


if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=3001)
53 changes: 26 additions & 27 deletions cms_bluebutton/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,36 +42,26 @@ def set_dict(self, auth_token_dict):
auth_token_dict.get("expires_at")
).astimezone(datetime.timezone.utc)
else:
self.expires_at = datetime.datetime.now(datetime.timezone.utc)
+datetime.timedelta(seconds=self.expires_in)
self.expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=self.expires_in)

self.patient = auth_token_dict.get("patient")
self.refresh_token = auth_token_dict.get("refresh_token")
self.scope = auth_token_dict.get("scope")
self.token_type = auth_token_dict.get("token_type")


def refresh_auth_token(bb, auth_token):
def refresh_auth_token(bb, auth_token) -> AuthorizationToken:
data = {
"client_id": bb.client_id,
"grant_type": "refresh_token",
"refresh_token": auth_token.refresh_token,
}

headers = SDK_HEADERS

token_response = requests.post(
url=bb.auth_token_url,
data=data,
headers=headers,
auth=(bb.client_id, bb.client_secret),
)

token_response = _do_post(data, bb, (bb.client_id, bb.client_secret))
token_response.raise_for_status()
return AuthorizationToken(token_response.json())


def generate_authorize_url(bb, auth_data):
def generate_authorize_url(bb, auth_data) -> str:
params = {
"client_id": bb.client_id,
"redirect_uri": bb.callback_url,
Expand All @@ -94,31 +84,31 @@ def base64_url_encode(buffer):
return buffer_result


def get_random_string(length):
def get_random_string(length) -> str:
letters = string.ascii_letters + string.digits + string.punctuation
result = "".join(random.choice(letters) for i in range(length))
return result


def generate_pkce_data():
def generate_pkce_data() -> dict:
verifier = generate_random_state(32)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode("ASCII")).digest()
)
return {"code_challenge": code_challenge.decode("utf-8"), "verifier": verifier}


def generate_random_state(num):
def generate_random_state(num) -> str:
return base64_url_encode(get_random_string(num))


def generate_auth_data():
def generate_auth_data() -> dict:
auth_data = {"state": generate_random_state(32)}
auth_data.update(generate_pkce_data())
return auth_data


def get_access_token_from_code(bb, auth_data, callback_code):
def get_access_token_from_code(bb, auth_data, callback_code) -> dict:
data = {
"client_id": bb.client_id,
"client_secret": bb.client_secret,
Expand All @@ -129,14 +119,7 @@ def get_access_token_from_code(bb, auth_data, callback_code):
"code_challenge": auth_data["code_challenge"],
}

mp_encoder = MultipartEncoder(data)
headers = SDK_HEADERS
headers["content-type"] = mp_encoder.content_type
token_response = requests.post(
url=bb.auth_token_url,
data=mp_encoder,
headers=headers
)
token_response = _do_post(data, bb, None)
token_response.raise_for_status()
token_dict = token_response.json()
token_dict["expires_at"] = datetime.datetime.now(
Expand All @@ -157,3 +140,19 @@ def get_authorization_token(bb, auth_data, callback_code, callback_state):
raise ValueError("Provided callback state does not match.")

return AuthorizationToken(get_access_token_from_code(bb, auth_data, callback_code))


def _do_post(data, bb, auth):
mp_encoder = MultipartEncoder(data)
headers = SDK_HEADERS
headers["content-type"] = mp_encoder.content_type
return requests.post(
url=bb.auth_token_url,
data=mp_encoder,
headers=headers
) if not auth else requests.post(
url=bb.auth_token_url,
data=mp_encoder,
headers=headers,
auth=auth
)
Loading