Skip to content

Commit 977e5ec

Browse files
authored
Authenticate/Authorise on resources (#5)
* Added PyJWT * Resource API now requires authentication/authorisation * Greeting makes use of the new read fixture
1 parent 316f0f7 commit 977e5ec

7 files changed

Lines changed: 153 additions & 85 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,18 @@ Configure the following clients as needed:
228228
* Access Type: confidential (but if it wasn't Keycloak, should be public)
229229
* Flow: Device Authorization Grant
230230

231-
Keycloaks default polling interval during the device authorization flow is set to a rather long 600s. I strongly
232-
suggest to reduce that to 5s in the realm settings.
231+
Keycloak's default polling interval during the device authorization flow is set to a rather long 600s. I strongly
232+
suggest reducing that to 5s in the realm settings.
233233

234234
### Test Client
235235

236236
* Suggested client_id: mrmat-python-api-flask-test
237237
* Access Type: confidential
238238
* Flow: Client Credentials Grant (Keycloak: "Service Accounts Enabled")
239239

240+
### Scopes
241+
242+
The resource API uses two scopes that need to be defined within the IDP:
243+
244+
* mrmat-python-api-flask-resource-write - Permit create/modify/remove of resources
245+
* mrmat-python-api-flask-resource-read - Permit reading resources

mrmat_python_api_flask/apis/resource/v1/api.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
"""Blueprint for the Resource API in V1
2424
"""
2525

26+
from typing import Tuple
27+
2628
from werkzeug.local import LocalProxy
2729
from flask import Blueprint, request, g, current_app
2830
from marshmallow import ValidationError
@@ -34,59 +36,69 @@
3436
logger = LocalProxy(lambda: current_app.logger)
3537

3638

39+
def _extract_identity() -> Tuple:
40+
return g.oidc_token_info['sub'], g.oidc_token_info['preferred_username']
41+
42+
3743
@bp.route('/', methods=['GET'])
38-
@oidc.accept_token(require_token=True)
44+
@oidc.accept_token(require_token=True, scopes_required=['mrmat-python-api-flask-resource-read'])
3945
def get_all():
40-
logger.error('Error')
41-
logger.warning('Warning')
42-
logger.info('Info')
43-
logger.debug(f'Called by {g.oidc_token_info["sub"]}')
46+
identity = _extract_identity()
47+
logger.info(f'Called by {identity[1]} ({identity[0]}')
4448
a = Resource.query.all()
4549
return {'resources': resources_schema.dump(a)}, 200
4650

4751

4852
@bp.route('/<i>', methods=['GET'])
49-
@oidc.accept_token(require_token=True)
53+
@oidc.accept_token(require_token=True, scopes_required=['mrmat-python-api-flask-resource-read'])
5054
def get_one(i: int):
55+
identity = _extract_identity()
56+
logger.info(f'Called by {identity[1]} ({identity[0]}')
5157
resource = Resource.query.filter(Resource.id == i).first_or_404()
5258
if resource is None:
5359
return {'status': 404, 'message': f'Unable to find entry with identifier {i} in database'}, 404
5460
return resource_schema.dump(resource), 200
5561

5662

5763
@bp.route('/', methods=['POST'])
58-
@oidc.accept_token(require_token=True)
64+
@oidc.accept_token(require_token=True, scopes_required=['mrmat-python-api-flask-resource-write'])
5965
def create():
66+
identity = _extract_identity()
67+
logger.info(f'Called by {identity[1]} ({identity[0]}')
6068
try:
6169
json_body = request.get_json()
6270
if not json_body:
6371
return {'message': 'No input data provided'}, 400
6472
body = resource_schema.load(request.get_json())
6573
except ValidationError as ve:
6674
return ve.messages, 422
67-
resource = Resource(owner=body['owner'], name=body['name'])
75+
resource = Resource(owner=identity[0], name=body['name'])
6876
db.session.add(resource)
6977
db.session.commit()
7078
return resource_schema.dump(resource), 201
7179

7280

7381
@bp.route('/<i>', methods=['PUT'])
74-
@oidc.accept_token(require_token=True)
82+
@oidc.accept_token(require_token=True, scopes_required=['mrmat-python-api-flask-resource-write'])
7583
def modify(i: int):
84+
identity = _extract_identity()
85+
logger.info(f'Called by {identity[1]} ({identity[0]}')
7686
body = resource_schema.load(request.get_json())
7787
resource = Resource.query.filter(Resource.id == i).one()
7888
if resource is None:
7989
return {'status': 404, 'message': 'Unable to find requested resource'}, 404
80-
resource.owner = body['owner']
90+
resource.owner = identity[0]
8191
resource.name = body['name']
8292
db.session.add(resource)
8393
db.session.commit()
8494
return resource_schema.dump(resource), 200
8595

8696

8797
@bp.route('/<i>', methods=['DELETE'])
88-
@oidc.accept_token(require_token=True)
98+
@oidc.accept_token(require_token=True, scopes_required=['mrmat-python-api-flask-resource-write'])
8999
def remove(i: int):
100+
identity = _extract_identity()
101+
logger.info(f'Called by {identity[1]} ({identity[0]}')
90102
resource = Resource.query.filter(Resource.id == i).one()
91103
if resource is None:
92104
return {'status': 410, 'message': 'Unable to find requested resource'}, 410

test-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ pylint==2.8.3 # GPL-2.0-or-later
44
flake8~=3.8.4 # MIT
55
pytest~=6.2.2 # MIT
66
pytest-cov~=2.11.1 # MIT
7+
pyjwt==2.1.0 # MIT

tests/conftest.py

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
import json
2626
import pytest
2727

28+
from typing import Optional, List, Dict
29+
30+
import jwt
2831
import oauthlib.oauth2
2932
import requests_oauthlib
3033

@@ -34,7 +37,28 @@
3437

3538

3639
@pytest.fixture
37-
def test_config():
40+
def test_config() -> Optional[Dict]:
41+
"""Read the test configuration file the FLASK_CONFIG environment variable points to
42+
43+
A token can only be obtained if the configuration file pointed to by the FLASK_CONFIG environment variable
44+
contains required entries to set up OIDC for testing. An empty dict is returned if these are not present.
45+
The following are required:
46+
47+
{
48+
"web": { This entry is required to be the very first entry. If you don't like that,
49+
then externalize it into a separate file and point to it via OIDC_CLIENT_SECRETS
50+
"client_id": Server side client_id
51+
"client_secret": Server-side client_secret
52+
...
53+
},
54+
"client": {
55+
"client_id": Test client client_id
56+
"client_secret": Test client secret
57+
"preferred_name": Asserted preferred_name of the client_id
58+
"OIDC_CLIENT_SECRETS": Point this to the same place as FLASK_CONFIG (to reduce the number of config files
59+
Returns:
60+
A dictionary of configuration or None if not configuration file is set
61+
"""
3862
if 'FLASK_CONFIG' not in os.environ:
3963
LOGGER.info('Missing test configuration via FLASK_CONFIG environment variable. Tests are limited')
4064
return None
@@ -60,39 +84,57 @@ def client():
6084
yield client
6185

6286

63-
@pytest.fixture
64-
def oidc_token(test_config):
87+
def oidc_token(config: Dict, scope: Optional[List]):
6588
"""Obtain an OIDC token to be used for client testing.
66-
67-
A token can only be obtained if the configuration file pointed to by the FLASK_CONFIG environment variable
68-
contains required entries to set up OIDC for testing. An empty dict is returned if these are not present.
69-
The following are required:
70-
71-
{
72-
"web": { This entry is required to be the very first entry. If you don't like that,
73-
then externalize it into a separate file and point to it via OIDC_CLIENT_SECRETS
74-
"client_id": Server side client_id
75-
"client_secret": Server-side client_secret
76-
...
77-
},
78-
"client": {
79-
"client_id": Test client client_id
80-
"client_secret": Test client secret
81-
"preferred_name": Asserted preferred_name of the client_id
82-
"OIDC_CLIENT_SECRETS": Point this to the same place as FLASK_CONFIG (to reduce the number of config files
83-
84-
Yields:
89+
Args:
90+
config: Test configuration
91+
scope: Optional scope to request a token for. Defaults to ['openid']
92+
Returns:
8593
A dictionary containing the access token or None if configuration is lacking
8694
"""
87-
if test_config is None or 'client' not in test_config:
95+
if test_config is None or 'client' not in config:
8896
LOGGER.info('Missing OIDC test client configuration. Tests will be limited')
8997
return None
9098
for key in ['client_id', 'client_secret', 'preferred_name']:
91-
if key not in test_config['client']:
99+
if key not in config['client']:
92100
LOGGER.info(f'Missing {key} in test client configuration. Tests will be limited')
93101
return None
94-
client = oauthlib.oauth2.BackendApplicationClient(client_id=test_config['client']['client_id'])
102+
if scope is None:
103+
scope = ['openid']
104+
elif 'openid' not in scope:
105+
scope.insert(0, 'openid')
106+
client = oauthlib.oauth2.BackendApplicationClient(client_id=config['client']['client_id'], scope=scope)
95107
oauth = requests_oauthlib.OAuth2Session(client=client)
96-
return oauth.fetch_token(token_url=test_config['web']['token_uri'],
97-
client_id=test_config['client']['client_id'],
98-
client_secret=test_config['client']['client_secret'])
108+
return oauth.fetch_token(token_url=config['web']['token_uri'],
109+
client_id=config['client']['client_id'],
110+
client_secret=config['client']['client_secret'])
111+
112+
113+
@pytest.fixture
114+
def oidc_token_read(test_config) -> Dict:
115+
""" Return an OIDC token with scope 'mrmat-python-api-flask-resource-read'
116+
117+
Args:
118+
test_config: The test configuration as per the test_config fixture
119+
120+
Returns:
121+
A Dict containing the desired token structure
122+
"""
123+
token = oidc_token(test_config, ['mrmat-python-api-flask-resource-read'])
124+
token['jwt'] = jwt.decode(token['access_token'], options={"verify_signature": False})
125+
return token
126+
127+
128+
@pytest.fixture
129+
def oidc_token_write(test_config) -> Dict:
130+
""" Return an OIDC token with scope 'mrmat-python-api-flask-resource-write'
131+
132+
Args:
133+
test_config: The test configuration as per the test_config fixture
134+
135+
Returns:
136+
A Dict containing the desired token structure
137+
"""
138+
token = oidc_token(test_config, ['mrmat-python-api-flask-resource-write'])
139+
token['jwt'] = jwt.decode(token['access_token'], options={"verify_signature": False})
140+
return token

tests/resource_api_client.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,34 +27,40 @@
2727

2828
class ResourceAPIClient:
2929
client: FlaskClient
30+
token: Dict
3031

31-
def __init__(self, client: FlaskClient):
32+
_headers: Dict = {}
33+
34+
def __init__(self, client: FlaskClient, token: Optional[Dict]):
3235
self.client = client
36+
self.token = token
37+
if token is not None:
38+
self._headers = {'Authorization': f'Bearer {token["access_token"]}'}
3339

3440
def get_all(self) -> Tuple:
35-
resp: Response = self.client.get('/api/resource/v1/')
41+
resp: Response = self.client.get('/api/resource/v1/', headers=self._headers)
3642
resp_body = self._parse_body(resp)
3743
return resp, resp_body
3844

3945
def get_one(self, i: Optional[int]) -> Tuple:
40-
resp: Response = self.client.get(f'/api/resource/v1/{i}')
46+
resp: Response = self.client.get(f'/api/resource/v1/{i}', headers=self._headers)
4147
resp_body = self._parse_body(resp)
4248
return resp, resp_body
4349

44-
def create(self, name: str, owner: str) -> Tuple:
45-
req_body = {'owner': owner, 'name': name}
46-
resp: Response = self.client.post('/api/resource/v1/', json=req_body)
50+
def create(self, name: str) -> Tuple:
51+
req_body = {'name': name}
52+
resp: Response = self.client.post('/api/resource/v1/', json=req_body, headers=self._headers)
4753
resp_body = self._parse_body(resp)
4854
return resp, resp_body
4955

50-
def modify(self, i: Optional[int], name: str, owner: str) -> Tuple:
51-
req_body = {'owner': owner, 'name': name}
52-
resp: Response = self.client.put(f'/api/resource/v1/{i}', json=req_body)
56+
def modify(self, i: Optional[int], name: str) -> Tuple:
57+
req_body = {'name': name}
58+
resp: Response = self.client.put(f'/api/resource/v1/{i}', json=req_body, headers=self._headers)
5359
resp_body = self._parse_body(resp)
5460
return resp, resp_body
5561

5662
def remove(self, i: Optional[int]) -> Tuple:
57-
resp: Response = self.client.delete(f'/api/resource/v1/{i}')
63+
resp: Response = self.client.delete(f'/api/resource/v1/{i}', headers=self._headers)
5864
resp_body = self._parse_body(resp)
5965
return resp, resp_body
6066

tests/test_greeting_v3.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@
2727
from flask.testing import FlaskClient
2828

2929

30-
def test_greeting_v3(client: FlaskClient, test_config: Dict, oidc_token: Optional[Dict]):
31-
if oidc_token is None:
30+
def test_greeting_v3(client: FlaskClient, test_config: Dict, oidc_token_read: Optional[Dict]):
31+
if oidc_token_read is None:
3232
pytest.skip('Skip test because there is no OIDC client configuration')
33-
rv: Response = client.get('/api/greeting/v3/', headers={'Authorization': f'Bearer {oidc_token["access_token"]}'})
33+
rv: Response = client.get('/api/greeting/v3/',
34+
headers={'Authorization': f'Bearer {oidc_token_read["access_token"]}'})
3435
assert rv.status_code == 200
3536
json_body = rv.get_json()
3637
assert 'message' in json_body

0 commit comments

Comments
 (0)