From 4f8d36be6f17360faa31882376f75db9cf8f1e51 Mon Sep 17 00:00:00 2001 From: Alexis Luque Date: Fri, 16 Mar 2018 18:35:25 -0300 Subject: [PATCH 1/7] Cache JWKS --- 00-Starter-Seed/requirements.txt | 4 +- 00-Starter-Seed/server.py | 161 ++++++++++++++++++++----------- 2 files changed, 108 insertions(+), 57 deletions(-) diff --git a/00-Starter-Seed/requirements.txt b/00-Starter-Seed/requirements.txt index 0f57878..e536af2 100644 --- a/00-Starter-Seed/requirements.txt +++ b/00-Starter-Seed/requirements.txt @@ -2,4 +2,6 @@ flask python-dotenv python-jose-cryptodome flask-cors -six \ No newline at end of file +six +python-memcached +flask_caching diff --git a/00-Starter-Seed/server.py b/00-Starter-Seed/server.py index 16c73a4..47a15bc 100644 --- a/00-Starter-Seed/server.py +++ b/00-Starter-Seed/server.py @@ -9,15 +9,26 @@ from dotenv import load_dotenv, find_dotenv from flask import Flask, request, jsonify, _request_ctx_stack from flask_cors import cross_origin -from jose import jwt +from jose import jwt, jws +from flask_caching import Cache ENV_FILE = find_dotenv() if ENV_FILE: load_dotenv(ENV_FILE) AUTH0_DOMAIN = env.get("AUTH0_DOMAIN") -AUTH0_AUDIENCE = env.get("AUTH0_AUDIENCE") +API_IDENTIFIER = env.get("API_IDENTIFIER") +AUTH0_ISSUER = "https://" + AUTH0_DOMAIN + "/" +AUTH0_JWKS = "https://" + AUTH0_DOMAIN + "/.well-known/jwks.json" ALGORITHMS = ["RS256"] -APP = Flask(__name__) + +app = Flask(__name__) + +config = { + 'CACHE_TYPE': 'simple', + 'CACHE_DEFAULT_TIMEOUT': 0 +} + +cache = Cache(app, config=config) # Format error response and append status code. @@ -27,13 +38,57 @@ def __init__(self, error, status_code): self.status_code = status_code -@APP.errorhandler(AuthError) +@app.errorhandler(AuthError) def handle_auth_error(ex): response = jsonify(ex.error) response.status_code = ex.status_code return response +def get_public_key(token): + """Obtain the public key from JWKS + Args: + token: Bearer token + Returns: + dict: A dictionary with JWK + """ + rsa_key = cache.get('rsa_key') + if rsa_key is not None: + return rsa_key + + jwks_url = urlopen(AUTH0_JWKS) + jwks = json.loads(jwks_url.read()) + try: + unverified_header = jwt.get_unverified_header(token) + except jwt.JWTError: + raise AuthError({"code": "invalid_header", + "description": + "Invalid header. " + "Use an RS256 signed JWT Access Token"}, 401) + if unverified_header["alg"] == "HS256": + raise AuthError({"code": "invalid_header", + "description": + "Invalid header. " + "Use an RS256 signed JWT Access Token"}, 401) + rsa_key = {} + for key in jwks["keys"]: + if key["kid"] == unverified_header["kid"]: + rsa_key = { + "kty": key["kty"], + "kid": key["kid"], + "use": key["use"], + "n": key["n"], + "e": key["e"] + } + + if rsa_key: + cache.set('rsa_key', rsa_key) + return rsa_key + else: + raise AuthError({"code": "invalid_header", + "description": "Unable to find appropriate key"}, 401) + + def get_token_auth_header(): """Obtains the access token from the Authorization Header """ @@ -78,68 +133,62 @@ def requires_scope(required_scope): return False +def decode_jwt(token, rsa_key): + try: + payload = jwt.decode( + token, + rsa_key, + algorithms=ALGORITHMS, + audience=API_IDENTIFIER, + issuer=AUTH0_ISSUER + ) + except jwt.ExpiredSignatureError: + raise AuthError({"code": "token_expired", + "description": "token is expired"}, 401) + except jwt.JWTClaimsError: + raise AuthError({"code": "invalid_claims", + "description": + "incorrect claims," + " please check the audience and issuer"}, 401) + except jwt.JWTError as ex: + raise AuthError({"code": "invalid_token", + "description": "The signature is invalid "}, 401) + except Exception: + raise AuthError({"code": "invalid_header", + "description": + "Unable to parse authentication" + " token."}, 401) + return payload + + def requires_auth(f): """Determines if the access token is valid """ @wraps(f) def decorated(*args, **kwargs): token = get_token_auth_header() - jsonurl = urlopen("https://"+AUTH0_DOMAIN+"/.well-known/jwks.json") - jwks = json.loads(jsonurl.read()) - try: - unverified_header = jwt.get_unverified_header(token) - except jwt.JWTError: - raise AuthError({"code": "invalid_header", - "description": - "Invalid header. " - "Use an RS256 signed JWT Access Token"}, 401) - if unverified_header["alg"] == "HS256": - raise AuthError({"code": "invalid_header", - "description": - "Invalid header. " - "Use an RS256 signed JWT Access Token"}, 401) - rsa_key = {} - for key in jwks["keys"]: - if key["kid"] == unverified_header["kid"]: - rsa_key = { - "kty": key["kty"], - "kid": key["kid"], - "use": key["use"], - "n": key["n"], - "e": key["e"] - } - if rsa_key: + + rsa_key = cache.get('rsa_key') + + if rsa_key is not None: try: - payload = jwt.decode( - token, - rsa_key, - algorithms=ALGORITHMS, - audience=AUTH0_AUDIENCE, - issuer="https://"+AUTH0_DOMAIN+"/" - ) - except jwt.ExpiredSignatureError: - raise AuthError({"code": "token_expired", - "description": "token is expired"}, 401) - except jwt.JWTClaimsError: - raise AuthError({"code": "invalid_claims", - "description": - "incorrect claims," - " please check the audience and issuer"}, 401) - except Exception: - raise AuthError({"code": "invalid_header", - "description": - "Unable to parse authentication" - " token."}, 401) + jws.verify(token, rsa_key, ALGORITHMS) + except jws.JWSError: + rsa_key = get_public_key(token) + payload = decode_jwt(token, rsa_key) _request_ctx_stack.top.current_user = payload - return f(*args, **kwargs) - raise AuthError({"code": "invalid_header", - "description": "Unable to find appropriate key"}, 401) + else: + rsa_key = get_public_key(token) + payload = decode_jwt(token, rsa_key) + _request_ctx_stack.top.current_user = payload + + return f(*args, **kwargs) return decorated # Controllers API -@APP.route("/api/public") +@app.route("/api/public") @cross_origin(headers=["Content-Type", "Authorization"]) def public(): """No access token required to access this route @@ -148,7 +197,7 @@ def public(): return jsonify(message=response) -@APP.route("/api/private") +@app.route("/api/private") @cross_origin(headers=["Content-Type", "Authorization"]) @cross_origin(headers=["Access-Control-Allow-Origin", "*"]) @requires_auth @@ -159,7 +208,7 @@ def private(): return jsonify(message=response) -@APP.route("/api/private-scoped") +@app.route("/api/private-scoped") @cross_origin(headers=["Content-Type", "Authorization"]) @cross_origin(headers=["Access-Control-Allow-Origin", "*"]) @requires_auth @@ -176,4 +225,4 @@ def private_scoped(): if __name__ == "__main__": - APP.run(host="0.0.0.0", port=env.get("PORT", 3010)) + app.run(host="0.0.0.0", port=env.get("PORT", 3010)) From f634355f43639d9f5b6fa34bbd2b10049606fbd8 Mon Sep 17 00:00:00 2001 From: Alexis Luque Date: Mon, 19 Mar 2018 13:31:38 -0300 Subject: [PATCH 2/7] Update get_public_key to get public key from cache or fetch from JWKS --- 00-Starter-Seed/server.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/00-Starter-Seed/server.py b/00-Starter-Seed/server.py index 47a15bc..b4cc115 100644 --- a/00-Starter-Seed/server.py +++ b/00-Starter-Seed/server.py @@ -45,15 +45,17 @@ def handle_auth_error(ex): return response -def get_public_key(token): +def get_public_key(token, get_from_cache=True): """Obtain the public key from JWKS Args: - token: Bearer token + token (str): Bearer token + get_from_cache (Boolean): If It's True get public key from cache, + otherwise fetch from JWKS Returns: dict: A dictionary with JWK """ rsa_key = cache.get('rsa_key') - if rsa_key is not None: + if rsa_key is not None and get_from_cache: return rsa_key jwks_url = urlopen(AUTH0_JWKS) @@ -150,7 +152,7 @@ def decode_jwt(token, rsa_key): "description": "incorrect claims," " please check the audience and issuer"}, 401) - except jwt.JWTError as ex: + except jwt.JWTError: raise AuthError({"code": "invalid_token", "description": "The signature is invalid "}, 401) except Exception: From f4640b3a9b9a48c2dfb494cc6bac6923e15ad3f5 Mon Sep 17 00:00:00 2001 From: Alexis Luque Date: Mon, 19 Mar 2018 14:07:16 -0300 Subject: [PATCH 3/7] Remove unused dependency --- 00-Starter-Seed/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/00-Starter-Seed/requirements.txt b/00-Starter-Seed/requirements.txt index e536af2..4c94be8 100644 --- a/00-Starter-Seed/requirements.txt +++ b/00-Starter-Seed/requirements.txt @@ -3,5 +3,4 @@ python-dotenv python-jose-cryptodome flask-cors six -python-memcached flask_caching From b78abf0a814f680fa28d36eceb5dfc8d76168b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Aguiar?= Date: Wed, 21 Mar 2018 14:54:09 -0300 Subject: [PATCH 4/7] Update server.py Added a different implementation for requires_auth --- 00-Starter-Seed/server.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/00-Starter-Seed/server.py b/00-Starter-Seed/server.py index b4cc115..de67db3 100644 --- a/00-Starter-Seed/server.py +++ b/00-Starter-Seed/server.py @@ -155,6 +155,9 @@ def decode_jwt(token, rsa_key): except jwt.JWTError: raise AuthError({"code": "invalid_token", "description": "The signature is invalid "}, 401) + except jwt.JWSError: + raise AuthError({"code": "invalid_certificate", + "description": "The certificate is invalid "}, 401) except Exception: raise AuthError({"code": "invalid_header", "description": @@ -169,21 +172,20 @@ def requires_auth(f): @wraps(f) def decorated(*args, **kwargs): token = get_token_auth_header() - - rsa_key = cache.get('rsa_key') - - if rsa_key is not None: - try: - jws.verify(token, rsa_key, ALGORITHMS) - except jws.JWSError: - rsa_key = get_public_key(token) - - payload = decode_jwt(token, rsa_key) - _request_ctx_stack.top.current_user = payload - else: - rsa_key = get_public_key(token) - payload = decode_jwt(token, rsa_key) - _request_ctx_stack.top.current_user = payload + rsa_key = get_public_key(token) + + # Try to decode with the cached JWKS key. + # If it fails, it could be because the JWKS key expired, so we retrieve it + # from the JWKS endpoint and try decoding again. + + try: + payload = decode_jwt(token, rsa_key) + except jws.AuthError as error: + if error.code == "invalid_certificate": + rsa_key = get_public_key(token, False) + payload = decode_jwt(token, rsa_key) + + _request_ctx_stack.top.current_user = payload return f(*args, **kwargs) return decorated From 29e692210b1aa17189146f8b3af1cf7572e2ddfc Mon Sep 17 00:00:00 2001 From: Alexis Luque Date: Mon, 2 Apr 2018 14:35:05 -0300 Subject: [PATCH 5/7] Update server.py --- 00-Starter-Seed/server.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/00-Starter-Seed/server.py b/00-Starter-Seed/server.py index de67db3..f4e62a5 100644 --- a/00-Starter-Seed/server.py +++ b/00-Starter-Seed/server.py @@ -9,7 +9,7 @@ from dotenv import load_dotenv, find_dotenv from flask import Flask, request, jsonify, _request_ctx_stack from flask_cors import cross_origin -from jose import jwt, jws +from jose import jwt from flask_caching import Cache ENV_FILE = find_dotenv() @@ -153,11 +153,8 @@ def decode_jwt(token, rsa_key): "incorrect claims," " please check the audience and issuer"}, 401) except jwt.JWTError: - raise AuthError({"code": "invalid_token", - "description": "The signature is invalid "}, 401) - except jwt.JWSError: - raise AuthError({"code": "invalid_certificate", - "description": "The certificate is invalid "}, 401) + raise AuthError({"code": "invalid_signature", + "description": "The signature is invalid"}, 401) except Exception: raise AuthError({"code": "invalid_header", "description": @@ -173,15 +170,15 @@ def requires_auth(f): def decorated(*args, **kwargs): token = get_token_auth_header() rsa_key = get_public_key(token) - - # Try to decode with the cached JWKS key. - # If it fails, it could be because the JWKS key expired, so we retrieve it + + # Try to decode with the cached JWKS key. + # If it fails, it could be because the JWKS key expired, so we retrieve it # from the JWKS endpoint and try decoding again. - + try: - payload = decode_jwt(token, rsa_key) - except jws.AuthError as error: - if error.code == "invalid_certificate": + payload = decode_jwt(token, rsa_key) + except AuthError as ex: + if ex.error["code"] == "invalid_signature": rsa_key = get_public_key(token, False) payload = decode_jwt(token, rsa_key) From 441bddd0817c34e6dcbed82374da40bd42978d09 Mon Sep 17 00:00:00 2001 From: Alexis Luque Date: Tue, 8 May 2018 12:22:19 -0300 Subject: [PATCH 6/7] Update server.py --- 00-Starter-Seed/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/00-Starter-Seed/server.py b/00-Starter-Seed/server.py index f4e62a5..a892245 100644 --- a/00-Starter-Seed/server.py +++ b/00-Starter-Seed/server.py @@ -175,6 +175,7 @@ def decorated(*args, **kwargs): # If it fails, it could be because the JWKS key expired, so we retrieve it # from the JWKS endpoint and try decoding again. + payload = None try: payload = decode_jwt(token, rsa_key) except AuthError as ex: From 768dd9af986279c8b6b4e840297c5a6d56b4f649 Mon Sep 17 00:00:00 2001 From: Alexis Luque Date: Tue, 8 May 2018 12:30:12 -0300 Subject: [PATCH 7/7] Add tests to test public key rotation --- 00-Starter-Seed/README.md | 11 ++- 00-Starter-Seed/tests/__init__.py | 0 00-Starter-Seed/tests/test.py | 119 ++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 00-Starter-Seed/tests/__init__.py create mode 100644 00-Starter-Seed/tests/test.py diff --git a/00-Starter-Seed/README.md b/00-Starter-Seed/README.md index bb9218b..0f7a545 100644 --- a/00-Starter-Seed/README.md +++ b/00-Starter-Seed/README.md @@ -46,4 +46,13 @@ In order to run the sample with [Docker](https://www.docker.com/) you need to ad to the `.env` filed as explained [previously](#running-the-example) and then 1. Execute in command line `sh exec.sh` to run the Docker in Linux, or `.\exec.ps1` to run the Docker in Windows. -2. Try calling [http://localhost:3010/api/public](http://localhost:3010/api/public) \ No newline at end of file +2. Try calling [http://localhost:3010/api/public](http://localhost:3010/api/public) + +# Running the tests + +In order to run the tests you need to setup the environment variables as explained [previously](#running-the-example) and then + +1. Install the needed dependencies with `pip install -r requirements.txt` +2. Run the tests with `python -m unittest tests.test` + +__note__: In earlier versions of python 3.3 `mock` isn't part of `unittest`, so you need to install it from [PyPI](https://pypi.org/project/mock/) diff --git a/00-Starter-Seed/tests/__init__.py b/00-Starter-Seed/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/00-Starter-Seed/tests/test.py b/00-Starter-Seed/tests/test.py new file mode 100644 index 0000000..fe0e38c --- /dev/null +++ b/00-Starter-Seed/tests/test.py @@ -0,0 +1,119 @@ +import json +import unittest +from unittest.mock import patch +from os import environ as env +from six.moves.urllib.request import urlopen + +from jose import jwt +from dotenv import load_dotenv, find_dotenv + +from server import app, get_public_key + +ENV_FILE = find_dotenv() +if ENV_FILE: + load_dotenv(ENV_FILE) +AUTH0_DOMAIN = env.get("AUTH0_DOMAIN") +API_IDENTIFIER = env.get("API_IDENTIFIER") +AUTH0_ISSUER = "https://" + AUTH0_DOMAIN + "/" +AUTH0_JWKS = "https://" + AUTH0_DOMAIN + "/.well-known/jwks.json" + +jwks_url = urlopen(AUTH0_JWKS) +jwks = json.loads(jwks_url.read()) +public_key = {} +for key in jwks["keys"]: + public_key = { + "kty": key["kty"], + "kid": key["kid"], + "use": key["use"], + "n": key["n"], + "e": key["e"] + } + +new_public_key = { + "kty": "RSA", + "use": "sig", + "n": "ms_PVSS-I8C4jktuFWejUG0rE5u_kYrNQTH9vaaet3zqjOy7_d3AMA2DbIenQBUy5oPwcN7ePPEcxmiKiSY0n-" + "XsvBHEWl89hto84azMI_V_55OEFEmvdMAvkgtAzpGyNd0sD1xb7dATphKZ7iMprhy3YjfGmJD2cfVyOSW71LTF" + "GN2jzSfSelqPrmKmxzGRONTHdv2zNeqvftOMIMKVqXCddxdHPHf-dPOoG2-3epexcOi34rN_FMcKVFF5vODOQG" + "INiKOFvGFBD18WK0fPT7FOoSQ4ttUfHhzjW9Sw2vZLBdk131CqH9LmvTnG3Gh6RRKBynIW0IjEVjAKzh3YNw", + "e": "AQAB", + "kid": "keyid" +} + + +def mock_get_public_key(): + return [public_key, new_public_key] + + +def mock_cache_get(): + return [None, new_public_key, new_public_key, new_public_key] + + +class JWKRotationTestCase(unittest.TestCase): + + def setUp(self): + # Setup Flask test app test client + app.testing = True + self.app = app.test_client() + self.app_context = app.app_context() + self.app_context.push() + + # Test private key for signin tokens + private_key = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAms/PVSS+I8C4jktuFWejUG0rE5u/kYrNQTH9v" \ + "aaet3zqjOy7/d3AMA2DbIenQBUy5oPwcN7ePPEcxmiKiSY0n+XsvBHEWl89hto84azMI/V/55OEFEmvdMAvkgt" \ + "AzpGyNd0sD1xb7dATphKZ7iMprhy3YjfGmJD2cfVyOSW71LTFGN2jzSfSelqPrmKmxzGRONTHdv2zNeqvftOMI" \ + "MKVqXCddxdHPHf+dPOoG2+3epexcOi34rN/FMcKVFF5vODOQGINiKOFvGFBD18WK0fPT7FOoSQ4ttUfHhzjW9S" \ + "w2vZLBdk131CqH9LmvTnG3Gh6RRKBynIW0IjEVjAKzh3YNwIDAQABAoIBADZ+YfAJn/h71TsZaCWWtpEP7HLZh" \ + "yRXJIsHatcAOKxEB1gV2NKy5PzFNPbWBVR0YddsqA1DFh2Djep1UBaY4TtLtvo4ktJw5fp7BaU2qyEZQK2man6" \ + "ttVo2cEhLN8O+22lEckbx7tYWQWRa9d4yeHB2YULseTapCGbyzAM7uhNUBkgtbMZBMbNIkRk2w8MJpJnTYJ7fK" \ + "AXm1lCCBf32M03VGHZwFQlbqWN4qcbv7/BIRELWgtlRwyGqjDMV7o2wg9KRqx5lgnJnjHDP7qORP1fL6Vc2zad" \ + "wsg5pY20jABiOr86G2dW9sjDljIhGWHuISaBqD7c8FiIfFFDgukbyRckCgYEAyRl/Ccc2umy8fkdwOTpInQPbE" \ + "pNWJnmgRPikHoATXnrLCyZwQ7pTUZmhESW3SJLvOHAqKUPA67l6rNp/OnsiIQ9yxIdSt9rl8BOCzcpDU2npxX2" \ + "qatukvjcM2ntOkpJCUE3MIEHW1DcJa+Htuni+Q3jr4yuXK+Yn0nuLiKAJ4+UCgYEAxRNWKEJEJT4Ns3TGifvG7" \ + "EoayEBDO5TaXaijXZqDJVZvyqCLuHbwQVAWECf7if2YSXkBssQjbmurjtQ0v9UgDzM/ocgUqNE5dN40bB4JUsV" \ + "lKHKhT7ku9rOlBtcnNoOTZ7wjfyR0p2q0RLzhkvGLX5JZa794yui5DRvwDl+ywesCgYEApaY63vMaQbYQDnUKH" \ + "BnGdpAWhNaYwFivjCDED9uwGMNNPYIMN73jo/PImTdYIo/mPbcnA5ar84B1bK0O4D1Nf64Z+4j8ujW18mwf8yQ" \ + "JEUzNI8DAAAWtToJKNC4eKt4PgdaTrn6NV4F+YT9Zc6DCGRIiPJ5Lh/2uD9N0vLYXb4ECgYEAol4jDvpBwNlWW" \ + "nMsnESXCNipJjFj8zPZkW6+YgFKabnEUxJg6zL7ESSVeOwoHvGTxXzv/EQC2RfWec+2QhKq3jsgAv+gndH7X6E" \ + "vWaCJl+tQQ7nl05RD8DfkEDW1dgGDseTc7gSwI7sTGMrxoqplZPFjwRU4xRxmUjmhV4Za9c8CgYBB+LOjcyJkC" \ + "lZEZQ1haPg1uCldxhB41sovuCvsPTu8SzaDL6f2xlQWXpiVLNwsaO2NDhyFg8aUDPJTOUxnxJwaJMDDgL2hhlh" \ + "RvfW+7uyxXYa8oKOlhILk4njTRbb8cYWx8x0sYqf7WrN/Q8Oko1zK1KeWkmMKkkeQhcsdUiq1kQ==\n-----EN" \ + "D RSA PRIVATE KEY-----" + + payload = { + 'key': 'value', + 'iss': AUTH0_ISSUER, + 'aud': API_IDENTIFIER + } + + self.token = jwt.encode(payload, private_key, algorithm='RS256') + headers = {'kid': public_key.get('kid')} + self.token1 = jwt.encode(payload, private_key, algorithm='RS256', headers=headers) + + def tearDown(self): + self.app_context.pop() + + @patch('server.get_public_key', side_effect=mock_get_public_key) + def test_private_endpoint(self, mock_public_key): + mock_public_key.side_effect = mock_get_public_key() + + rv = self.app.get('/api/private', headers={'Authorization': 'Bearer ' + self.token}) + self.assertEqual(rv.status_code, 200) + self.assertIn(b'Hello from a private endpoint! You need to be authenticated to see this.', rv.data) + + @patch('server.Cache.get', side_effect=mock_cache_get) + def test_get_public_key(self, mock_cache): + mock_cache.side_effect = mock_cache_get() + + pk = get_public_key(self.token1) + self.assertEqual(pk, public_key) + + pk = get_public_key(self.token) + self.assertEqual(pk, new_public_key) + + pk = get_public_key(self.token1, False) + self.assertEqual(pk, public_key) + + +if __name__ == '__main__': + unittest.main()