diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..08ab91d --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,21 @@ +# run test suite +--- +name: Test +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout git commit + uses: actions/checkout@v1 + + - name: Set up Python 3.11 + uses: actions/setup-python@v1 + with: + python-version: 3.11 + + - name: Install test runner + run: python3 -m pip install tox + + - name: Run tests + run: tox diff --git a/Dockerfile b/Dockerfile index 28d4db1..d7ad2ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.7 +FROM python:3.11 WORKDIR /opt/app diff --git a/jwt_proxy/api.py b/jwt_proxy/api.py index 4675554..0541856 100644 --- a/jwt_proxy/api.py +++ b/jwt_proxy/api.py @@ -1,13 +1,19 @@ -from flask import Blueprint, abort, current_app, jsonify, request, json as flask_json +from flask import Blueprint, abort, current_app, jsonify, request import jwt import requests import json +from flask.json.provider import DefaultJSONProvider from jwt_proxy.audit import audit_HAPI_change blueprint = Blueprint('auth', __name__) SUPPORTED_METHODS = ('GET', 'POST', 'PUT', 'DELETE', 'OPTIONS') +# Workaround no JSON representation for datetime.timedelta +class CustomJSONProvider(DefaultJSONProvider): + def default(self, obj): + return str(obj) + def proxy_request(req, upstream_url, user_info=None): """Forward request to given url""" @@ -39,6 +45,7 @@ def proxy_request(req, upstream_url, user_info=None): return result + @blueprint.route("/", defaults={"relative_path": ""}, methods=SUPPORTED_METHODS) @blueprint.route("/", methods=SUPPORTED_METHODS) def validate_jwt(relative_path): @@ -94,14 +101,6 @@ def smart_configuration(): @blueprint.route("/settings/") def config_settings(config_key): """Non-secret application settings""" - - # workaround no JSON representation for datetime.timedelta - class CustomJSONEncoder(flask_json.JSONEncoder): - def default(self, obj): - return str(obj) - - current_app.json_encoder = CustomJSONEncoder - # return selective keys - not all can be be viewed by users, e.g.secret key blacklist = ("SECRET", "KEY") diff --git a/jwt_proxy/app.py b/jwt_proxy/app.py index 6a428c6..35151c0 100644 --- a/jwt_proxy/app.py +++ b/jwt_proxy/app.py @@ -4,11 +4,12 @@ from jwt_proxy import api from jwt_proxy.audit import audit_log_init, audit_entry - +from jwt_proxy.api import CustomJSONProvider def create_app(testing=False, cli=False): """Application factory, used to create application""" app = Flask("jwt_proxy") + app.json = CustomJSONProvider(app) register_blueprints(app) configure_app(app) @@ -22,7 +23,6 @@ def register_blueprints(app): def configure_app(app): """Load successive configs - overriding defaults""" - app.config.from_object("jwt_proxy.config") configure_logging(app) diff --git a/requirements.dev.txt b/requirements.dev.txt new file mode 100644 index 0000000..b080437 --- /dev/null +++ b/requirements.dev.txt @@ -0,0 +1,89 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --extra=dev --output-file=requirements.dev.txt setup.cfg +# +blinker==1.8.2 + # via flask +cachetools==5.5.0 + # via tox +certifi==2024.7.4 + # via requests +cffi==1.17.0 + # via cryptography +chardet==5.2.0 + # via tox +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via flask +colorama==0.4.6 + # via tox +cryptography==43.0.0 + # via pyjwt +distlib==0.3.8 + # via virtualenv +exceptiongroup==1.2.2 + # via pytest +filelock==3.15.4 + # via + # tox + # virtualenv +flask==3.0.3 + # via jwt_proxy (setup.cfg) +gunicorn==23.0.0 + # via jwt_proxy (setup.cfg) +idna==3.7 + # via requests +importlib-metadata==8.4.0 + # via flask +iniconfig==2.0.0 + # via pytest +itsdangerous==2.2.0 + # via flask +jinja2==3.1.4 + # via flask +markupsafe==2.1.5 + # via + # jinja2 + # werkzeug +packaging==24.1 + # via + # gunicorn + # pyproject-api + # pytest + # tox +platformdirs==4.2.2 + # via + # tox + # virtualenv +pluggy==1.5.0 + # via + # pytest + # tox +pycparser==2.22 + # via cffi +pyjwt[crypto]==2.9.0 + # via jwt_proxy (setup.cfg) +pyproject-api==1.7.1 + # via tox +pytest==8.3.2 + # via jwt_proxy (setup.cfg) +requests==2.32.3 + # via jwt_proxy (setup.cfg) +tomli==2.0.1 + # via + # pyproject-api + # pytest + # tox +tox==4.18.0 + # via jwt_proxy (setup.cfg) +urllib3==2.2.2 + # via requests +virtualenv==20.26.3 + # via tox +werkzeug==3.0.3 + # via flask +zipp==3.20.0 + # via importlib-metadata \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e5afb59..38f9e84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,25 +2,47 @@ # This file is autogenerated by pip-compile # To update, run: # -# pip-compile +# pip-compile --output-file=requirements.txt setup.cfg # -certifi==2021.5.30 # via requests -cffi==1.14.6 # via cryptography -charset-normalizer==2.0.4 # via requests -click==8.0.1 # via flask -cryptography==3.4.8 # via pyjwt -flask==2.0.1 # via jwt_proxy (setup.py) -gunicorn==20.1.0 # via jwt_proxy (setup.py) -idna==3.2 # via requests -importlib-metadata==4.7.1 # via click -itsdangerous==2.0.1 # via flask -jinja2==3.0.1 # via flask -markupsafe==2.0.1 # via jinja2 -pycparser==2.20 # via cffi -python-json-logger==0.1.11 # via jwt_proxy (setup.py) -pyjwt[crypto]==2.8.0 # via jwt_proxy (setup.py) -requests==2.26.0 # via jwt_proxy (setup.py) -typing-extensions==3.10.0.0 # via importlib-metadata -urllib3==1.26.6 # via requests -werkzeug==2.0.1 # via flask -zipp==3.5.0 # via importlib-metadata +blinker==1.8.2 + # via flask +certifi==2024.7.4 + # via requests +cffi==1.17.0 + # via cryptography +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via flask +cryptography==43.0.0 + # via pyjwt +flask==3.0.3 + # via jwt_proxy (setup.cfg) +gunicorn==23.0.0 + # via jwt_proxy (setup.cfg) +idna==3.7 + # via requests +importlib-metadata==8.4.0 + # via flask +itsdangerous==2.2.0 + # via flask +jinja2==3.1.4 + # via flask +markupsafe==2.1.5 + # via + # jinja2 + # werkzeug +packaging==24.1 + # via gunicorn +pycparser==2.22 + # via cffi +pyjwt[crypto]==2.9.0 + # via jwt_proxy (setup.cfg) +requests==2.32.3 + # via jwt_proxy (setup.cfg) +urllib3==2.2.2 + # via requests +werkzeug==3.0.3 + # via flask +zipp==3.20.0 + # via importlib-metadata \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index b522437..16b47ec 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,6 +15,7 @@ include_package_data = True install_requires = flask gunicorn + python-json-logger # RSA encoding and decoding require the cryptography module pyjwt[crypto] requests diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..c72ee80 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,142 @@ +import unittest +from unittest.mock import patch, MagicMock +from flask import Flask +import json +import jwt +from jwt_proxy.api import blueprint, proxy_request, CustomJSONProvider, validate_jwt + +class TestAuthBlueprint(unittest.TestCase): + def setUp(self): + """Set up a test Flask app and client""" + self.app = Flask(__name__) + self.app.config['TESTING'] = True + self.app.config['UPSTREAM_SERVER'] = 'http://example.com' + self.app.config['JWKS_URL'] = 'http://jwks.example.com' + self.app.config['PATH_WHITELIST'] = ['/whitelisted'] + self.app.config['OIDC_AUTHORIZE_URL'] = 'http://authorize.example.com' + self.app.config['OIDC_TOKEN_URI'] = 'http://token.example.com' + self.app.config['OIDC_TOKEN_INTROSPECTION_URI'] = 'http://introspection.example.com' + self.app.json = CustomJSONProvider(self.app) + self.app.register_blueprint(blueprint) + self.client = self.app.test_client() + + @patch('requests.request') + def test_proxy_request(self, mock_request): + """Test proxy_request function""" + mock_response = MagicMock() + mock_response.json.return_value = {'key': 'value'} + mock_request.return_value = mock_response + + req = MagicMock() + req.method = 'GET' + req.headers = {'Authorization': 'Bearer token'} + req.args = {'param': 'value'} + req.json = None + req.data = None + + response = proxy_request(req, 'http://example.com/api') + self.assertEqual(response, {'key': 'value'}) + + # Test JSONDecodeError handling + mock_response.json.side_effect = json.decoder.JSONDecodeError("Expecting value", "", 0) + mock_response.text = "Plain text response" + + response = proxy_request(req, 'http://example.com/api') + self.assertEqual(response, "Plain text response") + + def test_smart_configuration(self): + """Test /fhir/.well-known/smart-configuration endpoint""" + response = self.client.get('/fhir/.well-known/smart-configuration') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, { + 'authorization_endpoint': 'http://authorize.example.com', + 'token_endpoint': 'http://token.example.com', + 'introspection_endpoint': 'http://introspection.example.com' + }) + + def test_config_settings(self): + """Test /settings endpoint""" + # Test retrieving non-sensitive config + response = self.client.get('/settings') + self.assertEqual(response.status_code, 200) + self.assertIn('UPSTREAM_SERVER', response.json) + self.assertNotIn('SECRET', response.json) + + # Test retrieving specific config + response = self.client.get('/settings/UPSTREAM_SERVER') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json['UPSTREAM_SERVER'], 'http://example.com') + + # Test accessing sensitive config + response = self.client.get('/settings/SECRET_KEY') + self.assertEqual(response.status_code, 400) + +class TestValidateJWT(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.app.config["PATH_WHITELIST"] = ["/allowed_path"] + self.app.config["UPSTREAM_SERVER"] = "http://upstream-server" + self.app.config["JWKS_URL"] = "http://jwks-url" + + # Register the route using the validate_jwt function + @self.app.route("/", defaults={"relative_path": ""}, methods=["GET", "POST"]) + @self.app.route("/", methods=["GET", "POST"]) + def validate_jwt_route(relative_path): + return validate_jwt(relative_path) + + self.client = self.app.test_client() + + @patch('jwt_proxy.api.proxy_request') # Adjust the import path based on where proxy_request is defined + def test_path_whitelist(self, mock_proxy_request): + # Mock response directly without using jsonify + mock_proxy_request.return_value = {"message": "request proxied"} + + with self.app.app_context(): + response = self.client.get("/allowed_path") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, {"message": "request proxied"}) + + @patch('jwt_proxy.api.proxy_request') # Adjust the import path based on where proxy_request is defined + @patch('jwt.PyJWKClient') + @patch('jwt.decode') + def test_valid_token(self, mock_decode, mock_jwks_client, mock_proxy_request): + mock_proxy_request.return_value = {"message": "request proxied"} + mock_jwks_client.return_value.get_signing_key_from_jwt.return_value.key = "test-key" + mock_decode.return_value = {"email": "test@example.com"} + + headers = {"Authorization": "Bearer valid-token"} + + with self.app.app_context(): + response = self.client.get("/some_path", headers=headers) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, {"message": "request proxied"}) + + @patch('jwt_proxy.api.proxy_request') # Adjust the import path based on where proxy_request is defined + @patch('jwt.PyJWKClient') + @patch('jwt.decode') + def test_missing_token(self, mock_decode, mock_jwks_client, mock_proxy_request): + with self.app.app_context(): + response = self.client.get("/some_path") + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json, {"message": "token missing"}) + + @patch('jwt_proxy.api.proxy_request') # Adjust the import path based on where proxy_request is defined + @patch('jwt.PyJWKClient') + @patch('jwt.decode') + def test_expired_token(self, mock_decode, mock_jwks_client, mock_proxy_request): + mock_jwks_client.return_value.get_signing_key_from_jwt.return_value.key = "test-key" + mock_decode.side_effect = jwt.exceptions.ExpiredSignatureError("token expired") + + headers = {"Authorization": "Bearer expired-token"} + + with self.app.app_context(): + response = self.client.get("/some_path", headers=headers) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json, {"message": "token expired"}) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..ee9cf04 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,23 @@ +import unittest + +from jwt_proxy.app import create_app + +class TestConfig: + TESTING = True + + +class TestIsaccJWTProxyApp(unittest.TestCase): + def setUp(self): + self.app = create_app(testing=True) + self.app.config.from_object(TestConfig) + self.client = self.app.test_client() + + def test_app_exists(self): + self.assertIsNotNone(self.app) + + def test_blueprints_registered(self): + self.assertIn('auth', self.app.blueprints) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_jsonprovider.py b/tests/test_jsonprovider.py new file mode 100644 index 0000000..f815e76 --- /dev/null +++ b/tests/test_jsonprovider.py @@ -0,0 +1,44 @@ +import unittest +from flask import Flask, json +from datetime import datetime, timedelta +from jwt_proxy.api import CustomJSONProvider, blueprint + +class TestCustomJSONProvider(unittest.TestCase): + def setUp(self): + # Create a Flask app and set the custom JSON provider + self.app = Flask(__name__) + self.app.json = CustomJSONProvider(self.app) + self.app.config["OIDC_AUTHORIZE_URL"] = "http://authorize.example.com" + self.app.config["OIDC_TOKEN_URI"] = "http://token.example.com" + self.app.config["OIDC_TOKEN_INTROSPECTION_URI"] = "http://introspection.example.com" + self.app.register_blueprint(blueprint) + self.client = self.app.test_client() + + def test_smart_configuration(self): + """Test /fhir/.well-known/smart-configuration endpoint""" + response = self.client.get('/fhir/.well-known/smart-configuration') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, { + 'authorization_endpoint': 'http://authorize.example.com', + 'token_endpoint': 'http://token.example.com', + 'introspection_endpoint': 'http://introspection.example.com' + }) + + def test_json_encoding_date(self): + """Test that CustomJSONProvider correctly serializes a datetime object""" + test_date = datetime(2024, 8, 15, 12, 30) + with self.app.test_request_context('/'): + response = self.client.get('/settings') + json_data = json.dumps({"test_date": test_date}, default=self.app.json.default) + self.assertEqual(json_data, '{"test_date": "2024-08-15 12:30:00"}') + + def test_json_encoding_timedelta(self): + """Test that CustomJSONProvider correctly serializes a timedelta object""" + test_timedelta = timedelta(days=5, hours=10, minutes=30) + with self.app.test_request_context('/'): + response = self.client.get('/settings') + json_data = json.dumps({"test_timedelta": test_timedelta}, default=self.app.json.default) + self.assertEqual(json_data, '{"test_timedelta": "5 days, 10:30:00"}') + +if __name__ == '__main__': + unittest.main() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..46c1ac7 --- /dev/null +++ b/tox.ini @@ -0,0 +1,23 @@ +[tox] +envlist = py3 +skipdist = True + +[testenv] +description = Default testing environment, run pytest suite +deps = + --requirement=requirements.dev.txt + pytest-cov +setenv = + PYTHONPATH = {toxinidir} + TESTING = true +passenv = + FLASK_APP + LANG + PERSISTENCE_DIR + SECRET_KEY + CI +commands = + py.test \ + --cov patientsearch \ + --cov-report xml:"{toxinidir}/coverage.xml" \ + []