Skip to content

Commit 891fc45

Browse files
authored
[ BB2-1118 ] BB2 SDK Python Auth Flow (#2)
* auth flow code, 1st cut * adding tests * add requirements.txt * remove __init__.py to make pytest happy. * work on tests. * revise readme adding pytest and coverage report steps. * added test coverage > 98% * flake8 linting stuff. * fix auth url generation - urlencode. * fix post url * separate direct dependencies vs all required packages. * cleanup config. * linting and pep8 compliance. * pkce enforced all time, code cleanup. * refactor error handling. * refactor per feedback. * cleanup. * remove refresh token checking, leave it to lower layer logic.
1 parent c434fb0 commit 891fc45

11 files changed

Lines changed: 461 additions & 20 deletions

File tree

.flake8

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[flake8]
2+
max-line-length = 130
3+
ignore = F403,F405,W503
4+
exclude = src/fixtures,build,dist,bb2_venv,venv

README.md

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
1-
Blue Button 2.0 SDK - Python Version
2-
====================================
1+
# Blue Button 2.0 SDK - Python Version
32

43
## Introduction
54

65
Introduction goes here!
76

8-
## Install Prerequisites:
7+
## Install Prerequisites:
8+
9+
- Install Python for your environment and verify the version with:
910

10-
* Install Python for your environment and verify the version with:
1111
```
1212
$ python --version
1313
# or
1414
$ python3 --version
1515
```
16+
1617
This should output a 3.x version number.
1718

18-
* Install up to date versions of pip, setuptools and wheel:
19+
- Install up to date versions of pip, setuptools and wheel:
1920
```
2021
$ python3 -m pip install --upgrade pip setuptools wheel
2122
```
22-
* Optionally you can use a virtual environment for the previous insall step via the following:
23+
- Optionally you can use a virtual environment for the previous insall step via the following:
2324
```
2425
$ python -m venv bb2_env
2526
$ source bb2_env/bin/activate
@@ -30,7 +31,7 @@ Introduction goes here!
3031

3132
To build the cms-bb2 package do the following:
3233

33-
* Build the package:
34+
- Build the package:
3435

3536
```
3637
# From repository root directory:
@@ -40,6 +41,7 @@ To build the cms-bb2 package do the following:
4041
## Installation
4142

4243
To install the package locally do the following:
44+
4345
```
4446
# From repository root directory:
4547
$ pip install -e .
@@ -48,17 +50,18 @@ $ pip install -e .
4850
## Usage
4951

5052
To test it out with Python interactively:
53+
5154
```
5255
$ python
5356
Python 3.10.1 ...
5457
Type "help", "copyright", "credits" or "license" for more information.
5558
>>> from bb2 import Bb2
56-
>>>
59+
>>>
5760
>>> a = Bb2()
58-
>>>
61+
>>>
5962
>>> a.hello()
6063
Hello from BB2 SDK Class method!!!
61-
>>>
64+
>>>
6265
```
6366

6467
## Developing the Blue Button 2.0 SDK (for BB2 devs)
@@ -71,12 +74,23 @@ To install with the tools you need to develop and run tests do the following:
7174
$ pip install -e .[dev]
7275
```
7376

74-
To run the tests run the following commands:
77+
To run the tests, use the following commands:
7578

7679
```
77-
# From the src/ directory
80+
# From the repo base directory
7881
$ pytest
7982
```
83+
84+
To run the tests with coverage, use the following commands:
85+
86+
```
87+
# From the repo base directory
88+
$ coverage run -m pytest
89+
90+
# Check report
91+
$ coverage report -m
92+
```
93+
8094
### Create Distribution
8195

8296
To create a distribution run the following command:

requirements/req.dev.in

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
requests==2.27.1
2+
requests-mock==1.9.3
3+
requests-toolbelt==0.9.1
4+
urllib3==1.26.8
5+
coverage==6.3.2
6+
pytest==7.0.1
7+
flake8==4.0.1

requirements/req.dev.txt

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#
2+
# This file is autogenerated by pip-compile with python 3.8
3+
# To update, run:
4+
#
5+
# pip-compile --generate-hashes --output-file=requirements/req.dev.txt requirements/req.dev.in
6+
#
7+
attrs==21.4.0 \
8+
--hash=sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4 \
9+
--hash=sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd
10+
# via pytest
11+
certifi==2021.10.8 \
12+
--hash=sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872 \
13+
--hash=sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569
14+
# via requests
15+
charset-normalizer==2.0.12 \
16+
--hash=sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597 \
17+
--hash=sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df
18+
# via requests
19+
coverage==6.3.2 \
20+
--hash=sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9 \
21+
--hash=sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d \
22+
--hash=sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf \
23+
--hash=sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7 \
24+
--hash=sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6 \
25+
--hash=sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4 \
26+
--hash=sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059 \
27+
--hash=sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39 \
28+
--hash=sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536 \
29+
--hash=sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac \
30+
--hash=sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c \
31+
--hash=sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903 \
32+
--hash=sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d \
33+
--hash=sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05 \
34+
--hash=sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684 \
35+
--hash=sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1 \
36+
--hash=sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f \
37+
--hash=sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7 \
38+
--hash=sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca \
39+
--hash=sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad \
40+
--hash=sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca \
41+
--hash=sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d \
42+
--hash=sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92 \
43+
--hash=sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4 \
44+
--hash=sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf \
45+
--hash=sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6 \
46+
--hash=sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1 \
47+
--hash=sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4 \
48+
--hash=sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359 \
49+
--hash=sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3 \
50+
--hash=sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620 \
51+
--hash=sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512 \
52+
--hash=sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69 \
53+
--hash=sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2 \
54+
--hash=sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518 \
55+
--hash=sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0 \
56+
--hash=sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa \
57+
--hash=sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4 \
58+
--hash=sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e \
59+
--hash=sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1 \
60+
--hash=sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2
61+
# via -r requirements/req.dev.in
62+
flake8==4.0.1 \
63+
--hash=sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d \
64+
--hash=sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d
65+
# via -r requirements/req.dev.in
66+
idna==3.3 \
67+
--hash=sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff \
68+
--hash=sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d
69+
# via requests
70+
iniconfig==1.1.1 \
71+
--hash=sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3 \
72+
--hash=sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32
73+
# via pytest
74+
mccabe==0.6.1 \
75+
--hash=sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42 \
76+
--hash=sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f
77+
# via flake8
78+
packaging==21.3 \
79+
--hash=sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb \
80+
--hash=sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522
81+
# via pytest
82+
pluggy==1.0.0 \
83+
--hash=sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159 \
84+
--hash=sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3
85+
# via pytest
86+
py==1.11.0 \
87+
--hash=sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719 \
88+
--hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378
89+
# via pytest
90+
pycodestyle==2.8.0 \
91+
--hash=sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20 \
92+
--hash=sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f
93+
# via flake8
94+
pyflakes==2.4.0 \
95+
--hash=sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c \
96+
--hash=sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e
97+
# via flake8
98+
pyparsing==3.0.7 \
99+
--hash=sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea \
100+
--hash=sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484
101+
# via packaging
102+
pytest==7.0.1 \
103+
--hash=sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db \
104+
--hash=sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171
105+
# via -r requirements/req.dev.in
106+
requests==2.27.1 \
107+
--hash=sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61 \
108+
--hash=sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d
109+
# via
110+
# -r requirements/req.dev.in
111+
# requests-mock
112+
# requests-toolbelt
113+
requests-mock==1.9.3 \
114+
--hash=sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970 \
115+
--hash=sha256:8d72abe54546c1fc9696fa1516672f1031d72a55a1d66c85184f972a24ba0eba
116+
# via -r requirements/req.dev.in
117+
requests-toolbelt==0.9.1 \
118+
--hash=sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f \
119+
--hash=sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0
120+
# via -r requirements/req.dev.in
121+
six==1.16.0 \
122+
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
123+
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254
124+
# via requests-mock
125+
tomli==2.0.1 \
126+
--hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \
127+
--hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f
128+
# via pytest
129+
urllib3==1.26.8 \
130+
--hash=sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed \
131+
--hash=sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c
132+
# via
133+
# -r requirements/req.dev.in
134+
# requests

src/__init__.py

Whitespace-only changes.

src/auth.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import base64
2+
import hashlib
3+
import requests
4+
import random
5+
import string
6+
import datetime
7+
import urllib
8+
9+
from requests_toolbelt.multipart.encoder import MultipartEncoder
10+
11+
from bb2 import Bb2
12+
13+
BB2_AUTH_URL = "{}/v{}/o/authorize"
14+
BB2_TOKEN_URL = "{}/v{}/o/token/"
15+
16+
17+
class AuthRequest:
18+
19+
def __init__(self, bb: Bb2):
20+
self.bb = bb
21+
self.auth_base_url = BB2_AUTH_URL.format(bb.base_url, bb.version)
22+
self.auth_token_url = BB2_TOKEN_URL.format(bb.base_url, bb.version)
23+
self.auth_data = self._generate_authdata()
24+
self.auth_url = self._generate_authorize_url()
25+
self.auth_token = None
26+
27+
def get_authorize_url(self):
28+
return self.auth_url
29+
30+
def authorize_callback(self, code, state):
31+
if code is None:
32+
raise ValueError("Authorization code missing.")
33+
34+
if state is None:
35+
raise ValueError("Callback parameter 'state' missing.")
36+
37+
if state != self.auth_data['state']:
38+
raise ValueError("Provided callback state does not match.")
39+
40+
self.auth_token = AuthorizationToken(self._get_access_token(code))
41+
42+
return self.auth_token
43+
44+
def access_token_expired(self):
45+
return self.auth_token.access_token_expired()
46+
47+
def refresh_access_token(self):
48+
49+
params = {
50+
'client_id': self.bb.client_id,
51+
'grant_type': 'refresh_token',
52+
'refresh_token': self.auth_token.refresh_token
53+
}
54+
55+
token_response = requests.post(url=self.auth_token_url,
56+
params=params,
57+
auth=(self.bb.client_id,
58+
self.bb.client_secret))
59+
60+
token_response.raise_for_status()
61+
self.auth_token = AuthorizationToken(token_response.json())
62+
63+
return self.auth_token
64+
65+
def _generate_authorize_url(self):
66+
params = {'client_id': self.bb.client_id,
67+
'redirect_uri': self.bb.client_secret,
68+
'state': self.auth_data['state'],
69+
'response_type': 'code',
70+
'code_challenge_method': 'S256',
71+
'code_challenge': self.auth_data['code_challenge']}
72+
73+
return self.auth_base_url + '?' + urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
74+
75+
def _base64_url_encode(self, buffer):
76+
buffer_bytes = base64.urlsafe_b64encode(buffer.encode("utf-8"))
77+
buffer_result = str(buffer_bytes, "utf-8")
78+
return buffer_result
79+
80+
def _get_random_string(self, length):
81+
letters = string.ascii_letters + string.digits + string.punctuation
82+
result = ''.join(random.choice(letters) for i in range(length))
83+
return result
84+
85+
def _generate_pkce_data(self):
86+
verifier = self._generate_random_state(32)
87+
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ASCII')).digest())
88+
return {'code_challenge': code_challenge.decode('utf-8'), 'verifier': verifier}
89+
90+
def _generate_random_state(self, num):
91+
return self._base64_url_encode(self._get_random_string(num))
92+
93+
def _generate_authdata(self):
94+
auth_data = {"state": self._generate_random_state(32)}
95+
auth_data.update(self._generate_pkce_data())
96+
return auth_data
97+
98+
def _get_access_token(self, code):
99+
params = {'client_id': self.bb.client_id,
100+
'client_secret': self.bb.client_secret,
101+
'code': code,
102+
'grant_type': 'authorization_code',
103+
'redirect_uri': self.bb.callback_url,
104+
'code_verifier': self.auth_data['verifier'],
105+
'code_challenge': self.auth_data['code_challenge']}
106+
107+
mp_encoder = MultipartEncoder(params)
108+
token_response = requests.post(url=self.auth_token_url, data=mp_encoder,
109+
headers={'content-type': mp_encoder.content_type})
110+
token_response.raise_for_status()
111+
token_json = token_response.json()
112+
token_json['expires_at'] = datetime.datetime.now(datetime.timezone.utc) + \
113+
datetime.timedelta(seconds=token_json['expires_in'])
114+
115+
return token_json
116+
117+
118+
class AuthorizationToken:
119+
120+
def __init__(self, auth_token):
121+
self.access_token = auth_token.get('access_token')
122+
self.expires_in = auth_token.get('expires_in')
123+
self.expires_at = auth_token.get('expires_at') if auth_token.get('expires_at') else \
124+
datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=self.expires_in)
125+
self.patient = auth_token.get('patient')
126+
self.refresh_token = auth_token.get('refresh_token')
127+
self.scope = auth_token.get('scope')
128+
self.token_type = auth_token.get('token_type')
129+
130+
def access_token_expired(self):
131+
return self.expires_at < datetime.datetime.now(datetime.timezone.utc)

0 commit comments

Comments
 (0)