├── tests ├── __init__.py ├── httpx_mauth │ ├── __init__.py │ └── client_test.py ├── middlewares │ ├── __init__.py │ ├── wsgi_test.py │ └── asgi_test.py ├── requests_mauth │ ├── __init__.py │ └── client_test.py ├── lambda_authenticator │ ├── __init__.py │ └── lambda_authenticator_test.py ├── blank.jpeg ├── keys │ ├── fake_mauth.rsapub.key │ ├── fake_mauth.pub.key │ └── fake_mauth.priv.key ├── common.py ├── utils_test.py ├── protocol_test_suite_test.py ├── signed_test.py ├── key_holder_test.py ├── protocol_test_suite_helper.py ├── signable_test.py ├── signer_test.py └── authenticator_test.py ├── mauth_client ├── httpx_mauth │ ├── __init__.py │ └── client.py ├── requests_mauth │ ├── __init__.py │ └── client.py ├── lambda_authenticator │ ├── __init__.py │ └── lambda_authenticator.py ├── middlewares │ ├── __init__.py │ ├── asgi.py │ └── wsgi.py ├── __init__.py ├── consts.py ├── config.py ├── exceptions.py ├── lambda_helper.py ├── utils.py ├── signed.py ├── key_holder.py ├── rsa_verifier.py ├── rsa_signer.py ├── signer.py ├── signable.py └── authenticator.py ├── docs ├── _static │ └── mdsol.png ├── modules.rst ├── mauth_client.requests_mauth.rst ├── faq.rst ├── mauth_client.flask_authenticator.rst ├── mauth_client.lambda_authenticator.rst ├── Makefile ├── mauth_setup.rst ├── index.rst ├── mauth_client.rst ├── examples.rst ├── getting_started.rst └── conf.py ├── .coveragerc ├── .gitmodules ├── .flake8 ├── .github └── workflows │ ├── release.yaml │ └── check.yaml ├── tox.ini ├── LICENSE.txt ├── .gitignore ├── pyproject.toml ├── CONTRIBUTING.md ├── CHANGELOG.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/httpx_mauth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/requests_mauth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/lambda_authenticator/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mauth_client/httpx_mauth/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import MAuthHttpx 2 | -------------------------------------------------------------------------------- /mauth_client/requests_mauth/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import MAuth 2 | -------------------------------------------------------------------------------- /tests/blank.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdsol/mauth-client-python/main/tests/blank.jpeg -------------------------------------------------------------------------------- /mauth_client/lambda_authenticator/__init__.py: -------------------------------------------------------------------------------- 1 | from .lambda_authenticator import LambdaAuthenticator 2 | -------------------------------------------------------------------------------- /docs/_static/mdsol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdsol/mauth-client-python/main/docs/_static/mdsol.png -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | mauth_client 2 | ============== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | mauth_client 8 | -------------------------------------------------------------------------------- /mauth_client/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .asgi import MAuthASGIMiddleware 2 | from .wsgi import MAuthWSGIMiddleware 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=mauth_client 3 | 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | @abstract 8 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/mauth-protocol-test-suite"] 2 | path = tests/mauth-protocol-test-suite 3 | url = https://github.com/mdsol/mauth-protocol-test-suite.git 4 | -------------------------------------------------------------------------------- /mauth_client/__init__.py: -------------------------------------------------------------------------------- 1 | # Load the version from the project metatdata 2 | import importlib.metadata as importlib_metadata 3 | 4 | __version__ = importlib_metadata.version(__name__) 5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | ignore = E203, W503 4 | per-file-ignores = __init__.py:F401 5 | exclude = 6 | .git 7 | __pycache__ 8 | setup.py 9 | build 10 | dist 11 | releases 12 | .venv 13 | .tox 14 | .pytest_cache 15 | .eggs 16 | -------------------------------------------------------------------------------- /tests/keys/fake_mauth.rsapub.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PUBLIC KEY----- 2 | MIIBCgKCAQEA5yobZeE0fkWHpqIFQT6ipRaureyvgCWHcJ4kynU2qGxjzr63fgwW 3 | 0syEGrcGAhNKcJ6QEkdylqTytyggVFeBR2KO1aVJUYvQB+tAyqPw/kHJU76g+zx5 4 | fKaIUWbaAY8vCT1hy4PIvnaI+hhiRUV+lNffL7an3Av7MY5gvwSTlrk3tCKVUrHa 5 | iGT3fLOGH2ZJBGn724EiddGtO4eSViYUtqgtcs8UMoiprRRZ53300SIYgsvcU1+l 6 | S33QvgTpWKrL3YiY+VCnzxgj2fuMt+UWrFu23xhe6eoByIRS1f2kOemrodymL/N9 7 | GgpEwUJCGyDCHP+vhygbS1kILBBvlrWUsQIDAQAB 8 | -----END RSA PUBLIC KEY----- 9 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def load_key(keytype="pub"): 5 | """ 6 | Load the sample keys 7 | :param keytype: type of key to load 8 | :return: key content 9 | :rtype: str 10 | """ 11 | assert keytype in ("pub", "rsapub", "priv") 12 | content = "" 13 | with open( 14 | os.path.join(os.path.dirname(__file__), "keys", "fake_mauth.{}.key".format(keytype)), "r" 15 | ) as key: 16 | content = key.read() 17 | return content 18 | -------------------------------------------------------------------------------- /tests/keys/fake_mauth.pub.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5yobZeE0fkWHpqIFQT6i 3 | pRaureyvgCWHcJ4kynU2qGxjzr63fgwW0syEGrcGAhNKcJ6QEkdylqTytyggVFeB 4 | R2KO1aVJUYvQB+tAyqPw/kHJU76g+zx5fKaIUWbaAY8vCT1hy4PIvnaI+hhiRUV+ 5 | lNffL7an3Av7MY5gvwSTlrk3tCKVUrHaiGT3fLOGH2ZJBGn724EiddGtO4eSViYU 6 | tqgtcs8UMoiprRRZ53300SIYgsvcU1+lS33QvgTpWKrL3YiY+VCnzxgj2fuMt+UW 7 | rFu23xhe6eoByIRS1f2kOemrodymL/N9GgpEwUJCGyDCHP+vhygbS1kILBBvlrWU 8 | sQIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /docs/mauth_client.requests_mauth.rst: -------------------------------------------------------------------------------- 1 | mauth\_client.requests\_mauth package 2 | ===================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | mauth\_client.requests\_mauth.client module 8 | ------------------------------------------- 9 | 10 | .. automodule:: mauth_client.requests_mauth.client 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | 16 | Module contents 17 | --------------- 18 | 19 | .. automodule:: mauth_client.requests_mauth 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | -------------------------------------------------------------------------------- /mauth_client/consts.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | AUTH_HEADER_DELIMITER = ";" 4 | 5 | MWS_TOKEN = "MWS" 6 | X_MWS_AUTH = "X-MWS-Authentication" 7 | X_MWS_TIME = "X-MWS-Time" 8 | X_MWS_AUTH_PATTERN = re.compile(r"\A([^ ]+) *([^:]+):([^:]+)\Z") 9 | 10 | MWSV2_TOKEN = "MWSV2" 11 | MCC_AUTH = "MCC-Authentication" 12 | MCC_TIME = "MCC-Time" 13 | MWSV2_AUTH_PATTERN = re.compile(r"({}) ([^:]+):([^;]+){}".format(MWSV2_TOKEN, AUTH_HEADER_DELIMITER)) 14 | 15 | ENV_APP_UUID = "mauth.app_uuid" 16 | ENV_AUTHENTIC = "mauth.authentic" 17 | ENV_PROTOCOL_VERSION = "mauth.protocol_version" 18 | -------------------------------------------------------------------------------- /docs/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently Asked Questions 2 | ========================== 3 | 4 | What are the licensing terms for mauth-client? 5 | ------------------------------------------------ 6 | The framework is licensed under the MIT licensing terms. 7 | 8 | What versions of Python are supported? 9 | -------------------------------------- 10 | Each release of requests-mauth is tested against: 11 | * Python 3.8 12 | * Python 3.9 13 | * Python 3.10 14 | * Python 3.11 15 | * PyPy3 16 | 17 | The values tested can be seen in the `tox.ini` file. We use tox to test the code across Python versions, see the README.md for details. 18 | -------------------------------------------------------------------------------- /docs/mauth_client.flask_authenticator.rst: -------------------------------------------------------------------------------- 1 | mauth\_client.flask\_authenticator package 2 | =========================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | mauth\_client.flask\_authenticator.flask\_authenticator module 8 | ---------------------------------------------------------------- 9 | 10 | .. automodule:: mauth_client.flask_authenticator.flask_authenticator 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | 16 | Module contents 17 | --------------- 18 | 19 | .. automodule:: mauth_client.flask_authenticator 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | -------------------------------------------------------------------------------- /docs/mauth_client.lambda_authenticator.rst: -------------------------------------------------------------------------------- 1 | mauth\_client.lambda\_authenticator package 2 | =========================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | mauth\_client.lambda\_authenticator.lambda\_authenticator module 8 | ---------------------------------------------------------------- 9 | 10 | .. automodule:: mauth_client.lambda_authenticator.lambda_authenticator 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | 16 | Module contents 17 | --------------- 18 | 19 | .. automodule:: mauth_client.lambda_authenticator 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | -------------------------------------------------------------------------------- /mauth_client/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .utils import to_rsa_format 3 | 4 | 5 | class Config: 6 | APP_UUID = os.getenv("APP_UUID", os.getenv("MAUTH_APP_UUID")) 7 | MAUTH_URL = os.getenv("MAUTH_URL") 8 | MAUTH_API_VERSION = os.getenv("MAUTH_API_VERSION", "v1") 9 | MAUTH_MODE = os.getenv("MAUTH_MODE", "local") 10 | _private_key_env = os.getenv("PRIVATE_KEY", os.getenv("MAUTH_PRIVATE_KEY", "")) 11 | PRIVATE_KEY = to_rsa_format(_private_key_env) if _private_key_env else None 12 | V2_ONLY_AUTHENTICATE = str(os.getenv("V2_ONLY_AUTHENTICATE")).lower() == "true" 13 | SIGN_VERSIONS = os.getenv("MAUTH_SIGN_VERSIONS", "v1") 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: 5 | - released 6 | 7 | jobs: 8 | publish: 9 | name: 📦 Publish 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Install poetry 14 | run: pipx install poetry==2.2.1 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.13" 19 | cache: 'poetry' 20 | - name: Publish 21 | env: 22 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} 23 | run: | 24 | poetry publish --build 25 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = MauthForRequests 8 | SOURCEDIR = . 9 | BUILDDIR = ../build/sphinx 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tests/utils_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .common import load_key 4 | from mauth_client.utils import to_rsa_format 5 | 6 | PRIVATE_KEY = load_key("priv").strip() 7 | 8 | 9 | class TestToRsaFormat(unittest.TestCase): 10 | def test_proper_format(self): 11 | key = to_rsa_format(PRIVATE_KEY) 12 | self.assertEqual(key, PRIVATE_KEY) 13 | 14 | def test_newlines_replaced_with_spaces(self): 15 | key_no_newlines = PRIVATE_KEY.replace("\n", " ") 16 | key = to_rsa_format(key_no_newlines) 17 | self.assertEqual(key, PRIVATE_KEY) 18 | 19 | def test_newlines_removed(self): 20 | key_no_newlines = PRIVATE_KEY.replace("\n", "") 21 | key = to_rsa_format(key_no_newlines) 22 | self.assertEqual(key, PRIVATE_KEY) 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = clean, flake8-py3, py38, py39, py310, py311, py312, stats 3 | skipsdist = True 4 | 5 | [testenv] 6 | allowlist_externals = poetry 7 | skip_install = true 8 | commands = 9 | poetry install -v 10 | poetry run pytest --cov --cov-append --cov-report=term-missing -m "not protocol_suite" 11 | 12 | [testenv:clean] 13 | deps = coverage 14 | skip_install = true 15 | commands = coverage erase 16 | 17 | [testenv:flake8-py3] 18 | basepython = python3.11 19 | allowlist_externals = poetry 20 | skip_install = true 21 | commands = 22 | poetry install -v 23 | poetry run flake8 --version 24 | poetry run flake8 25 | 26 | [testenv:stats] 27 | deps = coverage 28 | skip_install = true 29 | commands = 30 | coverage report 31 | coverage html 32 | -------------------------------------------------------------------------------- /mauth_client/exceptions.py: -------------------------------------------------------------------------------- 1 | class InauthenticError(Exception): 2 | """ 3 | Used to indicate that an object was expected to be validly signed but its signature does not 4 | match its contents, and so is inauthentic. 5 | """ 6 | 7 | 8 | class UnableToAuthenticateError(Exception): 9 | """ 10 | The response from the MAuth service encountered when attempting to retrieve mauth 11 | """ 12 | 13 | 14 | class UnableToSignError(Exception): 15 | """ 16 | Required information for signing was missing 17 | """ 18 | 19 | 20 | class MAuthNotPresent(Exception): 21 | """ 22 | No mAuth signature present 23 | """ 24 | 25 | 26 | class MissingV2Error(Exception): 27 | """ 28 | V2 is required but not present and v1 is present 29 | """ 30 | -------------------------------------------------------------------------------- /mauth_client/lambda_helper.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode 2 | from mauth_client.config import Config 3 | from mauth_client.requests_mauth import MAuth 4 | 5 | RSA_PRIVATE_KEY = "RSA PRIVATE KEY" 6 | 7 | 8 | def generate_mauth(): 9 | return MAuth(Config.APP_UUID, _get_private_key()) 10 | 11 | 12 | def _get_private_key(): 13 | private_key = Config.PRIVATE_KEY 14 | if RSA_PRIVATE_KEY not in private_key: 15 | try: 16 | import boto3 17 | 18 | kms_client = boto3.client("kms") 19 | private_key = kms_client.decrypt(CiphertextBlob=b64decode(private_key))["Plaintext"].decode("ascii") 20 | except ModuleNotFoundError: 21 | pass 22 | 23 | return private_key.replace("\\n", "\n").replace(" ", "\n").replace("\nRSA\nPRIVATE\nKEY", " RSA PRIVATE KEY") 24 | -------------------------------------------------------------------------------- /mauth_client/lambda_authenticator/lambda_authenticator.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from mauth_client.authenticator import LocalAuthenticator, RemoteAuthenticator 3 | from mauth_client.config import Config 4 | from mauth_client.signable import RequestSignable 5 | from mauth_client.signed import Signed 6 | 7 | 8 | class LambdaAuthenticator: 9 | def __init__(self, method, url, headers, body): 10 | logger = logging.getLogger() 11 | signable = RequestSignable(method=method, url=url, body=body) 12 | authenticator = LocalAuthenticator if Config.MAUTH_MODE == "local" else RemoteAuthenticator 13 | self._authenticator = authenticator(signable, Signed.from_headers(headers), logger) 14 | 15 | def get_app_uuid(self): 16 | return self._authenticator.signed.app_uuid 17 | 18 | def is_authentic(self): 19 | return self._authenticator.is_authentic() 20 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Medidata Solutions 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /mauth_client/httpx_mauth/client.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from mauth_client.config import Config 3 | from mauth_client.signable import RequestSignable 4 | from mauth_client.signer import Signer 5 | 6 | 7 | class MAuthHttpx(httpx.Auth): 8 | """ 9 | HTTPX authentication for MAuth. 10 | Adds MAuth headers based on method, URL, and body bytes. 11 | """ 12 | 13 | # We need the body bytes to sign the request 14 | requires_request_body = True 15 | 16 | def __init__( 17 | self, 18 | app_uuid: str = Config.APP_UUID, 19 | private_key_data: str = Config.PRIVATE_KEY, 20 | sign_versions: str = Config.SIGN_VERSIONS, 21 | ): 22 | self.signer = Signer(app_uuid, private_key_data, sign_versions) 23 | 24 | def _make_headers(self, request: httpx.Request) -> dict[str, str]: 25 | # With requires_request_body=True, httpx ensures the content is buffered. 26 | body = request.content or b"" 27 | req_signable = RequestSignable( 28 | method=request.method, 29 | url=str(request.url), 30 | body=body, 31 | ) 32 | return self.signer.signed_headers(req_signable) 33 | 34 | def auth_flow(self, request: httpx.Request): 35 | # Body is already read due to requires_request_body=True. 36 | request.headers.update(self._make_headers(request)) 37 | yield request 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | dist_lambda/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | .venv/ 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | -------------------------------------------------------------------------------- /docs/mauth_setup.rst: -------------------------------------------------------------------------------- 1 | Setting Up a MAuth Application 2 | ============================== 3 | 4 | .. _create_a_mauth_app: 5 | 6 | Creating and registering MAuth Application credentials 7 | ------------------------------------------------------- 8 | 9 | 1. Create a keyset:: 10 | 11 | $ mkdir keypair_dir 12 | $ cd keypair_dir 13 | $ openssl genrsa -out yourname_mauth.priv.key 2048 14 | $ chmod 0600 yourname_mauth.priv.key 15 | $ openssl rsa -in yourname_mauth.priv.key -pubout -out yourname_mauth.pub.key 16 | 17 | 2. Register the Public Key with Medidata for the correct Environment (eg **Production**, **Innovate**, etc) 18 | 19 | Provide PUBLIC key to DevOps via Zendesk_ ticket with the following configuration: 20 | 21 | +----------------+---------------------------+ 22 | | Attribute | Value | 23 | +================+===========================+ 24 | |Form | OPS - Service Request | 25 | +----------------+---------------------------+ 26 | |Assignee | OPS-Devops Cloud Team | 27 | +----------------+---------------------------+ 28 | |Service Catalog | Application support | 29 | +----------------+---------------------------+ 30 | 31 | 3. When the application is registered an **Application UUID** (or *APP_UUID* ) is generated. The value for the *APP_UUID* will be included in the Ticket response. 32 | The *APP_UUID* is required for authentication of requests. 33 | 34 | .. _Zendesk: https://mdsolsupport.zendesk.com/ 35 | -------------------------------------------------------------------------------- /mauth_client/requests_mauth/client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from mauth_client.config import Config 3 | from mauth_client.signable import RequestSignable 4 | from mauth_client.signer import Signer 5 | 6 | 7 | class MAuth(requests.auth.AuthBase): 8 | """ 9 | Custom requests authorizer for MAuth 10 | """ 11 | 12 | def __init__( 13 | self, 14 | app_uuid=Config.APP_UUID, 15 | private_key_data=Config.PRIVATE_KEY, 16 | sign_versions=Config.SIGN_VERSIONS 17 | ): 18 | """ 19 | Create a new MAuth Instance 20 | 21 | :param str app_uuid: The Application UUID (or APP_UUID) for the application 22 | :param str private_key_data: Content of the Private Key File 23 | :param str sign_versions: Comma-separated protocol versions to sign requests 24 | """ 25 | self.signer = Signer(app_uuid, private_key_data, sign_versions) 26 | 27 | def __call__(self, request): 28 | """Call override, the entry point for a custom auth object 29 | 30 | :param requests.models.PreparedRequest request: the Request object 31 | """ 32 | request.headers.update(self.make_headers(request)) 33 | return request 34 | 35 | def make_headers(self, request): 36 | """Make headers for the request. 37 | 38 | :param requests.models.PreparedRequest request: the Request object 39 | """ 40 | request_signable = RequestSignable(method=request.method, url=request.url, body=request.body) 41 | return {**self.signer.signed_headers(request_signable)} 42 | -------------------------------------------------------------------------------- /tests/httpx_mauth/client_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import httpx 3 | from mauth_client.httpx_mauth import MAuthHttpx 4 | from ..common import load_key 5 | 6 | APP_UUID = "5ff4257e-9c16-11e0-b048-0026bbfffe5e" 7 | PRIVATE_KEY = load_key("priv") 8 | URL = "https://innovate.imedidata.com/api/v2/users/10ac3b0e-9fe2-11df-a531-12313900d531/studies.json" 9 | 10 | 11 | def handler(request): 12 | return httpx.Response(200, json={"text": "Hello, world!"}) 13 | 14 | 15 | class MAuthHttpxBaseTest(unittest.TestCase): 16 | def test_call(self): 17 | auth = MAuthHttpx(APP_UUID, PRIVATE_KEY, sign_versions="v1,v2") 18 | with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client: 19 | response = client.get(URL) 20 | 21 | for header in ["mcc-authentication", "mcc-time", "x-mws-authentication", "x-mws-time"]: 22 | self.assertIn(header, response.request.headers) 23 | 24 | def test_call_v1_only(self): 25 | auth = MAuthHttpx(APP_UUID, PRIVATE_KEY) 26 | with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client: 27 | response = client.get(URL) 28 | 29 | for header in ["x-mws-authentication", "x-mws-time"]: 30 | self.assertIn(header, response.request.headers) 31 | 32 | def test_call_v2_only(self): 33 | auth = MAuthHttpx(APP_UUID, PRIVATE_KEY, sign_versions="v2") 34 | with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client: 35 | response = client.get(URL) 36 | 37 | for header in ["mcc-authentication", "mcc-time"]: 38 | self.assertIn(header, response.request.headers) 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "mauth-client" 3 | version = "1.8.0" 4 | description = "MAuth Client for Python" 5 | repository = "https://github.com/mdsol/mauth-client-python" 6 | authors = ["Medidata Solutions "] 7 | license = "MIT" 8 | readme = "README.md" 9 | classifiers = [ 10 | "Development Status :: 3 - Alpha", 11 | "Environment :: Web Environment", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "Topic :: Internet :: WWW/HTTP", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | ] 24 | 25 | [tool.poetry.dependencies] 26 | python = "^3.9" 27 | requests = "^2.31.0" 28 | cachetools = "^5.3.3" 29 | rsa = "^4.9" 30 | asgiref = "^3.8.1" 31 | charset-normalizer = "^3.3.2" 32 | importlib = "^1.0.4" 33 | 34 | [tool.poetry.group.dev.dependencies] 35 | boto3 = "^1.34.106" 36 | flask = "^2.3.3" 37 | python-dateutil = "^2.9.0.post0" 38 | requests-mock = "^1.12.1" 39 | pytest = "^7.4.4" 40 | pytest-cov = "^4.1.0" 41 | pytest-freezer = "^0.4" 42 | pytest-randomly = "^3.15.0" 43 | pytest-subtests = "^0.10" 44 | flake8 = "^7.3.0" 45 | tox = "^4.15.0" 46 | fastapi = "^0.109.0" 47 | httpx = "^0.26.0" 48 | 49 | [tool.black] 50 | line-length = 120 51 | 52 | [build-system] 53 | requires = ["poetry>=0.12"] 54 | build-backend = "poetry.masonry.api" 55 | -------------------------------------------------------------------------------- /tests/requests_mauth/client_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | from requests import Request 4 | from mauth_client.requests_mauth import MAuth 5 | 6 | APP_UUID = "5ff4257e-9c16-11e0-b048-0026bbfffe5e" 7 | URL = "https://innovate.imedidata.com/api/v2/users/10ac3b0e-9fe2-11df-a531-12313900d531/studies.json" 8 | 9 | 10 | class RequestMock: 11 | """Simple mock for a request object""" 12 | 13 | def __init__(self, method, url, body): 14 | self.method = method 15 | self.url = url 16 | self.body = body 17 | 18 | 19 | class MAuthBaseTest(unittest.TestCase): 20 | def setUp(self): 21 | with open(os.path.join(os.path.dirname(__file__), "..", "keys", "fake_mauth.priv.key"), "r") as key_file: 22 | self.example_private_key = key_file.read() 23 | 24 | def test_call(self): 25 | auth = MAuth(APP_UUID, self.example_private_key, "v1,v2") 26 | request = Request("GET", URL, auth=auth).prepare() 27 | self.assertEqual( 28 | sorted(list(request.headers.keys())), 29 | ["MCC-Authentication", "MCC-Time", "X-MWS-Authentication", "X-MWS-Time"], 30 | ) 31 | 32 | def test_call_v1_only(self): 33 | auth = MAuth(APP_UUID, self.example_private_key) 34 | request = Request("GET", URL, auth=auth).prepare() 35 | self.assertEqual(sorted(list(request.headers.keys())), ["X-MWS-Authentication", "X-MWS-Time"]) 36 | 37 | def test_call_v2_only(self): 38 | auth = MAuth(APP_UUID, self.example_private_key, "v2") 39 | request = Request("GET", URL, auth=auth).prepare() 40 | self.assertEqual(sorted(list(request.headers.keys())), ["MCC-Authentication", "MCC-Time"]) 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We use [travis](https://travis-ci.org) for automated CI of the code (and status checks are required to pass prior to PR merges being accepted). 4 | We use travis to deploy updated versions to PyPI (only from `master`) 5 | 6 | For local development (cross version) we use [tox](http://tox.readthedocs.io/en/latest/) with [pyenv](https://github.com/pyenv/pyenv) to automate the running of unit tests against different python versions in virtualised python environments. 7 | 8 | ## Installation 9 | 10 | To setup your environment: 11 | 1. Install Python 12 | 1. Install Pyenv 13 | ```bash 14 | brew update 15 | brew install pyenv 16 | ``` 17 | 1. Install your favorite Python version (>= 3.8 please!) 18 | ```bash 19 | pyenv install 20 | ``` 21 | 1. Install Poetry, see: https://python-poetry.org/docs/#installation 22 | 1. Install Dependencies 23 | ```bash 24 | poetry install -v 25 | ``` 26 | 27 | 28 | ## Cloning the Repo 29 | 30 | This repo contains the submodule `mauth-protocol-test-suite` so requires a flag when initially cloning in order to clone and init submodules: 31 | ```sh 32 | git clone --recurse-submodules git@github.com:mdsol/mauth-client-python.git 33 | ``` 34 | 35 | If you have already cloned before the submodule was introduced, then run: 36 | ```sh 37 | cd tests/mauth-protocol-test-suite 38 | git submodule update --init 39 | ``` 40 | 41 | to init the submodule. 42 | 43 | 44 | ## Unit Tests 45 | 46 | 1. Make any changes, update the tests and then run tests with `poetry run tox`. 47 | 1. Coverage report can be viewed using `open htmlcov/index.html`. 48 | 1. Or if you don't care about tox, just run `poetry run pytest` or `poetry run pytest `. 49 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Check 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | 7 | jobs: 8 | lint: 9 | name: 🧹 Lint 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Install poetry 14 | run: pipx install poetry==2.2.1 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.13" 19 | cache: 'poetry' 20 | - name: Install dependencies 21 | run: poetry install --no-interaction 22 | - run: poetry run flake8 23 | 24 | test: 25 | name: 🧪 ${{ matrix.os }} / ${{ matrix.python-version }} 26 | runs-on: ${{ matrix.image }} 27 | strategy: 28 | matrix: 29 | os: [Ubuntu, macOS, Windows] 30 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 31 | include: 32 | - os: Ubuntu 33 | image: ubuntu-latest 34 | - os: Windows 35 | image: windows-latest 36 | - os: macOS 37 | image: macos-latest 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | with: 42 | submodules: true 43 | - name: Install poetry 44 | run: pipx install poetry==2.2.1 45 | - name: Set up Python ${{ matrix.python-version }} 46 | uses: actions/setup-python@v5 47 | id: python-setup 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | cache: 'poetry' 51 | - name: Set poetry environment 52 | run: poetry env use ${{ steps.python-setup.outputs.python-path }} 53 | - name: Install dependencies 54 | run: poetry install --no-interaction 55 | - run: poetry run pytest 56 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Mauth Authenticator for Requests documentation master file, created by 2 | sphinx-quickstart on Tue May 22 15:22:13 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Mauth Client for Python's documentation! 7 | ============================================================ 8 | 9 | *mauth-client* is a python library that provides an authentication library for MAuth Authentication. 10 | 11 | What is MAuth? 12 | -------------- 13 | The MAuth protocol provides a fault-tolerant, service-to-service authentication scheme for Medidata and third-party applications that use web services to communicate. The Authentication Service and integrity algorithm is based on digital signatures encrypted and decrypted with a private/public key pair. 14 | 15 | The Authentication Service has two responsibilities. It provides message integrity and provenance validation by verifying a message sender's signature; its other task is to manage public keys. Each public key is associated with an application and is used to authenticate message signatures. The private key corresponding to the public key in the Authentication Service is stored by the application making a signed request; the request is encrypted with this private key. The Authentication Service has no knowledge of the application's private key, only its public key. 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | :caption: Contents: 20 | 21 | getting_started 22 | mauth_setup 23 | examples 24 | faq 25 | modules 26 | 27 | .. _Requests: http://docs.python-requests.org/en/master/ 28 | 29 | Indices and tables 30 | ================== 31 | 32 | * :ref:`genindex` 33 | * :ref:`modindex` 34 | * :ref:`search` 35 | -------------------------------------------------------------------------------- /tests/keys/fake_mauth.priv.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA5yobZeE0fkWHpqIFQT6ipRaureyvgCWHcJ4kynU2qGxjzr63 3 | fgwW0syEGrcGAhNKcJ6QEkdylqTytyggVFeBR2KO1aVJUYvQB+tAyqPw/kHJU76g 4 | +zx5fKaIUWbaAY8vCT1hy4PIvnaI+hhiRUV+lNffL7an3Av7MY5gvwSTlrk3tCKV 5 | UrHaiGT3fLOGH2ZJBGn724EiddGtO4eSViYUtqgtcs8UMoiprRRZ53300SIYgsvc 6 | U1+lS33QvgTpWKrL3YiY+VCnzxgj2fuMt+UWrFu23xhe6eoByIRS1f2kOemrodym 7 | L/N9GgpEwUJCGyDCHP+vhygbS1kILBBvlrWUsQIDAQABAoIBAH84aUO0oZMs6O8I 8 | FCRIOHLq/M+zhxLblKKiJlVWFPK0VGmgBJRWSulQrROWzrOtsjYwzdGBiMrnlLzA 9 | VKqWTgvfbgSepq8+Zws0qb/cYfFMe2SfcTFTovi7HiLOnARnrNdE9OFwcbaAvfoG 10 | GW9OQ8/eznIP8GnmHiSz2wLFngRAd2RYr8dtmzXS93YfaMNC+mat3sAsiG2OhbBE 11 | nknXWaU2QcQysLx+YVa5bccjQcCLc53/ZQEEKv92isShLPaISIh1xEo8glwhOQX5 12 | xSkWL5DSYfLVDKZEVTA8uq8uvRuCp+yCQksGvjFONM9AAEbpgMrODmS4fKvC8nek 13 | 3ujc4VECgYEA9coK5mfnRmU5xNfhxIwPvNCGF7BN5kAmAFM2ULW6HLPPO7mSggY8 14 | 70uoEqAyISzc8c9AEG777UA/cEvMM/ehV8fdhephTz0OBFtgqhZKnmkOs2ry3E4S 15 | DlgeLuV0xUzvxn45hWs93ls2qUIY5xOVIvIkC1S2SLtSBMbjhNl9LPMCgYEA8MSH 16 | 6UmxJnB2I/VCMRNh66tUFOa6AIuzxaxBI/9gnblt8k1nsKRlLn28w0kbGg1kjN6f 17 | OQ1K2TT+4Qi4vWMoSuJgzHqPEYg4Iug6OH5C766E5TV3emiemvgfMwtMDjy6RngH 18 | EnqLl9FVppZSnF1NKf8PIkLOQM1pOsuzC6G9UMsCgYA4WX7QPgf+0pxA7cF73ySI 19 | hEIJ0ki5vgE4V9t++3rUs8CSD8Rv/OAheHXq90Em6/MnmP02B0vIo88nfqktTNt3 20 | lYHK/uYaVYQOKajqtbubv7g4GA/fxCJNmZQp6j8wMKhUGII1fVWs0bqhaV7uM0Yg 21 | weTTmDNGT3PJVpO41GfnUwKBgQCmeCxEp5gdrMpyiEQw7Gba7IXhQbo/YFh3B2eu 22 | vQcPZsNXMh/MaY4v++4E1Eox1Cq+n7pVVxR2ZAcKjt40zBdy11z4ZJEBHT87G3gN 23 | 0Xb0g6UNWc93SljHa2EfCOOYQHLHAHxbUXGtEab33J3X0UbmD51mIey7r4rfhTIR 24 | i836ywKBgQC5EmOWPwIYFjn91PN0YOTJWATDdIndWnvWZ045hbJpzUb05kEknTDb 25 | qjPBqsLO4l0D1gJJyq1inNmyENeWM/hSygDHdM6UcIMIdqFS5gsnVCgw0CTOljSA 26 | WCPnTT1+DUdvFnJHh093mDmY1R8Ny39Px4k90WdthCPJcJHj+xPhjw== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /mauth_client/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import charset_normalizer 3 | import re 4 | from hashlib import sha512 5 | 6 | HEADER = '-----BEGIN RSA PRIVATE KEY-----' 7 | FOOTER = '-----END RSA PRIVATE KEY-----' 8 | 9 | 10 | def make_bytes(val): 11 | """ 12 | :param str val: The supplied value (string-like) 13 | """ 14 | if isinstance(val, str): 15 | return val.encode("utf-8") 16 | if isinstance(val, int): 17 | return str(val).encode("utf-8") 18 | 19 | return val 20 | 21 | 22 | def hexdigest(val): 23 | return sha512(make_bytes(val)).hexdigest() 24 | 25 | 26 | def base64_encode(signature): 27 | return base64.b64encode(signature).decode("US-ASCII").replace("\n", "") 28 | 29 | 30 | def decode(byte_string: bytes) -> str: 31 | """ 32 | Attempt to decode a byte string with utf and fallback to charset_normalizer. 33 | """ 34 | try: 35 | return byte_string.decode("utf-8") 36 | except UnicodeDecodeError: 37 | encoding = charset_normalizer.detect(byte_string)["encoding"] 38 | return byte_string.decode(encoding) 39 | 40 | 41 | def to_rsa_format(key: str) -> str: 42 | """Convert a private key to RSA format with proper newlines.""" 43 | 44 | if "\n" in key and HEADER in key and FOOTER in key: 45 | return key 46 | 47 | body = key.strip() 48 | body = body.replace(HEADER, "").replace(FOOTER, "").strip() 49 | 50 | # Replace whitespace with newlines or chunk into 64-char lines 51 | if " " in body or "\t" in body: 52 | body = re.sub(r'\s+', '\n', body) 53 | else: 54 | # PEM-encoded keys are typically split into lines of 64 characters as per RFC 7468 (section 2) 55 | body = '\n'.join(body[i:i + 64] for i in range(0, len(body), 64)) 56 | 57 | return f"{HEADER}\n{body}\n{FOOTER}" 58 | -------------------------------------------------------------------------------- /mauth_client/signed.py: -------------------------------------------------------------------------------- 1 | from .consts import X_MWS_AUTH, X_MWS_TIME, MCC_AUTH, MCC_TIME, X_MWS_AUTH_PATTERN, MWSV2_AUTH_PATTERN 2 | 3 | 4 | class Signed: 5 | """ 6 | Extracts signature information from an incoming object. 7 | 8 | mauth_client will authenticate with the highest protocol version present and if authentication fails, 9 | will fallback to lower protocol versions (if provided). 10 | """ 11 | 12 | def __init__(self, x_mws_authentication, x_mws_time, mcc_authentication, mcc_time): 13 | self.x_mws_authentication = x_mws_authentication 14 | self.x_mws_time = x_mws_time 15 | self.mcc_authentication = mcc_authentication 16 | self.mcc_time = mcc_time 17 | 18 | if self.mcc_authentication: 19 | self.build_signature_info(self.mcc_data()) 20 | elif self.x_mws_authentication: 21 | self.build_signature_info(self.x_mws_data()) 22 | else: 23 | self.build_signature_info() 24 | 25 | def build_signature_info(self, match_data=None): 26 | self.token, self.app_uuid, self.signature = match_data.groups() if match_data else ("", "", "") 27 | 28 | def fall_back_to_mws_signature_info(self): 29 | self.build_signature_info(self.x_mws_data()) 30 | 31 | def x_mws_data(self): 32 | return X_MWS_AUTH_PATTERN.search(self.x_mws_authentication) 33 | 34 | def mcc_data(self): 35 | return MWSV2_AUTH_PATTERN.search(self.mcc_authentication) 36 | 37 | def protocol_version(self): 38 | if self.mcc_authentication: 39 | return 2 40 | 41 | if self.x_mws_authentication: 42 | return 1 43 | 44 | return None 45 | 46 | @classmethod 47 | def from_headers(cls, headers): 48 | lowercased_headers = {k.lower(): v for k, v in headers.items()} 49 | return cls(*(lowercased_headers.get(k.lower(), "") for k in [X_MWS_AUTH, X_MWS_TIME, MCC_AUTH, MCC_TIME])) 50 | -------------------------------------------------------------------------------- /mauth_client/key_holder.py: -------------------------------------------------------------------------------- 1 | import cachetools 2 | import re 3 | import requests 4 | from requests.adapters import HTTPAdapter 5 | from mauth_client.config import Config 6 | from mauth_client.lambda_helper import generate_mauth 7 | from mauth_client.exceptions import InauthenticError 8 | 9 | CACHE_MAXSIZE = 128 10 | CACHE_TTL = 300 11 | MAX_AGE_REGEX = re.compile(r"max-age=(\d+)") 12 | 13 | 14 | class KeyHolder: 15 | _CACHE = None 16 | _MAUTH = None 17 | _MAX_RETRIES = 3 18 | 19 | @classmethod 20 | def get_public_key(cls, app_uuid): 21 | if not cls._CACHE or app_uuid not in cls._CACHE: 22 | cls._set_public_key(app_uuid) 23 | 24 | return cls._CACHE.get(app_uuid) 25 | 26 | @classmethod 27 | def _set_public_key(cls, app_uuid): 28 | public_key, cache_control = cls._get_public_key_and_cache_control_from_mauth(app_uuid) 29 | if not cls._CACHE: 30 | cls._CACHE = cls._create_cache(cache_control) 31 | 32 | cls._CACHE[app_uuid] = public_key 33 | 34 | @classmethod 35 | def _create_cache(cls, cache_control): 36 | max_age_match = MAX_AGE_REGEX.match(cache_control or "") 37 | ttl = int(max_age_match.group(1)) if max_age_match else CACHE_TTL 38 | return cachetools.TTLCache(maxsize=CACHE_MAXSIZE, ttl=ttl) 39 | 40 | @classmethod 41 | def _get_public_key_and_cache_control_from_mauth(cls, app_uuid): 42 | if not cls._MAUTH: 43 | cls._MAUTH = {"auth": generate_mauth(), "url": Config.MAUTH_URL, "api_version": Config.MAUTH_API_VERSION} 44 | 45 | url = "{}/mauth/{}/security_tokens/{}.json".format(cls._MAUTH["url"], cls._MAUTH["api_version"], app_uuid) 46 | response = cls._request_session().get(url, auth=cls._MAUTH["auth"]) 47 | if response.status_code == 200: 48 | return response.json().get("security_token").get("public_key_str"), response.headers.get("Cache-Control") 49 | 50 | raise InauthenticError("Failed to fetch the public key for {} from {}".format(app_uuid, cls._MAUTH["url"])) 51 | 52 | @classmethod 53 | def _request_session(cls): 54 | session = requests.Session() 55 | adapter = HTTPAdapter(max_retries=cls._MAX_RETRIES) 56 | session.mount("https://", adapter) 57 | return session 58 | -------------------------------------------------------------------------------- /tests/protocol_test_suite_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock 3 | from freezegun import freeze_time 4 | import logging 5 | 6 | from mauth_client.authenticator import LocalAuthenticator 7 | from mauth_client.key_holder import KeyHolder 8 | from mauth_client.signable import RequestSignable 9 | from mauth_client.signed import Signed 10 | from .protocol_test_suite_helper import ProtocolTestSuiteHelper, ProtocolTestSuiteParser 11 | 12 | TEST_SUITE = ProtocolTestSuiteHelper() 13 | 14 | 15 | class ProtocolTestSuiteTest(unittest.TestCase): 16 | def setUp(self): 17 | self.__get_public_key__ = KeyHolder.get_public_key 18 | KeyHolder.get_public_key = MagicMock(return_value=TEST_SUITE.public_key) 19 | self.logger = logging.getLogger() 20 | 21 | def tearDown(self): 22 | # reset the KeyHolder.get_public_key method 23 | KeyHolder.get_public_key = self.__get_public_key__ 24 | 25 | @freeze_time(TEST_SUITE.request_time) 26 | def test_protocol_test_suite(self): 27 | for case_path in TEST_SUITE.cases(): 28 | parser = ProtocolTestSuiteParser(case_path) 29 | request_signable = RequestSignable(**parser.request_attributes) 30 | signed_headers_v2 = TEST_SUITE.signer.signed_headers_v2(request_signable) 31 | if "authentication-only" not in case_path: 32 | with self.subTest(test="string_to_sign_v2", case_name=parser.case_name): 33 | string_to_sign = request_signable.string_to_sign_v2(TEST_SUITE.additional_attributes) 34 | self.assertEqual(string_to_sign.decode("utf-8"), parser.sts) 35 | 36 | with self.subTest(test="signature", case_name=parser.case_name): 37 | self.assertEqual(TEST_SUITE.signer.signature_v2(parser.sts), parser.sig) 38 | 39 | with self.subTest(test="authentication headers", case_name=parser.case_name): 40 | self.assertEqual(signed_headers_v2, parser.auth_headers) 41 | 42 | with self.subTest(test="authentication", case_name=parser.case_name): 43 | signed = Signed.from_headers(signed_headers_v2) 44 | authenticator = LocalAuthenticator(request_signable, signed, self.logger) 45 | self.assertTrue(authenticator._authenticate()) 46 | -------------------------------------------------------------------------------- /docs/mauth_client.rst: -------------------------------------------------------------------------------- 1 | mauth\_client package 2 | ======================= 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | mauth_client.requests_mauth 10 | mauth_client.lambda_authenticator 11 | mauth_client.flask_authenticator 12 | 13 | 14 | mauth\_client.authenticator module 15 | ---------------------------------- 16 | 17 | .. automodule:: mauth_client.authenticator 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | 23 | mauth\_client.exceptions module 24 | ------------------------------- 25 | 26 | .. automodule:: mauth_client.exceptions 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | 32 | mauth\_client.key\_holder module 33 | -------------------------------- 34 | 35 | .. automodule:: mauth_client.key_holder 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: 39 | 40 | 41 | mauth\_client.lambda\_helper module 42 | ----------------------------------- 43 | 44 | .. automodule:: mauth_client.lambda_helper 45 | :members: 46 | :undoc-members: 47 | :show-inheritance: 48 | 49 | 50 | mauth\_client.rsa\_signer module 51 | -------------------------------- 52 | 53 | .. automodule:: mauth_client.rsa_signer 54 | :members: 55 | :undoc-members: 56 | :show-inheritance: 57 | 58 | 59 | mauth\_client.rsa\_verifier module 60 | ---------------------------------- 61 | 62 | .. automodule:: mauth_client.rsa_verifier 63 | :members: 64 | :undoc-members: 65 | :show-inheritance: 66 | 67 | 68 | mauth\_client.signable module 69 | ----------------------------- 70 | 71 | .. automodule:: mauth_client.signable 72 | :members: 73 | :undoc-members: 74 | :show-inheritance: 75 | 76 | 77 | mauth\_client.signed module 78 | --------------------------- 79 | 80 | .. automodule:: mauth_client.signed 81 | :members: 82 | :undoc-members: 83 | :show-inheritance: 84 | 85 | 86 | mauth\_client.signer module 87 | --------------------------- 88 | 89 | .. automodule:: mauth_client.signer 90 | :members: 91 | :undoc-members: 92 | :show-inheritance: 93 | 94 | 95 | mauth\_client.utils module 96 | -------------------------- 97 | 98 | .. automodule:: mauth_client.utils 99 | :members: 100 | :undoc-members: 101 | :show-inheritance: 102 | 103 | 104 | Module contents 105 | --------------- 106 | 107 | .. automodule:: mauth_client 108 | :members: 109 | :undoc-members: 110 | :show-inheritance: 111 | -------------------------------------------------------------------------------- /mauth_client/rsa_verifier.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import rsa 3 | from .exceptions import UnableToAuthenticateError 4 | from .key_holder import KeyHolder 5 | from .utils import make_bytes, hexdigest 6 | 7 | 8 | class RSAVerifier: 9 | """ 10 | Wrapper of the rsa library for verifying 11 | """ 12 | 13 | def __init__(self, app_uuid): 14 | """ 15 | :param app_uuid: 16 | """ 17 | key_text = KeyHolder.get_public_key(app_uuid) 18 | if "BEGIN PUBLIC KEY" in key_text: 19 | # Load a PKCS#1 PEM-encoded public key 20 | self.public_key = rsa.PublicKey.load_pkcs1_openssl_pem(keyfile=key_text) 21 | 22 | elif "BEGIN RSA PUBLIC KEY" in key_text: 23 | # Loads a PKCS#1.5 PEM-encoded public key 24 | self.public_key = rsa.PublicKey.load_pkcs1(keyfile=key_text, format="PEM") 25 | 26 | else: 27 | # Unable to identify the key type 28 | raise UnableToAuthenticateError("Unable to identify Public Key type from Signature.") 29 | 30 | def verify_v1(self, expected, signature): 31 | try: 32 | padded = self.public_decrypt(signature) 33 | actual = self.unpad_message(padded) 34 | 35 | if hexdigest(expected) == actual.decode("utf-8"): 36 | return True 37 | 38 | return False 39 | 40 | except ValueError: 41 | return False 42 | 43 | def verify_v2(self, expected, signature): 44 | try: 45 | rsa.verify(make_bytes(expected), base64.b64decode(signature), self.public_key) 46 | return True 47 | 48 | except rsa.VerificationError: 49 | return False 50 | 51 | def public_decrypt(self, signature): 52 | """ 53 | Decrypt a String encrypted with a private key, returns the hash 54 | 55 | :param str signature: encrypted signature 56 | :return: signature hash 57 | :rtype: str 58 | """ 59 | # base64 decode 60 | decoded = base64.b64decode(make_bytes(signature)) 61 | # transform the decoded signature to int 62 | encrypted = rsa.transform.bytes2int(decoded) 63 | payload = rsa.core.decrypt_int(encrypted, self.public_key.e, self.public_key.n) 64 | padded = rsa.transform.int2bytes(payload, rsa.common.byte_size(self.public_key.n)) 65 | return padded 66 | 67 | @staticmethod 68 | def unpad_message(padded): 69 | """ 70 | Removes the padding from the string 71 | 72 | :param padded: padded string 73 | :rtype: str 74 | """ 75 | return padded[padded.index(b"\x00", 2) + 1 :] 76 | -------------------------------------------------------------------------------- /tests/lambda_authenticator/lambda_authenticator_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | from unittest.mock import MagicMock 4 | from io import StringIO 5 | import logging 6 | from mauth_client.config import Config 7 | from mauth_client.lambda_authenticator import LambdaAuthenticator 8 | 9 | LAMBDA_APP_UUID = "2f746447-c212-483c-9eec-d9b0216f7613" 10 | CLIENT_APP_UUID = "f5af50b2-bf7d-4c29-81db-76d086d4808a" 11 | URL = "https://api_gateway.com/sandbox/path" 12 | X_MWS_TIME = "1500854400" # 2017-07-24 09:00:00 UTC 13 | 14 | SIGNATURE = ( 15 | "p0SNltF6B4G5z+nVNbLv2XCEdouimo/ECQ/Sum6YM+QgE1/LZLXY+hAcwe/TkaC/2d8I3Zot37Xgob3cftgSf9S1fPAi3euN0Fmv/OE" 16 | "kfUmsYvmqyOXawEWGpevoEX6KNpEAUrt48hFGomsWRgbEEjuUtN4iiPe9y3HlIjumUmDrM499RZxgZdyOhqtLVOv5ngNShDbFv2LljI" 17 | "Tl4sO0f7zU8wAYGfxLEPXvp8qgnzQ6usZwrD2ujSmXbZtksqgG1R0Vmb7LAd6P+uvtRkw8kGLz/wWwxRweSGliX/IwovGi/bMIIClDD" 18 | "faUAY9QDjcU1x7i0Yy1IEyQYyCWcnL1rA==" 19 | ) 20 | 21 | X_MWS_AUTHENTICATION = "MWS {}:{}".format(CLIENT_APP_UUID, SIGNATURE) 22 | HEADERS = {"X-Mws-Time": X_MWS_TIME, "X-Mws-Authentication": X_MWS_AUTHENTICATION} 23 | BODY = "こんにちはÆ" 24 | 25 | 26 | class TestLambdaAuthenticator(unittest.TestCase): 27 | def setUp(self): 28 | Config.APP_UUID = LAMBDA_APP_UUID 29 | Config.MAUTH_MODE = "local" 30 | self.lambda_authenticator = LambdaAuthenticator("POST", URL, HEADERS, BODY) 31 | 32 | # redirect the output of stdout to self.captor 33 | self.captor = StringIO() 34 | self.logger = logging.getLogger() 35 | self.logger_handlers = self.logger.handlers 36 | self.logger.handlers = [logging.StreamHandler(self.captor)] 37 | 38 | def tearDown(self): 39 | # reset the output of stdout to console 40 | sys.stdout = sys.__stdout__ 41 | self.logger.handlers = self.logger_handlers 42 | 43 | def test_get_app_uuid(self): 44 | self.assertEqual(self.lambda_authenticator.get_app_uuid(), CLIENT_APP_UUID) 45 | 46 | def test_is_authentic(self): 47 | self.logger.setLevel(logging.INFO) 48 | self.lambda_authenticator._authenticator._authenticate = MagicMock(return_value=True) 49 | authentic, status, message = self.lambda_authenticator.is_authentic() 50 | 51 | self.assertTrue(authentic) 52 | self.assertEqual(status, 200) 53 | self.assertEqual(message, "") 54 | 55 | self.assertEqual( 56 | self.captor.getvalue(), 57 | "Mauth-client attempting to authenticate request from app with mauth" 58 | " app uuid {} to app with mauth app uuid {}" 59 | " using version MWS.\n".format(CLIENT_APP_UUID, LAMBDA_APP_UUID), 60 | ) 61 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.8.0 2 | - Add `to_rsa_format` function to normalize private key 3 | - Update requests_mauth and httpx_mauth to support reading configuration from environment variables 4 | 5 | # 1.7.0 6 | - Add `MAuthHttpx` custom authentication scheme for HTTPX. 7 | - Remove Support for EOL Python 3.8 8 | 9 | # 1.6.6 10 | - Support long-lived connections in ASGI middleware 11 | 12 | # 1.6.5 13 | - Resolved dependabot identified security issues 14 | - Removed build status icon from travis (not used for CI any longer) 15 | 16 | # 1.6.4 17 | - Fix `MAuthASGIMiddleware` when accessing `path` when `path` is not set yet. 18 | This appears to only happen on startup. 19 | - Replace the character `\n` on one-liner private keys. 20 | 21 | # 1.6.3 22 | - Revert change introduced in v1.6.2 now that Starlette has been updated to 23 | always include `root_path` in `path`. 24 | 25 | # 1.6.2 26 | - Fix `MAuthASGIMiddleware` signature validation when the full URL path is split 27 | between `root_path` and `path` in the request scope. 28 | 29 | # 1.6.1 30 | - Fix `MAuthWSGIMiddleware` to return a string for "status" and to properly set 31 | content-length header. 32 | 33 | # 1.6.0 34 | - Fix bug with reading request body in `MAuthWSGIMiddleware`. 35 | - Remove Support for EOL Python 3.7 36 | 37 | # 1.5.1 38 | - Fix `MAuthWSGIMiddleware` to no longer depend on `werkzeug` data in the request env. 39 | 40 | # 1.5.0 41 | - Replace `cchardet` with `charset-normalizer` to support Python 3.11 42 | 43 | # 1.4.0 44 | - Add `MAuthWSGIMiddleware` for authenticating requests in WSGI frameworks like Flask. 45 | - Remove `FlaskAuthenticator`. 46 | 47 | # 1.3.0 48 | - Add `MAuthASGIMiddleware` for authenticating requests in ASGI frameworks like FastAPI. 49 | - Remove Support for EOL Python 3.6 50 | 51 | # 1.2.3 52 | - Ignore `boto3` import error (`ModuleNotFoundError`). 53 | 54 | # 1.2.2 55 | - Extend the fallback cache TTL to 5 minutes. 56 | 57 | # 1.2.1 58 | - Add autodeploy to PyPI 59 | - Remove Support for EOL Python 3.5 60 | - Remove PyPy support 61 | 62 | # 1.2.0 63 | - Change the default signing versions (`MAUTH_SIGN_VERSIONS` option) to `v1` only. 64 | 65 | # 1.1.0 66 | - Replace `V2_ONLY_SIGN_REQUESTS` option with `MAUTH_SIGN_VERSIONS` option and change the default to `v2` only. 67 | 68 | # 1.0.0 69 | - Add parsing code to test with mauth-protocol-test-suite. 70 | - Add unescape step in query_string encoding in order to remove "double encoding". 71 | - Add normalization of paths. 72 | 73 | # 0.5.0 74 | - Fall back to V1 when V2 authentication fails. 75 | 76 | # 0.4.0 77 | - Add `FlaskAuthenticator` to authenticate requests in Flask applications. 78 | 79 | # 0.3.0 80 | - Support binary request bodies. 81 | 82 | # 0.2.1 83 | - Fix `LambdaAuthenticator` to return an empty string on "200 OK" response. 84 | 85 | # 0.2.0 86 | - Add support for MWSV2 protocol. 87 | - Rename `MAuthAuthenticator` to `LambdaAuthenticator`. 88 | 89 | # 0.1.0 90 | - Initial release. 91 | -------------------------------------------------------------------------------- /tests/signed_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from mauth_client.signed import Signed 4 | 5 | APP_UUID = "f5af50b2-bf7d-4c29-81db-76d086d4808a" 6 | URL = "https://api_gateway.com/sandbox/path" 7 | EPOCH = "1500854400" # 2017-07-24 09:00:00 UTC 8 | 9 | X_MWS_SIGNATURE = ( 10 | "p0SNltF6B4G5z+nVNbLv2XCEdouimo/ECQ/Sum6YM+QgE1/LZLXY+hAcwe/TkaC/2d8I3Zot37Xgob3cftgSf9S1fPAi3euN0Fm" 11 | "v/OEkfUmsYvmqyOXawEWGpevoEX6KNpEAUrt48hFGomsWRgbEEjuUtN4iiPe9y3HlIjumUmDrM499RZxgZdyOhqtLVOv5ngNShDbFv2Ll" 12 | "jITl4sO0f7zU8wAYGfxLEPXvp8qgnzQ6usZwrD2ujSmXbZtksqgG1R0Vmb7LAd6P+uvtRkw8kGLz/wWwxRweSGliX/IwovGi/bMIIClDD" 13 | "faUAY9QDjcU1x7i0Yy1IEyQYyCWcnL1rA==" 14 | ) 15 | X_MWS_AUTHENTICATION = "MWS {}:{}".format(APP_UUID, X_MWS_SIGNATURE) 16 | X_MWS_HEADERS = {"X-MWS-Time": EPOCH, "X-MWS-Authentication": X_MWS_AUTHENTICATION} 17 | 18 | MWSV2_SIGNATURE = ( 19 | "Ub8CWA4rIWsG62PbzKeP33pBDXDk+yY5l3XdI35NSrS7LlwJMQ78C5y+yIAsDAZL3RqZTAd8zQJKdh3s1JXdd3ccc/hoJfs3B31" 20 | "qCzZffx685QoVpl+Az2AJHvGzOUcZi55ZsvArvdlTikNH7dVz3+K5y5Q5/c2i2D5CBiqD+76zRy6R43BoxxD9flVwhy6PCdgfygegyZo2" 21 | "g5F7MEgAH/Qvpc6omoVxkbGUmMdWbu00CkfVYh511L4RYss9lLMdd84/2OhV/uG/JtObSJuf5dObvAwKNwqxcmuuAVOE7Bo/qtUL5XBIl" 22 | "Kmst1b9CjoRn2sZzd/alvZtTdFqdC7DeQ==" 23 | ) 24 | MWSV2_AUTHENTICATION = "MWSV2 {}:{};".format(APP_UUID, MWSV2_SIGNATURE) 25 | MWSV2_HEADERS = {"MCC-Time": EPOCH, "MCC-Authentication": MWSV2_AUTHENTICATION} 26 | 27 | 28 | class TestSigned(unittest.TestCase): 29 | def test_from_headers_v1(self): 30 | signed = Signed.from_headers(X_MWS_HEADERS) 31 | 32 | self.assertEqual(signed.protocol_version(), 1) 33 | self.assertEqual(signed.x_mws_time, EPOCH) 34 | self.assertEqual(signed.token, "MWS") 35 | self.assertEqual(signed.app_uuid, APP_UUID) 36 | self.assertEqual(signed.signature, X_MWS_SIGNATURE) 37 | 38 | def test_from_headers_v2(self): 39 | signed = Signed.from_headers(MWSV2_HEADERS) 40 | 41 | self.assertEqual(signed.protocol_version(), 2) 42 | self.assertEqual(signed.mcc_time, EPOCH) 43 | self.assertEqual(signed.token, "MWSV2") 44 | self.assertEqual(signed.app_uuid, APP_UUID) 45 | self.assertEqual(signed.signature, MWSV2_SIGNATURE) 46 | 47 | def test_from_headers_missing_header(self): 48 | signed = Signed.from_headers({}) 49 | 50 | self.assertEqual(signed.protocol_version(), None) 51 | self.assertEqual(signed.mcc_time, "") 52 | self.assertEqual(signed.token, "") 53 | self.assertEqual(signed.app_uuid, "") 54 | self.assertEqual(signed.signature, "") 55 | 56 | def test_from_headers_bad_header(self): 57 | bad_header = {"MCC-Time": EPOCH, "MCC-Authentication": X_MWS_AUTHENTICATION} 58 | signed = Signed.from_headers(bad_header) 59 | 60 | self.assertEqual(signed.protocol_version(), 2) 61 | self.assertEqual(signed.mcc_time, EPOCH) 62 | self.assertEqual(signed.token, "") 63 | self.assertEqual(signed.app_uuid, "") 64 | self.assertEqual(signed.signature, "") 65 | -------------------------------------------------------------------------------- /tests/key_holder_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from io import StringIO 4 | 5 | import unittest 6 | from unittest.mock import MagicMock 7 | 8 | import requests_mock 9 | from mauth_client.key_holder import KeyHolder 10 | from mauth_client.exceptions import InauthenticError 11 | from .common import load_key 12 | 13 | APP_UUID = "f5af50b2-bf7d-4c29-81db-76d086d4808a" 14 | MAUTH_URL = "https://mauth.com" 15 | MAUTH_API_VERSION = "v1" 16 | MAUTH_PATH = "{}/mauth/v1/security_tokens/{}.json".format(MAUTH_URL, APP_UUID) 17 | PUBLIC_KEY = load_key("rsapub") 18 | 19 | MAUTH_RESPONSE = { 20 | "security_token": { 21 | "app_name": "awesome-app-sandbox", 22 | "app_uuid": APP_UUID, 23 | "public_key_str": PUBLIC_KEY, 24 | "created_at": "2016-10-21T21:16:14Z", 25 | } 26 | } 27 | 28 | CACHE_CONTROL = "max-age=60, private" 29 | 30 | 31 | class TestKeyHolder(unittest.TestCase): 32 | def setUp(self): 33 | KeyHolder._MAUTH = {"auth": MagicMock(), "url": MAUTH_URL, "api_version": MAUTH_API_VERSION} 34 | 35 | # redirect the output of stdout to self.captor 36 | self.captor = StringIO() 37 | sys.stdout = self.captor 38 | 39 | def tearDown(self): 40 | # reset the output of stdout to console 41 | sys.stdout = sys.__stdout__ 42 | 43 | def test_get_request(self): 44 | KeyHolder._CACHE = None 45 | with requests_mock.mock() as requests: 46 | requests.get(MAUTH_PATH, text=json.dumps(MAUTH_RESPONSE)) 47 | self.assertEqual(KeyHolder.get_public_key(APP_UUID), PUBLIC_KEY) 48 | self.assertEqual(KeyHolder._CACHE.maxsize, 128) 49 | self.assertEqual(KeyHolder._CACHE.ttl, 300) 50 | 51 | def test_get_request_respect_cache_header(self): 52 | KeyHolder._CACHE = None 53 | with requests_mock.mock() as requests: 54 | requests.get(MAUTH_PATH, text=json.dumps(MAUTH_RESPONSE), headers={"Cache-Control": CACHE_CONTROL}) 55 | self.assertEqual(KeyHolder.get_public_key(APP_UUID), PUBLIC_KEY) 56 | self.assertEqual(KeyHolder._CACHE.ttl, 60) 57 | 58 | def test_get_request_cache_expiration(self): 59 | KeyHolder._CACHE = None 60 | with requests_mock.mock() as requests: 61 | requests.get(MAUTH_PATH, text=json.dumps(MAUTH_RESPONSE), headers={"Cache-Control": CACHE_CONTROL}) 62 | self.assertEqual(KeyHolder.get_public_key(APP_UUID), PUBLIC_KEY) 63 | KeyHolder._CACHE.expire(KeyHolder._CACHE.timer() + 60) 64 | self.assertEqual(KeyHolder._CACHE.get(APP_UUID), None) 65 | 66 | def test_get_request_404_error(self): 67 | KeyHolder._CACHE = None 68 | with requests_mock.mock() as requests: 69 | requests.get(MAUTH_PATH, status_code=404) 70 | with self.assertRaises(InauthenticError) as exc: 71 | KeyHolder.get_public_key(APP_UUID) 72 | self.assertEqual( 73 | str(exc.exception), "Failed to fetch the public key for {} from {}".format(APP_UUID, MAUTH_URL) 74 | ) 75 | -------------------------------------------------------------------------------- /mauth_client/rsa_signer.py: -------------------------------------------------------------------------------- 1 | # This module exists to reproduce, with the rsa library, the raw signature required by MAuth 2 | # which in OpenSSL is created with private_encrypt(hash). It provides an RSA sign class built from 3 | # code that came from https://www.dlitz.net/software/pycrypto/api/current/ no copyright of that original 4 | # code is claimed. 5 | 6 | import rsa 7 | from .utils import make_bytes, hexdigest 8 | 9 | 10 | class RSASigner: 11 | """ 12 | Wrapper of the rsa library for signing 13 | """ 14 | 15 | def __init__(self, private_key_data): 16 | """ 17 | :param private_key_data: 18 | """ 19 | self.private_key = rsa.PrivateKey.load_pkcs1(private_key_data, "PEM") 20 | 21 | def sign_v2(self, string_to_sign): 22 | """Signs the data using SHA512 for V2 protocol 23 | 24 | :param str string_to_sign: The string to sign 25 | :rtype: str 26 | """ 27 | return rsa.sign(make_bytes(string_to_sign), self.private_key, "SHA-512") 28 | 29 | def sign_v1(self, string_to_sign): 30 | """Signs the data in a emulation of the OpenSSL private_encrypt method for V1 protocol 31 | 32 | :param str string_to_sign: The string to sign 33 | :rtype: str 34 | """ 35 | hashed = hexdigest(string_to_sign).encode("US-ASCII") 36 | keylength = rsa.common.byte_size(self.private_key.n) 37 | padded = self.pad_for_signing(hashed, keylength) 38 | padded = make_bytes(padded) 39 | payload = rsa.transform.bytes2int(padded) 40 | encrypted = rsa.core.encrypt_int(payload, self.private_key.d, self.private_key.n) 41 | return rsa.transform.int2bytes(encrypted, keylength) 42 | 43 | @staticmethod 44 | def pad_for_signing(message, target_length): 45 | """Pulled from rsa pkcs1.py, 46 | 47 | Pads the message for signing, returning the padded message. 48 | 49 | The padding is always a repetition of FF bytes:: 50 | 51 | 00 01 PADDING 00 MESSAGE 52 | 53 | Sample code:: 54 | 55 | >>> block = RSASigner.pad_for_signing("hello", 16) 56 | >>> len(block) 57 | 16 58 | >>> block[0:2] 59 | "\x00\x01" 60 | >>> block[-6:] 61 | "\x00hello" 62 | >>> block[2:-6] 63 | "\xff\xff\xff\xff\xff\xff\xff\xff" 64 | 65 | :param message: message to pad in readiness for signing 66 | :type message: str 67 | :param target_length: target length for padded string 68 | :type target_length: int 69 | 70 | :rtype: str 71 | :return: suitably padded string 72 | """ 73 | 74 | max_msglength = target_length - 11 75 | msglength = len(message) 76 | 77 | if msglength > max_msglength: # pragma: no cover 78 | raise OverflowError( 79 | "%i bytes needed for message, but there is only" " space for %i" % (msglength, max_msglength) 80 | ) 81 | 82 | padding_length = target_length - msglength - 3 83 | 84 | return b"".join([b"\x00\x01", padding_length * b"\xff", b"\x00", message]) 85 | -------------------------------------------------------------------------------- /mauth_client/signer.py: -------------------------------------------------------------------------------- 1 | import time 2 | import re 3 | from .rsa_signer import RSASigner 4 | from .consts import AUTH_HEADER_DELIMITER, MWS_TOKEN, X_MWS_AUTH, X_MWS_TIME, MWSV2_TOKEN, MCC_AUTH, MCC_TIME 5 | from .utils import base64_encode 6 | 7 | 8 | class Signer: 9 | """ 10 | methods to sign requests. 11 | """ 12 | 13 | def __init__(self, app_uuid, private_key_data, sign_versions): 14 | """ 15 | Create a new Signer Instance 16 | 17 | :param str app_uuid: The Application UUID (or APP_UUID) for the application 18 | :param str private_key_data: Content of the Private Key File 19 | :param str sign_versions: Comma-separated protocol versions to sign requests 20 | """ 21 | self.app_uuid = app_uuid 22 | self.rsa_signer = RSASigner(private_key_data) 23 | self.sign_versions = self._list_sign_versions(sign_versions) 24 | 25 | def signed_headers(self, signable, attributes=None): 26 | """ 27 | Takes a signable object and returns a hash of headers to be applied to the object which comprises its signature. 28 | """ 29 | headers = {} 30 | if "v1" in self.sign_versions: 31 | headers.update(self.signed_headers_v1(signable, attributes)) 32 | 33 | if "v2" in self.sign_versions: 34 | headers.update(self.signed_headers_v2(signable, attributes)) 35 | 36 | return headers 37 | 38 | def signed_headers_v1(self, signable, attributes=None): 39 | override_attributes = self._build_override_attributes(attributes) 40 | signature = self.signature_v1(signable.string_to_sign_v1(override_attributes)) 41 | 42 | return { 43 | X_MWS_AUTH: "{} {}:{}".format(MWS_TOKEN, self.app_uuid, signature), 44 | X_MWS_TIME: override_attributes.get("time"), 45 | } 46 | 47 | def signed_headers_v2(self, signable, attributes=None): 48 | override_attributes = self._build_override_attributes(attributes) 49 | signature = self.signature_v2(signable.string_to_sign_v2(override_attributes)) 50 | 51 | return { 52 | MCC_AUTH: "{} {}:{}{}".format(MWSV2_TOKEN, self.app_uuid, signature, AUTH_HEADER_DELIMITER), 53 | MCC_TIME: override_attributes.get("time"), 54 | } 55 | 56 | def signature_v1(self, string_to_sign): 57 | return base64_encode(self.rsa_signer.sign_v1(string_to_sign)) 58 | 59 | def signature_v2(self, string_to_sign): 60 | return base64_encode(self.rsa_signer.sign_v2(string_to_sign)) 61 | 62 | def _build_override_attributes(self, attributes): 63 | if not attributes: 64 | attributes = {} 65 | 66 | return {"time": str(int(time.time())), "app_uuid": self.app_uuid, **attributes} 67 | 68 | @staticmethod 69 | def _list_sign_versions(sign_versions): 70 | sign_versions = sign_versions.lower().replace(" ", "").split(",") 71 | if not all(re.match(r"^v\d+$", sign_version) for sign_version in sign_versions): 72 | raise ValueError("SIGN_VERSIONS must be comma-separated MAuth protocol versions (e.g. 'v1,v2')") 73 | 74 | return sign_versions 75 | -------------------------------------------------------------------------------- /tests/protocol_test_suite_helper.py: -------------------------------------------------------------------------------- 1 | # file to handle loading and parsing of mauth protocol test suite cases in order 2 | # to run them as unit tests 3 | 4 | from datetime import datetime, timezone 5 | import glob 6 | import os 7 | import json 8 | 9 | from mauth_client.signer import Signer 10 | 11 | TEST_SUITE_RELATIVE_PATH = "mauth-protocol-test-suite" 12 | MAUTH_PROTOCOL_DIR = os.path.join(os.path.dirname(__file__), TEST_SUITE_RELATIVE_PATH) 13 | CASE_PATH = os.path.join(MAUTH_PROTOCOL_DIR, "protocols/MWSV2") 14 | 15 | 16 | class ProtocolTestSuiteHelper: 17 | def __init__(self): 18 | if not os.path.isdir(MAUTH_PROTOCOL_DIR): 19 | self.request_time = None 20 | self.public_key = None 21 | return 22 | 23 | with open(os.path.join(MAUTH_PROTOCOL_DIR, "signing-config.json"), "r") as config_file: 24 | config = json.load(config_file) 25 | 26 | with open(os.path.join(MAUTH_PROTOCOL_DIR, config["private_key_file"]), "r") as key_file: 27 | private_key = key_file.read() 28 | 29 | with open(os.path.join(MAUTH_PROTOCOL_DIR, "signing-params/rsa-key-pub"), "r") as key_file: 30 | self.public_key = key_file.read() 31 | 32 | self.request_time = datetime.fromtimestamp(float(config["request_time"]), timezone.utc) 33 | self.app_uuid = config["app_uuid"] 34 | self.signer = Signer(config["app_uuid"], private_key, "v2") 35 | self.additional_attributes = {"app_uuid": config["app_uuid"], "time": config["request_time"]} 36 | 37 | def cases(self): 38 | return glob.glob(os.path.join(CASE_PATH, "*")) 39 | 40 | 41 | class ProtocolTestSuiteParser: 42 | def __init__(self, case_path): 43 | self.case_name = os.path.basename(case_path) 44 | self.request_attributes = self.build_request_attributes(case_path) 45 | self.sts = self.read_file_by_extension(case_path, "sts") 46 | self.sig = self.read_file_by_extension(case_path, "sig") 47 | self.auth_headers = {k: str(v) for k, v in self.read_json_by_extension(case_path, "authz").items()} 48 | 49 | def build_request_attributes(self, case_path): 50 | req = self.read_json_by_extension(case_path, "req") 51 | body_file_path = os.path.join(case_path, req["body_filepath"]) if "body_filepath" in req else "" 52 | body = self.read_file(body_file_path, "rb") if body_file_path else req.get("body") 53 | return {"method": req.get("verb"), "url": "https://example.org{}".format(req.get("url")), "body": body} 54 | 55 | @staticmethod 56 | def read_json_by_extension(case_path, extension): 57 | files = glob.glob(os.path.join(case_path, "*.{}".format(extension))) 58 | with open(files[0], "r") as f: 59 | return json.load(f) 60 | 61 | @classmethod 62 | def read_file_by_extension(cls, case_path, extension): 63 | files = glob.glob(os.path.join(case_path, "*.{}".format(extension))) 64 | return cls.read_file(files[0]) if files else None 65 | 66 | @staticmethod 67 | def read_file(file_path, mode="r"): 68 | with open(file_path, mode) as f: 69 | return f.read() 70 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Example Usage 2 | ============= 3 | 4 | Accessing an iMedidata User Endpoint 5 | ------------------------------------ 6 | 7 | This sample shows the code to make a call to the iMedidata API endpoint. 8 | 9 | Note the extracted method `generate_signer` for creating a MAuth Request signer using either the key text or a reference to a Private Key file. 10 | 11 | 12 | .. code-block:: python 13 | 14 | import requests 15 | 16 | from mauth_client.requests_mauth import MAuth 17 | 18 | def generate_signer(app_uuid, private_key_file=None, private_key_string=None): 19 | """ 20 | Generate a MAUTH instance 21 | :param str app_uuid: Application UUID 22 | :param str private_key_file: name of the Private Key File 23 | :param str private_key_string: content of the Private Key String 24 | :return: 25 | """ 26 | if not private_key_string: 27 | auth = MAuth(app_uuid, open(private_key_file, "r").read()) 28 | else: 29 | auth = MAuth(app_uuid, private_key_string) 30 | return auth 31 | 32 | def get_user_details(configuration, user_uuid): 33 | """ 34 | Get the User details from the iMedidata API 35 | :param dict configuration: Configuration Set 36 | :param str user_uuid: UUID for user 37 | """ 38 | mauth_signer = generate_signer(**configuration) 39 | base_url = "https://www.imedidata.com" 40 | api_path = f"api/v2/users/{user_uuid}.json" 41 | full_url = base_url + "/" + api_path 42 | response = requests.get(full_url, auth=mauth_signer) 43 | if response.status_code == 200: 44 | return response.json() 45 | else: 46 | print(f"Looking up {user_uuid} failed with code {response.status_code}") 47 | return {} 48 | 49 | Using the API Gateway 50 | --------------------- 51 | 52 | In this example we use the MAuth signer to access the underlying Countries API endpoint. 53 | 54 | .. code-block:: python 55 | 56 | import requests 57 | 58 | from mauth_client.requests_mauth import MAuth 59 | 60 | def generate_signer(app_uuid, private_key_file=None, private_key_string=None): 61 | """ 62 | Generate a MAUTH instance 63 | :param str app_uuid: Application UUID 64 | :param str private_key_file: name of the Private Key File 65 | :param str private_key_string: content of the Private Key String 66 | :return: 67 | """ 68 | if not private_key_string: 69 | auth = MAuth(app_uuid, open(private_key_file, "r").read()) 70 | else: 71 | auth = MAuth(app_uuid, private_key_string) 72 | return auth 73 | 74 | def get_countries(configuration): 75 | """ 76 | Get the list of countries from the API GW for a specific API version 77 | :param dict configuration: a configuration dictionary 78 | """ 79 | full_url = "https://api.mdsol.com/countries" 80 | headers = {"Accept": "application/json", "Mcc-Version": "v2019-03-22"} 81 | mauth_signer = generate_signer(**configuration) 82 | 83 | session = requests.Session() 84 | session.auth = mauth_signer 85 | session.headers = headers 86 | 87 | response = session.get(full_url) 88 | if response.status_code == 200: 89 | return response.json() 90 | else: 91 | print(f"Accessing the countries endpoint failed with error: {response.status_code}") 92 | return {} 93 | 94 | 95 | Authenticating Incoming Requests in AWS Lambda 96 | ---------------------------------------------- 97 | 98 | In this example we use the lambda authenticator to authenticate incoming requests. 99 | 100 | .. code-block:: python 101 | 102 | from mauth_client.lambda_authenticator import LambdaAuthenticator 103 | 104 | lambda_authenticator = LambdaAuthenticator(method, url, headers, body) 105 | authentic, status_code, message = lambda_authenticator.is_authentic() 106 | app_uuid = lambda_authenticator.get_app_uuid() 107 | -------------------------------------------------------------------------------- /tests/signable_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import json 3 | from hashlib import sha512 4 | from mauth_client.signable import RequestSignable 5 | from mauth_client.exceptions import UnableToSignError 6 | 7 | APP_UUID = "5ff4257e-9c16-11e0-b048-0026bbfffe5e" 8 | REQUEST_ATTRIBUTES = {"method": "GET", "url": "https://example.org/studies/123/users?k=v"} 9 | 10 | 11 | class RequestSignableTest(unittest.TestCase): 12 | def setUp(self): 13 | self.request_signable = RequestSignable(**REQUEST_ATTRIBUTES) 14 | 15 | def test_string_to_sign_v1(self): 16 | expected = ( 17 | "GET" + "\n" "/studies/123/users" + "\n" "\n" "5ff4257e-9c16-11e0-b048-0026bbfffe5e" + "\n" "1309891855" 18 | ) 19 | 20 | epoch = 1309891855 21 | tested = self.request_signable.string_to_sign_v1({"app_uuid": APP_UUID, "time": epoch}).decode("utf-8") 22 | self.assertEqual(tested, expected) 23 | 24 | def test_string_to_sign_v1_missing_attributes(self): 25 | with self.assertRaises(UnableToSignError) as exc: 26 | RequestSignable(**{}).string_to_sign_v1({}) 27 | self.assertEqual( 28 | str(exc.exception), "Missing required attributes to sign: ['verb', 'request_url', 'app_uuid', 'time']" 29 | ) 30 | 31 | def test_string_to_sign_v2(self): 32 | expected = ( 33 | "GET" + "\n" 34 | "/studies/123/users" + "\n" + sha512("".encode()).hexdigest() + "\n" 35 | "5ff4257e-9c16-11e0-b048-0026bbfffe5e" + "\n" 36 | "1309891855" + "\n" 37 | "k=v" 38 | ) 39 | 40 | epoch = 1309891855 41 | tested = self.request_signable.string_to_sign_v2({"app_uuid": APP_UUID, "time": epoch}).decode("utf-8") 42 | self.assertEqual(tested, expected) 43 | 44 | def test_string_to_sign_v2_missing_attributes(self): 45 | with self.assertRaises(UnableToSignError) as exc: 46 | RequestSignable(**{}).string_to_sign_v2({}) 47 | self.assertEqual( 48 | str(exc.exception), "Missing required attributes to sign: ['verb', 'request_url', 'app_uuid', 'time']" 49 | ) 50 | 51 | def test_encode_query_string(self): 52 | cases = { 53 | "special_characters_in_the_query_string_before_encoding_them": [ 54 | "key=-_.%21%40%23%24%25%5E%2A%28%29%20%7B%7D%7C%3A%22%27%60%3C%3E%3F", 55 | "key=-_.%21%40%23%24%25%5E%2A%28%29%20%7B%7D%7C%3A%22%27%60%3C%3E%3F", 56 | ], 57 | "sort_by_value_if_keys_are_the_same": ["a=b&a=c&a=a", "a=a&a=b&a=c"], 58 | "sort_after_unescaping": ["k=%7E&k=~&k=%40&k=a", "k=%40&k=a&k=~&k=~"], 59 | "unescapes_tilda": ["k=%7E", "k=~"], 60 | "unescapes_plus": ["k=+", "k=%20"], 61 | "empty_values": ["k=&k=v", "k=&k=v"], 62 | "empty_string": ["", ""], 63 | } 64 | 65 | for case_name, case_item in cases.items(): 66 | with self.subTest(case_name=case_name): 67 | self.assertEqual(self.request_signable.encode_query_string(case_item[0]), case_item[1]) 68 | 69 | def test_build_attributes_binary_body(self): 70 | expected = {"verb": "GET", "request_url": "/studies/123/users", "query_string": "", "body": b'{"key": "data"}'} 71 | url = "https://innovate.imedidata.com/studies/123/users" 72 | binary_body = json.dumps({"key": "data"}).encode("utf8") 73 | tested = self.request_signable.build_attributes(method="GET", url=url, body=binary_body) 74 | self.assertEqual(tested, expected) 75 | 76 | def test_normalize_path(self): 77 | cases = { 78 | "self ('.'') in the path": ["/./example/./.", "/example/"], 79 | "parent ('..'') in path": ["/example/sample/..", "/example/"], 80 | "parent ('..') that points to non-existent parent": ["/example/sample/../../../..", "/"], 81 | "case of percent encoded characters": ["/%2b", "/%2B"], 82 | "multiple adjacent slashes to a single slash": ["//example///sample", "/example/sample"], 83 | "preserves trailing slashes": ["/example/", "/example/"], 84 | } 85 | 86 | for case_name, case_item in cases.items(): 87 | with self.subTest(case_name=case_name): 88 | self.assertEqual(self.request_signable.normalize_path(case_item[0]), case_item[1]) 89 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | *************** 3 | 4 | Below you can find some information and examples on getting started using the framework. 5 | 6 | Installation 7 | ------------ 8 | Install using `pip` in the usual way 9 | 10 | Install with pip:: 11 | 12 | $ pip install mauth-client 13 | 14 | Or directly from github with:: 15 | 16 | $ pip install git+https://github.com/mdsol/mauth-client-python.git 17 | 18 | 19 | Simple signing of Requests 20 | -------------------------- 21 | 22 | In order to be able to utilise this you will need to have setup your MAuth Credentials. To do so: 23 | 24 | 1. Generate and register an Application (see :doc:`mauth_setup` for instructions) 25 | 26 | 2. Create a `MAuth` instance using the `mauth_client.requests_mauth.MAuth` class:: 27 | 28 | from mauth_client.requests_mauth import MAuth 29 | 30 | mauth = MAuth(app_uuid='your_app_uuid', private_key_data='your_private_key_data') 31 | 3. Add the `MAuth` instance to your request; this can be done inline with the `requests.verb` action or by using a `requests.Session`:: 32 | 33 | # Using the request authentication request signer inline 34 | response = requests.get('/some/url.json', auth=mauth) 35 | 36 | # Using a requests.Session 37 | client = requests.Session() 38 | client.auth = mauth 39 | response = client.get('/some/url.json') 40 | 41 | See :doc:`examples` for more examples. 42 | 43 | Configuration 44 | ------------- 45 | The module expects to have the following variables passed (both as strings) 46 | * Application UUID - `app_uuid` 47 | * Private Key Data - `private_key_data` 48 | 49 | These are supplied as 12-factor environment variables. 50 | 51 | 52 | Authenticating Incoming Requests in AWS Lambda 53 | ---------------------------------------------- 54 | 55 | 1. Configure the following AWS Lambda environment variables: 56 | 57 | ============== =============================================================== 58 | Key Value 59 | ============== =============================================================== 60 | APP_UUID APP_UUID for the AWS Lambda function 61 | PRIVATE_KEY Encrypted private key for the APP_UUID 62 | MAUTH_URL MAuth service URL (e.g. https://mauth-sandbox.imedidata.net) 63 | ============== =============================================================== 64 | 65 | 2. Create a `LambdaAuthenticator` instance using the `mauth_client.lambda_authenticator.LambdaAuthenticator` class:: 66 | 67 | from mauth_client.lambda_authenticator import LambdaAuthenticator 68 | 69 | lambda_authenticator = LambdaAuthenticator(method, url, headers, body) 70 | 71 | 3. Authenticate incoming request by calling the `is_authentic` method:: 72 | 73 | authentic, status_code, message = lambda_authenticator.is_authentic() 74 | 75 | 76 | Authenticating Incoming Requests in Flask applications 77 | ------------------------------------------------------ 78 | 79 | 1. Configure the following environment variables: 80 | 81 | ============== =============================================================== 82 | Key Value 83 | ============== =============================================================== 84 | APP_UUID APP_UUID for the Flask application 85 | PRIVATE_KEY Encrypted private key for the APP_UUID 86 | MAUTH_URL MAuth service URL (e.g. https://mauth-sandbox.imedidata.net) 87 | ============== =============================================================== 88 | 89 | 2. Create an application instance and initialize it with the flask authenticator:: 90 | 91 | from flask import Flask 92 | from mauth_client.flask_authenticator import FlaskAuthenticator 93 | 94 | app = Flask("Some Sample App") 95 | authenticator = MAuthAuthenticator() 96 | authenticator.init_app(app) 97 | 98 | 3. Specify routes that need to be authenticated using the `requires_authentication` decorator:: 99 | 100 | from flask import Flask 101 | from mauth_client.flask_authenticator import requires_authentication 102 | 103 | @app.route("/some/private/route", methods=["GET"]) 104 | @requires_authentication 105 | def private_route(): 106 | return 'Wibble' 107 | 108 | @app.route("/app_status", methods=["GET"]) 109 | def app_status(): 110 | return 'OK' 111 | -------------------------------------------------------------------------------- /mauth_client/middlewares/asgi.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from asgiref.typing import ( 5 | ASGI3Application, 6 | ASGIReceiveCallable, 7 | ASGIReceiveEvent, 8 | ASGISendCallable, 9 | Scope, 10 | ) 11 | from typing import List, Tuple, Optional 12 | 13 | from mauth_client.authenticator import LocalAuthenticator 14 | from mauth_client.config import Config 15 | from mauth_client.consts import ( 16 | ENV_APP_UUID, 17 | ENV_AUTHENTIC, 18 | ENV_PROTOCOL_VERSION, 19 | ) 20 | from mauth_client.signable import RequestSignable 21 | from mauth_client.signed import Signed 22 | from mauth_client.utils import decode 23 | 24 | logger = logging.getLogger("mauth_asgi") 25 | 26 | 27 | class MAuthASGIMiddleware: 28 | def __init__(self, app: ASGI3Application, exempt: Optional[set] = None) -> None: 29 | self._validate_configs() 30 | self.app = app 31 | self.exempt = exempt.copy() if exempt else set() 32 | 33 | async def __call__( 34 | self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 35 | ) -> None: 36 | if scope["type"] != "http": 37 | return await self.app(scope, receive, send) 38 | 39 | path = scope["path"] 40 | if path in self.exempt: 41 | return await self.app(scope, receive, send) 42 | 43 | query_string = scope["query_string"] 44 | url = f"{path}?{decode(query_string)}" if query_string else path 45 | headers = {decode(k): decode(v) for k, v in scope["headers"]} 46 | 47 | events, body = await self._get_body(receive) 48 | 49 | signable = RequestSignable( 50 | method=scope["method"], 51 | url=url, 52 | body=body, 53 | ) 54 | signed = Signed.from_headers(headers) 55 | authenticator = LocalAuthenticator(signable, signed, logger) 56 | is_authentic, status, message = authenticator.is_authentic() 57 | 58 | if is_authentic: 59 | # asgi spec calls for passing a copy of the scope rather than mutating it 60 | # note: deepcopy will blow up with infi recursion due to objects in some values 61 | scope_copy = scope.copy() 62 | scope_copy[ENV_APP_UUID] = signed.app_uuid 63 | scope_copy[ENV_AUTHENTIC] = True 64 | scope_copy[ENV_PROTOCOL_VERSION] = signed.protocol_version() 65 | await self.app(scope_copy, self._fake_receive(events, receive), send) 66 | else: 67 | await self._send_response(send, status, message) 68 | 69 | def _validate_configs(self) -> None: 70 | # Validate the client settings (APP_UUID, PRIVATE_KEY) 71 | if not all([Config.APP_UUID, Config.PRIVATE_KEY]): 72 | raise TypeError("MAuthASGIMiddleware requires APP_UUID and PRIVATE_KEY") 73 | # Validate the mauth settings (MAUTH_BASE_URL, MAUTH_API_VERSION) 74 | if not all([Config.MAUTH_URL, Config.MAUTH_API_VERSION]): 75 | raise TypeError("MAuthASGIMiddleware requires MAUTH_URL and MAUTH_API_VERSION") 76 | 77 | async def _get_body( 78 | self, receive: ASGIReceiveCallable 79 | ) -> Tuple[List[ASGIReceiveEvent], bytes]: 80 | body = b"" 81 | more_body = True 82 | events = [] 83 | 84 | while more_body: 85 | event = await receive() 86 | body += event.get("body", b"") 87 | more_body = event.get("more_body", False) 88 | events.append(event) 89 | return (events, body) 90 | 91 | async def _send_response(self, send: ASGISendCallable, status: int, msg: str) -> None: 92 | await send({ 93 | "type": "http.response.start", 94 | "status": status, 95 | "headers": [(b"content-type", b"application/json")], 96 | }) 97 | body = {"errors": {"mauth": [msg]}} 98 | await send({ 99 | "type": "http.response.body", 100 | "body": json.dumps(body).encode("utf-8"), 101 | }) 102 | 103 | def _fake_receive(self, events: List[ASGIReceiveEvent], 104 | original_receive: ASGIReceiveCallable) -> ASGIReceiveCallable: 105 | """ 106 | Create a fake receive function that replays cached body events. 107 | 108 | After the middleware consumes request body events for authentication, 109 | this allows downstream apps to also "receive" those events. Once all 110 | cached events are exhausted, delegates to the original receive to 111 | properly forward lifecycle events (like http.disconnect). 112 | 113 | This is essential for long-lived connections (SSE, streaming responses) 114 | that need to detect client disconnects. 115 | """ 116 | events_iter = iter(events) 117 | 118 | async def _receive() -> ASGIReceiveEvent: 119 | try: 120 | return next(events_iter) 121 | except StopIteration: 122 | # After body events are consumed, delegate to original receive 123 | # This allows proper handling of disconnects for SSE connections 124 | return await original_receive() 125 | return _receive 126 | -------------------------------------------------------------------------------- /mauth_client/middlewares/wsgi.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import logging 4 | 5 | from urllib.parse import quote 6 | 7 | from mauth_client.authenticator import LocalAuthenticator 8 | from mauth_client.config import Config 9 | from mauth_client.consts import ( 10 | ENV_APP_UUID, 11 | ENV_AUTHENTIC, 12 | ENV_PROTOCOL_VERSION, 13 | ) 14 | 15 | from mauth_client.signable import RequestSignable 16 | from mauth_client.signed import Signed 17 | 18 | logger = logging.getLogger("mauth_wsgi") 19 | 20 | 21 | class MAuthWSGIMiddleware: 22 | def __init__(self, app, exempt=None): 23 | self._validate_configs() 24 | self.app = app 25 | self.exempt = exempt.copy() if exempt else set() 26 | 27 | def __call__(self, environ, start_response): 28 | path = environ.get("PATH_INFO", "") 29 | 30 | if path in self.exempt: 31 | return self.app(environ, start_response) 32 | 33 | signable = RequestSignable( 34 | method=environ["REQUEST_METHOD"], 35 | url=self._extract_url(environ), 36 | body=self._read_body(environ), 37 | ) 38 | signed = Signed.from_headers(self._extract_headers(environ)) 39 | authenticator = LocalAuthenticator(signable, signed, logger) 40 | is_authentic, code, message = authenticator.is_authentic() 41 | 42 | if is_authentic: 43 | environ[ENV_APP_UUID] = signed.app_uuid 44 | environ[ENV_AUTHENTIC] = True 45 | environ[ENV_PROTOCOL_VERSION] = signed.protocol_version() 46 | return self.app(environ, start_response) 47 | 48 | return self._send_response(code, message, start_response) 49 | 50 | def _validate_configs(self): 51 | # Validate the client settings (APP_UUID, PRIVATE_KEY) 52 | if not all([Config.APP_UUID, Config.PRIVATE_KEY]): 53 | raise TypeError("MAuthWSGIMiddleware requires APP_UUID and PRIVATE_KEY") 54 | # Validate the mauth settings (MAUTH_BASE_URL, MAUTH_API_VERSION) 55 | if not all([Config.MAUTH_URL, Config.MAUTH_API_VERSION]): 56 | raise TypeError("MAuthWSGIMiddleware requires MAUTH_URL and MAUTH_API_VERSION") 57 | 58 | def _read_body(self, environ): 59 | try: 60 | size = int(environ.get("CONTENT_LENGTH", 0)) 61 | except ValueError: 62 | size = 0 63 | 64 | if not size: 65 | return b"" 66 | 67 | body = environ["wsgi.input"].read(size) 68 | 69 | # hack way of "rewinding" body so that downstream can reuse 70 | # 71 | # seek() will not work because production Flask and gunicorn give 72 | # objects without a seek() function and blow up... 73 | # yet humorously Flask in our tests gives a normal BytesIO object 74 | # that does have seek() 75 | # 76 | # NOTE: 77 | # this will not play well with large bodies where this may result in 78 | # blowing out memory, but tbh MAuth is not adequately designed for and 79 | # thus should not be used with large bodies. 80 | environ["wsgi.input"] = io.BytesIO(body) 81 | 82 | return body 83 | 84 | def _extract_headers(self, environ): 85 | """ 86 | Adapted from werkzeug package: https://github.com/pallets/werkzeug 87 | """ 88 | headers = {} 89 | 90 | # don't care to titleize the header keys since 91 | # the Signed class is just going to lowercase them 92 | for k, v in environ.items(): 93 | if k.startswith("HTTP_") and k not in { 94 | "HTTP_CONTENT_TYPE", 95 | "HTTP_CONTENT_LENGTH", 96 | }: 97 | key = k[5:].replace("_", "-") 98 | headers[key] = v 99 | elif k in {"CONTENT_TYPE", "CONTENT_LENGTH"}: 100 | key = k.replace("_", "-") 101 | headers[key] = v 102 | 103 | return headers 104 | 105 | SAFE_CHARS = "!$&'()*+,/:;=@%" 106 | 107 | def _extract_url(self, environ): 108 | """ 109 | Adapted from https://peps.python.org/pep-0333/#url-reconstruction 110 | """ 111 | scheme = environ["wsgi.url_scheme"] 112 | url_parts = [scheme, "://"] 113 | http_host = environ.get("HTTP_HOST") 114 | 115 | if http_host: 116 | url_parts.append(http_host) 117 | else: 118 | url_parts.append(environ["SERVER_NAME"]) 119 | port = environ["SERVER_PORT"] 120 | 121 | if (scheme == "https" and port != 443) or (scheme != "https" and port != 80): 122 | url_parts.append(f":{port}") 123 | 124 | url_parts.append( 125 | quote(environ.get("SCRIPT_NAME", ""), safe=self.SAFE_CHARS) 126 | ) 127 | url_parts.append( 128 | quote(environ.get("PATH_INFO", ""), safe=self.SAFE_CHARS) 129 | ) 130 | 131 | qs = environ.get("QUERY_STRING") 132 | if qs: 133 | url_parts.append(f"?{quote(qs, safe=self.SAFE_CHARS)}") 134 | 135 | return "".join(url_parts) 136 | 137 | _STATUS_STRS = { 138 | 401: "401 Unauthorized", 139 | 500: "500 Internal Server Error", 140 | } 141 | 142 | def _send_response(self, code, msg, start_response): 143 | status = self._STATUS_STRS[code] 144 | body = {"errors": {"mauth": [msg]}} 145 | body_bytes = json.dumps(body).encode("utf-8") 146 | 147 | headers = [ 148 | ("Content-Type", "application/json"), 149 | ("Content-Length", str(len(body_bytes))), 150 | ] 151 | start_response(status, headers) 152 | 153 | return [body_bytes] 154 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file does only contain a selection of the most common options. For a 4 | # full list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('..')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'Mauth Client for Python' 21 | copyright = '2019, Medidata' 22 | author = 'Medidata' 23 | 24 | 25 | def get_version(): 26 | """ 27 | Get the Product version 28 | :return: 29 | """ 30 | import re 31 | init = open('../mauth_client/__init__.py').read() 32 | release = re.search("__version__ = '([^']+)'", init).group(1) 33 | return release, '.'.join(release.split('.')[0:1]) 34 | 35 | 36 | # The short X.Y version 37 | version = get_version()[1] 38 | # The full version, including alpha/beta/rc tags 39 | release = get_version()[0] 40 | 41 | 42 | # -- General configuration --------------------------------------------------- 43 | 44 | # If your documentation needs a minimal Sphinx version, state it here. 45 | # 46 | # needs_sphinx = '1.0' 47 | 48 | # Add any Sphinx extension module names here, as strings. They can be 49 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 50 | # ones. 51 | extensions = [ 52 | 'sphinx.ext.autodoc', 53 | 'sphinx.ext.doctest', 54 | 'sphinx.ext.todo', 55 | 'sphinx.ext.coverage', 56 | 'sphinx.ext.mathjax', 57 | 'sphinx.ext.ifconfig', 58 | 'sphinx.ext.viewcode', 59 | 'sphinx.ext.githubpages', 60 | ] 61 | 62 | # Add any paths that contain templates here, relative to this directory. 63 | templates_path = ['_templates'] 64 | 65 | # The suffix(es) of source filenames. 66 | # You can specify multiple suffix as a list of string: 67 | # 68 | # source_suffix = ['.rst', '.md'] 69 | source_suffix = '.rst' 70 | 71 | # The master toctree document. 72 | master_doc = 'index' 73 | 74 | # The language for content autogenerated by Sphinx. Refer to documentation 75 | # for a list of supported languages. 76 | # 77 | # This is also used if you do content translation via gettext catalogs. 78 | # Usually you set "language" from the command line for these cases. 79 | language = None 80 | 81 | # List of patterns, relative to source directory, that match files and 82 | # directories to ignore when looking for source files. 83 | # This pattern also affects html_static_path and html_extra_path . 84 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 85 | 86 | # The name of the Pygments (syntax highlighting) style to use. 87 | pygments_style = 'sphinx' 88 | 89 | 90 | # -- Options for HTML output ------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | # 95 | html_theme = 'sphinxdoc' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | # 101 | # html_theme_options = {} 102 | 103 | # Add any paths that contain custom static files (such as style sheets) here, 104 | # relative to this directory. They are copied after the builtin static files, 105 | # so a file named "default.css" will overwrite the builtin "default.css". 106 | html_static_path = ['_static'] 107 | 108 | # Custom sidebar templates, must be a dictionary that maps document names 109 | # to template names. 110 | # 111 | # The default sidebars (for documents that don't match any pattern) are 112 | # defined by theme itself. Builtin themes are using these templates by 113 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 114 | # 'searchbox.html']``. 115 | # 116 | # html_sidebars = {} 117 | html_logo = '_static/mdsol.png' 118 | 119 | 120 | # -- Options for HTMLHelp output --------------------------------------------- 121 | 122 | # Output file base name for HTML help builder. 123 | htmlhelp_basename = 'MauthClientforPythondoc' 124 | 125 | 126 | # -- Options for LaTeX output ------------------------------------------------ 127 | 128 | latex_elements = { 129 | # The paper size ('letterpaper' or 'a4paper'). 130 | # 131 | # 'papersize': 'letterpaper', 132 | 133 | # The font size ('10pt', '11pt' or '12pt'). 134 | # 135 | # 'pointsize': '10pt', 136 | 137 | # Additional stuff for the LaTeX preamble. 138 | # 139 | # 'preamble': '', 140 | 141 | # Latex figure (float) alignment 142 | # 143 | # 'figure_align': 'htbp', 144 | } 145 | 146 | # Grouping the document tree into LaTeX files. List of tuples 147 | # (source start file, target name, title, 148 | # author, documentclass [howto, manual, or own class]). 149 | latex_documents = [ 150 | (master_doc, 'MauthClientforPythondoc.tex', 'Mauth Client for Python Documentation', 151 | author, 'manual'), 152 | ] 153 | 154 | 155 | # -- Options for manual page output ------------------------------------------ 156 | 157 | # One entry per manual page. List of tuples 158 | # (source start file, name, description, authors, manual section). 159 | man_pages = [ 160 | (master_doc, 'MauthClientforPythondoc', 'Mauth Client for Python Documentation', 161 | [author], 1) 162 | ] 163 | 164 | 165 | # -- Options for Texinfo output ---------------------------------------------- 166 | 167 | # Grouping the document tree into Texinfo files. List of tuples 168 | # (source start file, target name, title, author, 169 | # dir menu entry, description, category) 170 | texinfo_documents = [ 171 | (master_doc, 'MauthClientforPythondoc', 'Mauth Client for Python Documentation', 172 | author, 'MauthClientforPythondoc', 'One line description of project.', 173 | 'Miscellaneous'), 174 | ] 175 | 176 | 177 | # -- Extension configuration ------------------------------------------------- 178 | 179 | # -- Options for todo extension ---------------------------------------------- 180 | 181 | # If true, `todo` and `todoList` produce output, else they produce nothing. 182 | todo_include_todos = True 183 | -------------------------------------------------------------------------------- /mauth_client/signable.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import posixpath 3 | import re 4 | from urllib.parse import quote, unquote_plus, urlparse 5 | from .utils import hexdigest, make_bytes 6 | from .exceptions import UnableToSignError 7 | 8 | 9 | class Signable(ABC): 10 | """ 11 | Makes a signature string to sign 12 | """ 13 | 14 | def __init__(self, **kwargs): 15 | """ 16 | Create a new Signable instance 17 | 18 | :param dict attributes_for_signing: Attributes to generate a signature string 19 | """ 20 | self.name = self.__class__.__name__.replace("Signable", "").lower() 21 | self.attributes_for_signing = self.build_attributes(**kwargs) 22 | 23 | def string_to_sign_v1(self, override_attributes): 24 | """ 25 | Composes a string suitable for private-key signing from the SIGNATURE_COMPONENTS keys of 26 | attributes for signing, which are themselves taken from attributes_for_signing and 27 | the given argument override_attributes. 28 | 29 | The string to sign for V1 protocol will be (where LF is line feed character) for requests:: 30 | 31 | string_to_sign = 32 | http_verb + + 33 | resource_url_path (no host, port or query string; first "/" is included) + + 34 | request_body + + 35 | app_uuid + + 36 | current_seconds_since_epoch 37 | 38 | :param dict override_attributes: Additional attributes to generate a signature string 39 | """ 40 | attributes_for_signing = {**self.attributes_for_signing, **override_attributes} 41 | missing_attributes = [ 42 | k for k in self.SIGNATURE_COMPONENTS if (not attributes_for_signing.get(k) and k != "body") 43 | ] 44 | 45 | if missing_attributes: 46 | raise UnableToSignError("Missing required attributes to sign: {}".format(missing_attributes)) 47 | 48 | return b"\n".join([make_bytes(attributes_for_signing.get(k, "")) for k in self.SIGNATURE_COMPONENTS]) 49 | 50 | def string_to_sign_v2(self, override_attributes): 51 | """ 52 | Composes a string suitable for private-key signing from the SIGNATURE_COMPONENTS_V2 keys of 53 | attributes for signing, which are themselves taken from attributes_for_signing and 54 | the given argument override_attributes 55 | 56 | The string to sign for V2 protocol will be (where LF is line feed character) for requests:: 57 | 58 | string_to_sign = 59 | http_verb + + 60 | resource_url_path (no host, port or query string; first "/" is included) + + 61 | request_body_digest + + 62 | app_uuid + + 63 | current_seconds_since_epoch + + 64 | encoded_query_params 65 | 66 | :param dict override_attributes: Additional attributes to generate a signature string 67 | """ 68 | 69 | # memoization of body_digest 70 | # note that if :body is None we hash an empty string ("") 71 | if "body_digest" not in self.attributes_for_signing: 72 | body_digest = hexdigest(self.attributes_for_signing.get("body", "")) 73 | self.attributes_for_signing["body_digest"] = body_digest 74 | 75 | attrs_with_overrides = {**self.attributes_for_signing, **override_attributes} 76 | encoded_query_params = self.encode_query_string(attrs_with_overrides.get("query_string")) 77 | attrs_with_overrides["encoded_query_params"] = encoded_query_params 78 | attrs_with_overrides["request_url"] = self.normalize_path(attrs_with_overrides["request_url"]) 79 | 80 | missing_attributes = [ 81 | k for k in self.SIGNATURE_COMPONENTS_V2 if (not attrs_with_overrides.get(k) and k != "encoded_query_params") 82 | ] 83 | 84 | if missing_attributes: 85 | raise UnableToSignError("Missing required attributes to sign: {}".format(missing_attributes)) 86 | 87 | return b"\n".join([make_bytes(attrs_with_overrides.get(k, "")) for k in self.SIGNATURE_COMPONENTS_V2]) 88 | 89 | @staticmethod 90 | def normalize_path(path): 91 | if not path: 92 | return "" 93 | 94 | # Normalize `.` and `..` in path 95 | # i.e. /./example => /example ; /example/.. => / 96 | resolved = re.sub("//+" , "/", posixpath.normpath(path)) 97 | # Normalize percent encoding to uppercase i.e. %cf%80 => %CF%80 98 | normalized = re.sub(r"(%[a-f0-9]{2})", lambda match: match.group(1).upper(), resolved) 99 | # Preserve trailing slash 100 | return normalized + "/" if len(normalized) > 1 and path.endswith(("/", "/.", "/..")) else normalized 101 | 102 | def encode_query_string(self, query_string): 103 | """ 104 | Sorts query string parameters by codepoint, uri encodes keys and values, 105 | and rejoins parameters into a query string 106 | """ 107 | if query_string: 108 | return "&".join([self.encode_query_parameter(param) for param in self.sort_unescape_params(query_string)]) 109 | 110 | return "" 111 | 112 | @staticmethod 113 | def sort_unescape_params(query_string): 114 | return sorted( 115 | [ 116 | [unquote_plus(part[0]), unquote_plus(part[2])] 117 | for part in [param.partition("=") for param in query_string.split("&")] 118 | ] 119 | ) 120 | 121 | @classmethod 122 | def encode_query_parameter(cls, param): 123 | return "{}={}".format(cls.quote_unescape_tilde(param[0]), cls.quote_unescape_tilde(param[1])) 124 | 125 | @staticmethod 126 | def quote_unescape_tilde(string): 127 | """ 128 | The urllib.parse.quote method changed in 3.7 to not escape tildes. 129 | We replace tilde encoding back to tildes to account for older Pythons. 130 | (See: https://docs.python.org/3/library/urllib.parse.html#url-quoting) 131 | """ 132 | return quote(string).replace("%7E", "~") 133 | 134 | @abstractmethod 135 | def build_attributes(self, **kwargs): 136 | pass 137 | 138 | 139 | class RequestSignable(Signable): 140 | """ 141 | Makes a signature string for signing a request 142 | """ 143 | 144 | SIGNATURE_COMPONENTS = ["verb", "request_url", "body", "app_uuid", "time"] 145 | SIGNATURE_COMPONENTS_V2 = ["verb", "request_url", "body_digest", "app_uuid", "time", "encoded_query_params"] 146 | 147 | def build_attributes(self, **kwargs): 148 | body = kwargs.get("body") or "" 149 | parsed = urlparse(kwargs.get("url"), allow_fragments=False) 150 | return {"verb": kwargs.get("method"), "request_url": parsed.path, "query_string": parsed.query, "body": body} 151 | -------------------------------------------------------------------------------- /tests/middlewares/wsgi_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | from unittest.mock import patch 4 | 5 | from flask import Flask, request, jsonify 6 | from uuid import uuid4 7 | 8 | from mauth_client.authenticator import LocalAuthenticator 9 | from mauth_client.config import Config 10 | from mauth_client.consts import ( 11 | AUTH_HEADER_DELIMITER, 12 | X_MWS_AUTH, 13 | MWS_TOKEN, 14 | MCC_AUTH, 15 | MWSV2_TOKEN, 16 | ENV_APP_UUID, 17 | ENV_AUTHENTIC, 18 | ENV_PROTOCOL_VERSION, 19 | ) 20 | from mauth_client.middlewares import MAuthWSGIMiddleware 21 | 22 | 23 | class TestMAuthWSGIMiddlewareInitialization(unittest.TestCase): 24 | def setUp(self): 25 | self.app = Flask("Test App") 26 | Config.APP_UUID = "2f746447-c212-483c-9eec-d9b0216f7613" 27 | Config.MAUTH_URL = "https://mauth.com" 28 | Config.MAUTH_API_VERSION = "v1" 29 | Config.PRIVATE_KEY = "key" 30 | 31 | def test_app_configuration(self): 32 | try: 33 | self.app.wsgi_app = MAuthWSGIMiddleware(self.app) 34 | except TypeError: 35 | self.fail("Shouldn't raise exception") 36 | 37 | def test_app_configuration_missing_uuid(self): 38 | Config.APP_UUID = None 39 | with self.assertRaises(TypeError) as exc: 40 | self.app.wsgi_app = MAuthWSGIMiddleware(self.app) 41 | self.assertEqual( 42 | str(exc.exception), 43 | "MAuthWSGIMiddleware requires APP_UUID and PRIVATE_KEY" 44 | ) 45 | 46 | def test_app_configuration_missing_key(self): 47 | Config.PRIVATE_KEY = None 48 | with self.assertRaises(TypeError) as exc: 49 | self.app.wsgi_app = MAuthWSGIMiddleware(self.app) 50 | self.assertEqual( 51 | str(exc.exception), 52 | "MAuthWSGIMiddleware requires APP_UUID and PRIVATE_KEY" 53 | ) 54 | 55 | def test_app_configuration_missing_url(self): 56 | Config.MAUTH_URL = None 57 | with self.assertRaises(TypeError) as exc: 58 | self.app.wsgi_app = MAuthWSGIMiddleware(self.app) 59 | self.assertEqual( 60 | str(exc.exception), 61 | "MAuthWSGIMiddleware requires MAUTH_URL and MAUTH_API_VERSION" 62 | ) 63 | 64 | def test_app_configuration_missing_version(self): 65 | Config.MAUTH_API_VERSION = None 66 | with self.assertRaises(TypeError) as exc: 67 | self.app.wsgi_app = MAuthWSGIMiddleware(self.app) 68 | self.assertEqual( 69 | str(exc.exception), 70 | "MAuthWSGIMiddleware requires MAUTH_URL and MAUTH_API_VERSION" 71 | ) 72 | 73 | 74 | class TestMAuthWSGIMiddlewareFunctionality(unittest.TestCase): 75 | def setUp(self): 76 | self.app_uuid = str(uuid4()) 77 | Config.APP_UUID = self.app_uuid 78 | Config.MAUTH_URL = "https://mauth.com" 79 | Config.MAUTH_API_VERSION = "v1" 80 | Config.PRIVATE_KEY = "key" 81 | 82 | self.app = Flask("Test App") 83 | self.app.wsgi_app = MAuthWSGIMiddleware( 84 | self.app.wsgi_app, 85 | exempt={"/app_status"}, 86 | ) 87 | 88 | @self.app.get("/") 89 | def root(): 90 | return "authenticated!" 91 | 92 | @self.app.get("/app_status") 93 | def app_status(): 94 | return "open" 95 | 96 | self.client = self.app.test_client() 97 | 98 | def test_401_response_when_not_authenticated(self): 99 | response = self.client.get("/") 100 | 101 | self.assertEqual(response.status_code, 401) 102 | self.assertEqual(response.headers["Content-Length"], "151") 103 | self.assertEqual(response.json, { 104 | "errors": { 105 | "mauth": [( 106 | "Authentication Failed. No mAuth signature present; " 107 | "X-MWS-Authentication header is blank, " 108 | "MCC-Authentication header is blank." 109 | )] 110 | } 111 | }) 112 | 113 | def test_ok_when_calling_open_route(self): 114 | response = self.client.get("/app_status") 115 | 116 | self.assertEqual(response.status_code, 200) 117 | self.assertEqual(response.get_data(as_text=True), "open") 118 | 119 | @patch.object(LocalAuthenticator, "is_authentic") 120 | def test_ok_when_authenticated(self, is_authentic_mock): 121 | is_authentic_mock.return_value = (True, 200, "") 122 | 123 | response = self.client.get("/") 124 | 125 | self.assertEqual(response.status_code, 200) 126 | self.assertEqual(response.get_data(as_text=True), "authenticated!") 127 | 128 | @patch.object(LocalAuthenticator, "is_authentic") 129 | def test_adds_values_to_context_v1(self, is_authentic_mock): 130 | is_authentic_mock.return_value = (True, 200, "") 131 | 132 | headers_v1 = { 133 | X_MWS_AUTH: f"{MWS_TOKEN} {self.app_uuid}:blah" 134 | } 135 | 136 | @self.app.get("/v1_test") 137 | def v1_test(): 138 | return jsonify({ 139 | "app_uuid": request.environ[ENV_APP_UUID], 140 | "authentic": request.environ[ENV_AUTHENTIC], 141 | "protocol": request.environ[ENV_PROTOCOL_VERSION], 142 | }) 143 | 144 | response = self.client.get("/v1_test", headers=headers_v1) 145 | 146 | self.assertEqual(response.status_code, 200) 147 | self.assertEqual(response.json, { 148 | "app_uuid": self.app_uuid, 149 | "authentic": True, 150 | "protocol": 1, 151 | }) 152 | 153 | @patch.object(LocalAuthenticator, "is_authentic") 154 | def test_adds_values_to_context_v2(self, is_authentic_mock): 155 | is_authentic_mock.return_value = (True, 200, "") 156 | 157 | headers_v2 = { 158 | MCC_AUTH: f"{MWSV2_TOKEN} {self.app_uuid}:blah{AUTH_HEADER_DELIMITER}" 159 | } 160 | 161 | @self.app.get("/v2_test") 162 | def v2_test(): 163 | return jsonify({ 164 | "app_uuid": request.environ[ENV_APP_UUID], 165 | "authentic": request.environ[ENV_AUTHENTIC], 166 | "protocol": request.environ[ENV_PROTOCOL_VERSION], 167 | }) 168 | 169 | response = self.client.get("/v2_test", headers=headers_v2) 170 | 171 | self.assertEqual(response.status_code, 200) 172 | self.assertEqual(response.json, { 173 | "app_uuid": self.app_uuid, 174 | "authentic": True, 175 | "protocol": 2, 176 | }) 177 | 178 | @patch.object(LocalAuthenticator, "is_authentic") 179 | def test_downstream_can_receive_body(self, is_authentic_mock): 180 | is_authentic_mock.return_value = (True, 200, "") 181 | body = {"msg": "helloes"} 182 | 183 | @self.app.post("/post_test") 184 | def post_test(): 185 | return jsonify(request.json) 186 | 187 | response = self.client.post( 188 | "/post_test", 189 | data=json.dumps(body), 190 | headers={"content-type": "application/json"}, 191 | ) 192 | 193 | self.assertEqual(response.status_code, 200) 194 | self.assertEqual(response.json, body) 195 | -------------------------------------------------------------------------------- /tests/signer_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import datetime, timezone 3 | import os 4 | from freezegun import freeze_time 5 | from mauth_client.signable import RequestSignable 6 | from mauth_client.signer import Signer 7 | 8 | APP_UUID = "5ff4257e-9c16-11e0-b048-0026bbfffe5e" 9 | EPOCH = "1309891855" # 2011-07-05 18:50:00 UTC 10 | EPOCH_DATETIME = datetime.fromtimestamp(float(EPOCH), timezone.utc) 11 | REQUEST_ATTRIBUTES = {"method": "GET", "url": "https://example.org/studies/123/users?k=v"} 12 | ADDITIONAL_ATTRIBUTES = {"app_uuid": APP_UUID, "time": EPOCH} 13 | 14 | with open(os.path.join(os.path.dirname(__file__), "blank.jpeg"), "rb") as binary_file: 15 | BINARY_FILE_BODY = binary_file.read() 16 | 17 | REQUEST_ATTRIBUTES_WITH_BINARY_BODY = { 18 | "method": "PUT", 19 | "url": "https://example.org/v1/pictures?key=-_.~!@#$%^*()+{}|:\"'`<>?&∞=v&キ=v&0=v&a=v&a=b&a=c&a=a&k=&k=v", 20 | "body": BINARY_FILE_BODY, 21 | } 22 | 23 | 24 | class SignerTest(unittest.TestCase): 25 | def setUp(self): 26 | with open(os.path.join(os.path.dirname(__file__), "keys", "fake_mauth.priv.key"), "r") as key_file: 27 | self.private_key = key_file.read() 28 | self.signer = Signer(APP_UUID, self.private_key, "v1,v2") 29 | self.signer_v1_only = Signer(APP_UUID, self.private_key, "v1") 30 | self.signer_v2_only = Signer(APP_UUID, self.private_key, "v2") 31 | self.signable = RequestSignable(**REQUEST_ATTRIBUTES) 32 | self.signable_with_binary_body = RequestSignable(**REQUEST_ATTRIBUTES_WITH_BINARY_BODY) 33 | 34 | @freeze_time(EPOCH_DATETIME) 35 | def test_signed_headers(self): 36 | expected = { 37 | "X-MWS-Authentication": r"\AMWS {}:".format(APP_UUID), 38 | "X-MWS-Time": EPOCH, 39 | "MCC-Authentication": r"MWSV2 {}:[^;]*;".format(APP_UUID), 40 | "MCC-Time": EPOCH, 41 | } 42 | 43 | signed_headers = self.signer.signed_headers(self.signable) 44 | self.assertEqual(signed_headers.keys(), expected.keys()) 45 | self.assertRegex(signed_headers["X-MWS-Authentication"], expected["X-MWS-Authentication"]) 46 | self.assertRegex(signed_headers["MCC-Authentication"], expected["MCC-Authentication"]) 47 | self.assertEqual(signed_headers["X-MWS-Time"], expected["X-MWS-Time"]) 48 | self.assertEqual(signed_headers["MCC-Time"], expected["MCC-Time"]) 49 | 50 | @freeze_time(EPOCH_DATETIME) 51 | def test_signed_headers_v1_only(self): 52 | expected = {"X-MWS-Authentication": r"\AMWS {}:".format(APP_UUID), "X-MWS-Time": EPOCH} 53 | 54 | signed_headers = self.signer_v1_only.signed_headers(self.signable) 55 | self.assertEqual(signed_headers.keys(), expected.keys()) 56 | self.assertRegex(signed_headers["X-MWS-Authentication"], expected["X-MWS-Authentication"]) 57 | self.assertEqual(signed_headers["X-MWS-Time"], expected["X-MWS-Time"]) 58 | 59 | @freeze_time(EPOCH_DATETIME) 60 | def test_signed_headers_v2_only(self): 61 | expected = {"MCC-Authentication": r"MWSV2 {}:[^;]*;".format(APP_UUID), "MCC-Time": EPOCH} 62 | 63 | signed_headers = self.signer_v2_only.signed_headers(self.signable) 64 | self.assertEqual(signed_headers.keys(), expected.keys()) 65 | self.assertRegex(signed_headers["MCC-Authentication"], expected["MCC-Authentication"]) 66 | self.assertEqual(signed_headers["MCC-Time"], expected["MCC-Time"]) 67 | 68 | def test_signature_v1(self): 69 | tested = self.signer.signature_v1("Hello world") 70 | self.assertEqual( 71 | tested, 72 | "1oTyoecqng4TE7ycGoW6qFMSPpA4C9TiZVDANHN4T/76LxtcCqmTTn9VCsVIDRWGKl3O5EzJEUYIfbI2QjsMdxtOk1BmMJspX08nAhRxZA" 73 | "j3urNaBDkKPKmCiDgpaBNwJHlAVPi9LuVun6rFqRASkjz7jDTt+EVgrWHnJxcikXYMx32VYFteQXPQNpYmPqrduJVuadcgCZWqBqVWGVHR" 74 | "pRdb2OXYPkJ3FEnvPZtSnufcgrticJBD5PDY6LKYmhNwgvVOXjSPRDxsDnqc5fSn4+zQYAZHo4ZbarRpPoj9C+YXp+BDb8gfm7wyuwKLSt" 75 | "UE5cck4dbWae+Vvle5QrObNw==", 76 | ) 77 | 78 | def test_signature_v1_unicode(self): 79 | tested = self.signer.signature_v1("こんにちはÆ") 80 | self.assertEqual( 81 | tested, 82 | "F7t8/AJCbGFDbIsE41u0CqsT4VB2lm0hXlQdCw2Io/5fBjJOGMZTiHEUj604YSb/zWKgFZYYUNpY+aVXZH7EjkB/Lg1l8MIid1OMV9Ok/U" 83 | "bhMzvcPrHoi8DqOzvbx/+be4hN9GpDiY5woBak2E7NgI0x8sagpUXjMqnRR47O3PCLsE0x0PjkSGztWFt2aRWYSlRASi96Z8ESLhF76KbI" 84 | "G7iekW54/EusK+qGA3sewlWbCuBisVBoF8yRtukwq065vz7VZx1GPNGbmB+MF6uGvxh+hhcYbq/kbcuHoAtqrp0oJJqXRbvPzrUZKZW86O" 85 | "tQzekMkzapDDMfJhE0V+SxNw==", 86 | ) 87 | 88 | def test_signature_v2(self): 89 | tested = self.signer.signature_v2("Hello world") 90 | self.assertEqual( 91 | tested, 92 | "G7jZk1nf5kd+oOzHfMsTS18pNkZea22pT6XsJaH5XCKqP4tYoua5isDWtipagwmjveEr3dG2tUC9KwiOLDGO30xiO4fdZwhyUb3mBrtELC" 93 | "rBz0nXoH7BlhV4LmRVtiPtVwLHauRb01KglPx0WoyuOEbrCO4ikwls75s/wv22Xk6kVFYx2y1r+HQWpeqQETarQs/x/2W610TqDjNdXU0V" 94 | "FRKJ8w0ERWlt5lJGBhp0zaoguyyVMvC8fjNHFORNIZHYVd0DOQAOlHmJD+0JdNo+2qcrA2d3G4+vc/pWRV+lI2buudyOGSnURZhKan/S0j" 95 | "Ue9yF2tS+3wXulqfLM3pFhwA==", 96 | ) 97 | 98 | def test_signature_v2_unicode(self): 99 | tested = self.signer.signature_v2("こんにちはÆ") 100 | self.assertEqual( 101 | tested, 102 | "eHvTMmEH31a9Tz6ZikHNUQPtii5iSjbkukQcFflQR6BtWL+HlZGgyjcL8jOT9oVMxkFV2eITrBA4hBPGznJlQ22yRca82tcOBKznllqTPT" 103 | "0vk8t2oX4ruPjFO1vaw/Eiko3r29+VflYibAEmP5m+SqhUZn5BWeDlFAkp6UqVOtfQzX7I6J/M7tsgw8PZQp6FUUDtXPSLFAkIPpcW/wND" 104 | "siV5wjlQzdlDAMc+Onc0lMFUcG0uH2W3ciUe5I2+ID4EvuprEUFDy8FYzXativ9p3k5TGtt7u0BXd39ll4r7p6pdby6+JgFjT2ITg3N5iC" 105 | "q17UFV5tFUABZ3dak/wT0apA==", 106 | ) 107 | 108 | def test_signature_v1_binary_body(self): 109 | string_to_sign_v1 = self.signable_with_binary_body.string_to_sign_v1(ADDITIONAL_ATTRIBUTES) 110 | tested = self.signer.signature_v1(string_to_sign_v1) 111 | self.assertEqual( 112 | tested, 113 | "19C27KyNwGA3KByjpQi7MssyDGBAha4ByuPmIobaZ9PRnXa42ZD1njD5ZQVuNMDHtL+Zfo851UGmPphaqgJeSK4niqUOM2dhwMuj6QAE+z" 114 | "0IFfhJvIXrIp1FAavMSlrdeDRqsVWjlwfoZeqY3HJk1vfY+7YMYApIPagmZH/3OoSB84k3o6WYplGtT8KvKRi8GDlq6D+gLLtAo9ocgQAO" 115 | "OhSzNyCowNcMUKXq8LlVXFguekawC8oEz+zJ0zJhDh9NnXMfp3fIg0a2MBDZhQSRLFUo/AMczZBGMl63nIQWq029/0f3xdiiQf3Trv4wBS" 116 | "zCiMSnPMg4uOjfDZY0tMR1JA==", 117 | ) 118 | 119 | def test_signature_v2_binary_body(self): 120 | string_to_sign_v2 = self.signable_with_binary_body.string_to_sign_v2(ADDITIONAL_ATTRIBUTES) 121 | tested = self.signer.signature_v2(string_to_sign_v2) 122 | self.assertEqual( 123 | tested, 124 | "s9cqo1kIqiw9lvCxXq2ObAIJOU/m0tap79ox8mvKKS8QabGvIJblwRn5YiUwYb2VHix0q3teU4+CYuLe5+wuxhwtraAfNwZQt0eIfyO3AX" 125 | "Q001BVaROq75GW7bEFKoy0TOx4dgaFTHTs56Pr6A3cC4IPGBpV5Utlx6ck0Wd6u6rU7BDtZLawVl6wg3fvXn23iFP1D0QwouldyCtL9y9E" 126 | "TjWzTnFSz9cRPrZ4dzKyVeUwsCCGSkcYTz+jYTfvsv51OVOdxaTscyGWyTC2V4QRScONESHZ7Yhs8C6YgTgMdtNGyozqHreLB4ptP2HdII" 127 | "a7Nv2jIZUozyjkED+G0OEisA==", 128 | ) 129 | 130 | def test_sign_versions(self): 131 | signer = Signer(APP_UUID, self.private_key, "v1, V2,v777") 132 | self.assertEqual(signer.sign_versions, ["v1", "v2", "v777"]) 133 | 134 | def test_sign_versions_bad_version(self): 135 | with self.assertRaises(ValueError) as exc: 136 | Signer(APP_UUID, self.private_key, "v1,vv2") 137 | self.assertEqual( 138 | str(exc.exception), "SIGN_VERSIONS must be comma-separated MAuth protocol versions (e.g. 'v1,v2')" 139 | ) 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MAuth Client Python 2 | 3 | MAuth Client Python is an authentication library to manage the information needed to both sign and authenticate requests and responses for Medidata's MAuth authentication system. 4 | 5 | 6 | ## Pre-requisites 7 | 8 | To use MAuth Authenticator you will need: 9 | 10 | * An MAuth app ID 11 | * An MAuth private key (with the public key registered with Medidata's MAuth server) 12 | 13 | 14 | ## Installation 15 | 16 | To resolve packages using pip, add the following to ~/.pip/pip.conf: 17 | ``` 18 | [global] 19 | index-url = https://:@mdsol.jfrog.io/mdsol/api/pypi/pypi-packages/simple/ 20 | ``` 21 | 22 | Install using pip: 23 | ``` 24 | $ pip install mauth-client 25 | ``` 26 | 27 | Or directly from GitHub: 28 | ``` 29 | $ pip install git+https://github.com/mdsol/mauth-client-python.git 30 | ``` 31 | 32 | This will also install the dependencies. 33 | 34 | To resolve using a requirements file, the index URL can be specified in the first line of the file: 35 | ``` 36 | --index-url https://:@mdsol.jfrog.io/mdsol/api/pypi/pypi-packages/simple/ 37 | mauth-client== 38 | ``` 39 | 40 | ## Usage 41 | 42 | ### Signing Outgoing Requests 43 | 44 | #### With [Requests library](https://requests.readthedocs.io/en/latest/) 45 | 46 | ```python 47 | import requests 48 | from mauth_client.requests_mauth import MAuth 49 | 50 | # MAuth configuration 51 | APP_UUID = "" 52 | private_key = open("private.key", "r").read() 53 | mauth = MAuth(APP_UUID, private_key) 54 | 55 | # Call an MAuth protected resource, in this case an iMedidata API 56 | # listing the studies for a particular user 57 | user_uuid = "10ac3b0e-9fe2-11df-a531-12313900d531" 58 | url = "https://innovate.imedidata.com/api/v2/users/{}/studies.json".format(user_uuid) 59 | 60 | # Make the requests call, passing the auth client 61 | result = requests.get(url, auth=mauth) 62 | 63 | # Print results 64 | if result.status_code == 200: 65 | print([r["uuid"] for r in result.json()["studies"]]) 66 | print(result.text) 67 | ``` 68 | 69 | #### With [HTTPX](https://www.python-httpx.org/) library 70 | 71 | ```python 72 | import httpx 73 | from mauth_client.httpx_mauth import MAuthHttpx 74 | 75 | # MAuth configuration 76 | APP_UUID = "" 77 | private_key = open("private.key", "r").read() 78 | 79 | auth = MAuthHttpx(app_uuid=APP_UUID, private_key_data=private_key) 80 | client = httpx.Client(auth=auth) 81 | response = client.get("https://api.example.com/endpoint") 82 | ``` 83 | 84 | The following variables can be configured in the environment variables: 85 | 86 | | Key | Value | 87 | | ------------------------------------ | ---------------------------------- | 88 | | `APP_UUID` or `MAUTH_APP_UUID` | APP_UUID for signing requests | 89 | | `PRIVATE_KEY` or `MAUTH_PRIVATE_KEY` | MAuth private key for the APP_UUID | 90 | 91 | The `mauth_sign_versions` option can be set as an environment variable to specify protocol versions to sign outgoing requests: 92 | 93 | | Key | Value | 94 | | --------------------- | ------------------------------------------------------------------------------------ | 95 | | `MAUTH_SIGN_VERSIONS` | **(optional)** Comma-separated protocol versions to sign requests. Defaults to `v1`. | 96 | 97 | This option can also be passed to the constructor: 98 | 99 | ```python 100 | mauth_sign_versions = "v1,v2" 101 | mauth = MAuth(APP_UUID, private_key, mauth_sign_versions) 102 | 103 | auth = MAuthHttpx(app_uuid=APP_UUID, private_key_data=private_key, sign_versions=mauth_sign_versions) 104 | ``` 105 | 106 | 107 | ### Authenticating Incoming Requests 108 | 109 | MAuth Client Python supports AWS Lambda functions and Flask applications to authenticate MAuth signed requests. 110 | 111 | The following variables are **required** to be configured in the environment variables: 112 | 113 | | Key | Value | 114 | | ------------------------------------ | ------------------------------------------------------------- | 115 | | `APP_UUID` or `MAUTH_APP_UUID` | APP_UUID for the AWS Lambda function | 116 | | `PRIVATE_KEY` or `MAUTH_PRIVATE_KEY` | Encrypted private key for the APP_UUID | 117 | | `MAUTH_URL` | MAuth service URL (e.g. https://mauth-innovate.imedidata.com) | 118 | 119 | 120 | The following variables can optionally be set in the environment variables: 121 | 122 | | Key | Value | 123 | | ---------------------- | ----------------------------------------------------------------------------------------- | 124 | | `MAUTH_API_VERSION` | **(optional)** MAuth API version. Only `v1` exists as of this writing. Defaults to `v1`. | 125 | | `MAUTH_MODE` | **(optional)** Method to authenticate requests. `local` or `remote`. Defaults to `local`. | 126 | | `V2_ONLY_AUTHENTICATE` | **(optional)** Authenticate requests with only V2. Defaults to `False`. | 127 | 128 | 129 | #### AWS Lambda functions 130 | 131 | ```python 132 | from mauth_client.lambda_authenticator import LambdaAuthenticator 133 | 134 | authenticator = LambdaAuthenticator(method, url, headers, body) 135 | authentic, status_code, message = authenticator.is_authentic() 136 | app_uuid = authenticator.get_app_uuid() 137 | ``` 138 | 139 | #### WSGI Applications 140 | 141 | To apply to a WSGI application you should use the `MAuthWSGIMiddleware`. You 142 | can make certain paths exempt from authentication by passing the `exempt` 143 | option with a set of paths to exempt. 144 | 145 | Here is an example for Flask. Note that requesting app's UUID and the 146 | protocol version will be added to the request environment for successfully 147 | authenticated requests. 148 | 149 | ```python 150 | from flask import Flask, request, jsonify 151 | from mauth_client.consts import ENV_APP_UUID, ENV_PROTOCOL_VERSION 152 | from mauth_client.middlewares import MAuthWSGIMiddleware 153 | 154 | app = Flask("MyApp") 155 | app.wsgi_app = MAuthWSGIMiddleware(app.wsgi_app, exempt={"/app_status"}) 156 | 157 | @app.get("/") 158 | def root(): 159 | return jsonify({ 160 | "msg": "authenticated", 161 | "app_uuid": request.environ[ENV_APP_UUID], 162 | "protocol_version": request.environ[ENV_PROTOCOL_VERSION], 163 | }) 164 | 165 | @app.get("/app_status") 166 | return "this route is exempt from authentication" 167 | ``` 168 | 169 | #### ASGI Applications 170 | 171 | To apply to an ASGI application you should use the `MAuthASGIMiddleware`. You 172 | can make certain paths exempt from authentication by passing the `exempt` 173 | option with a set of paths to exempt. 174 | 175 | Here is an example for FastAPI. Note that requesting app's UUID and the 176 | protocol version will be added to the ASGI `scope` for successfully 177 | authenticated requests. 178 | 179 | ```python 180 | from fastapi import FastAPI, Request 181 | from mauth_client.consts import ENV_APP_UUID, ENV_PROTOCOL_VERSION 182 | from mauth_client.middlewares import MAuthASGIMiddleware 183 | 184 | app = FastAPI() 185 | app.add_middleware(MAuthASGIMiddleware, exempt={"/app_status"}) 186 | 187 | @app.get("/") 188 | async def root(request: Request): 189 | return { 190 | "msg": "authenticated", 191 | "app_uuid": request.scope[ENV_APP_UUID], 192 | "protocol_version": request.scope[ENV_PROTOCOL_VERSION], 193 | } 194 | 195 | @app.get("/app_status") 196 | async def app_status(): 197 | return { 198 | "msg": "this route is exempt from authentication", 199 | } 200 | ``` 201 | 202 | ## Contributing 203 | 204 | See [CONTRIBUTING](CONTRIBUTING.md) 205 | -------------------------------------------------------------------------------- /mauth_client/authenticator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import base64 3 | import datetime 4 | import requests 5 | from .config import Config 6 | from .consts import MWS_TOKEN, MWSV2_TOKEN 7 | from .exceptions import InauthenticError, MAuthNotPresent, MissingV2Error, UnableToAuthenticateError 8 | from .lambda_helper import generate_mauth 9 | from .rsa_verifier import RSAVerifier 10 | from .utils import make_bytes 11 | 12 | 13 | class AbstractAuthenticator(ABC): 14 | ALLOWED_DRIFT_SECONDS = 300 15 | AUTHENTICATION_TYPE = None 16 | 17 | @abstractmethod 18 | def __init__(self, signable, signed, logger): 19 | self.signable = signable 20 | self.signed = signed 21 | self.logger = logger 22 | self.rsa_verifier = None # Lazy loading 23 | 24 | def is_authentic(self): 25 | self._log_authentication_request() 26 | try: 27 | self._authenticate() 28 | except (MAuthNotPresent, MissingV2Error) as exc: 29 | self.logger.error("mAuth signature not present on %s. Exception: %s", self.signable.name, str(exc)) 30 | return False, 401, str(exc) 31 | except InauthenticError as exc: 32 | self.logger.error( 33 | "mAuth signature authentication failed for %s. " "Exception: %s", self.signable.name, str(exc) 34 | ) 35 | return False, 401, str(exc) 36 | except UnableToAuthenticateError as exc: 37 | self.logger.error(str(exc)) 38 | return False, 500, str(exc) 39 | return True, 200, "" 40 | 41 | def _log_authentication_request(self): 42 | signed_app_uuid = self.signed.app_uuid if self.signed.app_uuid else "[none provided]" 43 | signed_token = self.signed.token if self.signed.token else "[none provided]" 44 | self.logger.info( 45 | "Mauth-client attempting to authenticate request from app with mauth" 46 | " app uuid %s to app with mauth app uuid %s" 47 | " using version %s.", 48 | signed_app_uuid, 49 | Config.APP_UUID, 50 | signed_token, 51 | ) 52 | 53 | # raises InauthenticError unless the given object is authentic. Will only 54 | # authenticate with v2 if the environment variable V2_ONLY_AUTHENTICATE 55 | # is set. Otherwise will fallback to v1 when v2 authentication fails 56 | def _authenticate(self): 57 | if self.signed.protocol_version() == 2: 58 | try: 59 | self._authenticate_v2() 60 | except InauthenticError: 61 | if Config.V2_ONLY_AUTHENTICATE: 62 | raise 63 | 64 | self.signed.fall_back_to_mws_signature_info() 65 | if not self.signed.signature: 66 | raise 67 | 68 | self._log_authentication_request() 69 | self._authenticate_v1() 70 | self.logger.warning("Completed successful authentication attempt after fallback to v1") 71 | 72 | elif self.signed.protocol_version() == 1: 73 | if Config.V2_ONLY_AUTHENTICATE: 74 | # If v2 is required but not present and v1 is present we raise MissingV2Error 75 | msg = ( 76 | "This service requires mAuth v2 mcc-authentication header " 77 | "but only v1 x-mws-authentication is present" 78 | ) 79 | raise MissingV2Error(msg) 80 | 81 | self._authenticate_v1() 82 | 83 | else: 84 | sub_str = "" if Config.V2_ONLY_AUTHENTICATE else "X-MWS-Authentication header is blank, " 85 | msg = "Authentication Failed. No mAuth signature present; " "{}MCC-Authentication header is blank.".format( 86 | sub_str 87 | ) 88 | raise MAuthNotPresent(msg) 89 | 90 | return True 91 | 92 | # V1 helpers 93 | def _authenticate_v1(self): 94 | self._time_valid_v1() 95 | self._token_valid_v1() 96 | self._signature_valid_v1() 97 | 98 | def _time_valid_v1(self): 99 | if not self.signed.x_mws_time: 100 | raise InauthenticError("Time verification failed. No X-MWS-Time present.") 101 | 102 | if not str(self.signed.x_mws_time).isdigit(): 103 | raise InauthenticError("Time verification failed. X-MWS-Time header format incorrect.") 104 | 105 | self._time_within_valid_range(self.signed.x_mws_time) 106 | 107 | def _token_valid_v1(self): 108 | if not self.signed.token == MWS_TOKEN: 109 | msg = "Token verification failed. Expected {}; token was {}.".format(MWS_TOKEN, self.signed.token) 110 | raise InauthenticError(msg) 111 | 112 | @abstractmethod 113 | def _signature_valid_v1(self): 114 | pass 115 | 116 | # V2 helpers 117 | def _authenticate_v2(self): 118 | self._time_valid_v2() 119 | self._token_valid_v2() 120 | self._signature_valid_v2() 121 | 122 | def _time_valid_v2(self): 123 | if not self.signed.mcc_time: 124 | raise InauthenticError("Time verification failed. No MCC-Time present.") 125 | 126 | if not str(self.signed.mcc_time).isdigit(): 127 | raise InauthenticError("Time verification failed. MCC-Time header format incorrect.") 128 | 129 | self._time_within_valid_range(self.signed.mcc_time) 130 | 131 | def _token_valid_v2(self): 132 | if not self.signed.token == MWSV2_TOKEN: 133 | msg = "Token verification failed. Expected {}.".format(MWSV2_TOKEN) 134 | raise InauthenticError(msg) 135 | 136 | @abstractmethod 137 | def _signature_valid_v2(self): 138 | pass 139 | 140 | def _time_within_valid_range(self, signature_timestamp): 141 | """ 142 | Is the time of the request within the allowed drift? 143 | """ 144 | now = datetime.datetime.now() 145 | # this needs a float 146 | signature_time = datetime.datetime.fromtimestamp(float(signature_timestamp)) 147 | if now > signature_time + datetime.timedelta(seconds=self.ALLOWED_DRIFT_SECONDS): 148 | msg = "Time verification failed. {} not within {}s of {}".format( 149 | signature_time, self.ALLOWED_DRIFT_SECONDS, now.strftime("%Y-%m-%d %H:%M:%S") 150 | ) 151 | raise InauthenticError(msg) 152 | 153 | @property 154 | def authenticator_type(self): 155 | return self.AUTHENTICATION_TYPE 156 | 157 | 158 | class LocalAuthenticator(AbstractAuthenticator): 159 | """ 160 | Local Authentication object, authenticates the request locally, retrieving the necessary credentials from the 161 | upstream MAuth Server 162 | """ 163 | 164 | AUTHENTICATION_TYPE = "LOCAL" 165 | 166 | def __init__(self, signable, signed, logger): 167 | super().__init__(signable, signed, logger) 168 | 169 | def _signature_valid_v1(self): 170 | if not self.rsa_verifier: 171 | self.rsa_verifier = RSAVerifier(self.signed.app_uuid) 172 | 173 | expected = self.signable.string_to_sign_v1({"time": self.signed.x_mws_time, "app_uuid": self.signed.app_uuid}) 174 | if not self.rsa_verifier.verify_v1(expected, self.signed.signature): 175 | msg = "Signature verification failed for {}.".format(self.signable.name) 176 | raise InauthenticError(msg) 177 | 178 | def _signature_valid_v2(self): 179 | if not self.rsa_verifier: 180 | self.rsa_verifier = RSAVerifier(self.signed.app_uuid) 181 | 182 | expected = self.signable.string_to_sign_v2({"time": self.signed.mcc_time, "app_uuid": self.signed.app_uuid}) 183 | if not self.rsa_verifier.verify_v2(expected, self.signed.signature): 184 | msg = "Signature verification failed for {}.".format(self.signable.name) 185 | raise InauthenticError(msg) 186 | 187 | 188 | class RemoteAuthenticator(AbstractAuthenticator): 189 | """ 190 | Remote Authentication object, passes through the authentication to the upstream MAuth Server 191 | """ 192 | 193 | AUTHENTICATION_TYPE = "REMOTE" 194 | _MAUTH = None 195 | 196 | def __init__(self, signable, signed, logger): 197 | if not self._MAUTH: 198 | self._MAUTH = { 199 | "auth": generate_mauth(), 200 | "url": "{}/mauth/{}/authentication_tickets.json".format(Config.MAUTH_URL, Config.MAUTH_API_VERSION), 201 | } 202 | 203 | super().__init__(signable, signed, logger) 204 | 205 | def _signature_valid_v1(self): 206 | self._make_mauth_request(self._build_authentication_ticket(self.signed.x_mws_time)) 207 | 208 | def _signature_valid_v2(self): 209 | self._make_mauth_request( 210 | self._build_authentication_ticket( 211 | self.signed.mcc_time, 212 | {"query_string": self.signable.attributes_for_signing["query_string"], "token": self.signed.token}, 213 | ) 214 | ) 215 | 216 | def _build_authentication_ticket(self, request_time, additional_attributes=None): 217 | if not additional_attributes: 218 | additional_attributes = {} 219 | 220 | binary_body = make_bytes(self.signable.attributes_for_signing.get("body", "")) 221 | return { 222 | "verb": self.signable.attributes_for_signing["verb"], 223 | "app_uuid": self.signed.app_uuid, 224 | "client_signature": self.signed.signature, 225 | "request_url": self.signable.attributes_for_signing["request_url"], 226 | "request_time": request_time, 227 | "b64encoded_body": base64.b64encode(binary_body).decode("utf-8"), 228 | **additional_attributes, 229 | } 230 | 231 | def _make_mauth_request(self, authentication_ticket): 232 | response = requests.post( 233 | self._MAUTH["url"], json=dict(authentication_ticket=authentication_ticket), auth=self._MAUTH["auth"] 234 | ) 235 | 236 | if 200 <= response.status_code <= 299: 237 | return True 238 | 239 | # the mAuth service responds with 412 when the given request is not authentically signed. 240 | # older versions of the mAuth service respond with 404 when the given app_uuid 241 | # does not exist, which is also considered to not be authentically signed. newer 242 | # versions of the service respond 412 in all cases, so the 404 check may be removed 243 | # when the old version of the mAuth service is out of service. 244 | error_class = InauthenticError if response.status_code in (412, 404) else UnableToAuthenticateError 245 | raise error_class("The mAuth service responded with {}: {}".format(response.status_code, response.text)) 246 | -------------------------------------------------------------------------------- /tests/middlewares/asgi_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from fastapi import FastAPI, Request 3 | from fastapi.testclient import TestClient 4 | from fastapi.websockets import WebSocket 5 | from unittest.mock import AsyncMock 6 | from unittest.mock import patch 7 | from uuid import uuid4 8 | 9 | from mauth_client.authenticator import LocalAuthenticator 10 | from mauth_client.config import Config 11 | from mauth_client.consts import ( 12 | AUTH_HEADER_DELIMITER, 13 | X_MWS_AUTH, 14 | MWS_TOKEN, 15 | MCC_AUTH, 16 | MWSV2_TOKEN, 17 | ENV_APP_UUID, 18 | ENV_AUTHENTIC, 19 | ENV_PROTOCOL_VERSION, 20 | ) 21 | from mauth_client.middlewares import MAuthASGIMiddleware 22 | 23 | 24 | class TestMAuthASGIMiddlewareInitialization(unittest.TestCase): 25 | def setUp(self): 26 | self.app = FastAPI() 27 | Config.APP_UUID = str(uuid4()) 28 | Config.MAUTH_URL = "https://mauth.com" 29 | Config.MAUTH_API_VERSION = "v1" 30 | Config.PRIVATE_KEY = "key" 31 | 32 | def test_app_configuration(self): 33 | try: 34 | self.app.add_middleware(MAuthASGIMiddleware) 35 | self.app.build_middleware_stack() 36 | except TypeError: 37 | self.fail("Shouldn't raise exception") 38 | 39 | def test_app_configuration_missing_uuid(self): 40 | Config.APP_UUID = None 41 | with self.assertRaises(TypeError) as exc: 42 | self.app.add_middleware(MAuthASGIMiddleware) 43 | self.app.build_middleware_stack() 44 | self.assertEqual( 45 | str(exc.exception), 46 | "MAuthASGIMiddleware requires APP_UUID and PRIVATE_KEY" 47 | ) 48 | 49 | def test_app_configuration_missing_key(self): 50 | Config.PRIVATE_KEY = None 51 | with self.assertRaises(TypeError) as exc: 52 | self.app.add_middleware(MAuthASGIMiddleware) 53 | self.app.build_middleware_stack() 54 | self.assertEqual( 55 | str(exc.exception), 56 | "MAuthASGIMiddleware requires APP_UUID and PRIVATE_KEY" 57 | ) 58 | 59 | def test_app_configuration_missing_url(self): 60 | Config.MAUTH_URL = None 61 | with self.assertRaises(TypeError) as exc: 62 | self.app.add_middleware(MAuthASGIMiddleware) 63 | self.app.build_middleware_stack() 64 | self.assertEqual( 65 | str(exc.exception), 66 | "MAuthASGIMiddleware requires MAUTH_URL and MAUTH_API_VERSION" 67 | ) 68 | 69 | def test_app_configuration_missing_version(self): 70 | Config.MAUTH_API_VERSION = None 71 | with self.assertRaises(TypeError) as exc: 72 | self.app.add_middleware(MAuthASGIMiddleware) 73 | self.app.build_middleware_stack() 74 | self.assertEqual( 75 | str(exc.exception), 76 | "MAuthASGIMiddleware requires MAUTH_URL and MAUTH_API_VERSION" 77 | ) 78 | 79 | 80 | class TestMAuthASGIMiddlewareFunctionality(unittest.TestCase): 81 | def setUp(self): 82 | self.app_uuid = str(uuid4()) 83 | Config.APP_UUID = self.app_uuid 84 | Config.MAUTH_URL = "https://mauth.com" 85 | Config.MAUTH_API_VERSION = "v1" 86 | Config.PRIVATE_KEY = "key" 87 | 88 | self.app = FastAPI() 89 | self.app.add_middleware(MAuthASGIMiddleware, exempt={"/app_status"}) 90 | 91 | @self.app.get("/") 92 | async def root(): 93 | return {"msg": "authenticated"} 94 | 95 | @self.app.get("/app_status") 96 | async def app_status(): 97 | return {"msg": "open"} 98 | 99 | self.client = TestClient(self.app) 100 | 101 | def test_401_reponse_when_not_authenticated(self): 102 | response = self.client.get("/") 103 | 104 | self.assertEqual(response.status_code, 401) 105 | self.assertEqual(response.json(), { 106 | "errors": { 107 | "mauth": [( 108 | "Authentication Failed. No mAuth signature present; " 109 | "X-MWS-Authentication header is blank, " 110 | "MCC-Authentication header is blank." 111 | )] 112 | } 113 | }) 114 | 115 | def test_ok_when_calling_open_route(self): 116 | response = self.client.get("/app_status") 117 | 118 | self.assertEqual(response.status_code, 200) 119 | self.assertEqual(response.json(), {"msg": "open"}) 120 | 121 | @patch.object(LocalAuthenticator, "is_authentic") 122 | def test_ok_when_authenticated(self, is_authentic_mock): 123 | is_authentic_mock.return_value = (True, 200, "") 124 | 125 | response = self.client.get("/") 126 | 127 | self.assertEqual(response.status_code, 200) 128 | self.assertEqual(response.json(), {"msg": "authenticated"}) 129 | 130 | @patch.object(LocalAuthenticator, "is_authentic") 131 | def test_adds_values_to_context_v1(self, is_authentic_mock): 132 | is_authentic_mock.return_value = (True, 200, "") 133 | 134 | headers_v1 = { 135 | X_MWS_AUTH: f"{MWS_TOKEN} {self.app_uuid}:blah" 136 | } 137 | 138 | @self.app.get("/v1_test") 139 | def root(request: Request): 140 | self.assertEqual(request.scope[ENV_APP_UUID], self.app_uuid) 141 | self.assertEqual(request.scope[ENV_AUTHENTIC], True) 142 | self.assertEqual(request.scope[ENV_PROTOCOL_VERSION], 1) 143 | return {"msg": "got it"} 144 | 145 | self.client.get("/v1_test", headers=headers_v1) 146 | 147 | @patch.object(LocalAuthenticator, "is_authentic") 148 | def test_adds_values_to_context_v2(self, is_authentic_mock): 149 | is_authentic_mock.return_value = (True, 200, "") 150 | 151 | headers_v2 = { 152 | MCC_AUTH: f"{MWSV2_TOKEN} {self.app_uuid}:blah{AUTH_HEADER_DELIMITER}" 153 | } 154 | 155 | @self.app.get("/v2_test") 156 | def root(request: Request): 157 | self.assertEqual(request.scope[ENV_APP_UUID], self.app_uuid) 158 | self.assertEqual(request.scope[ENV_AUTHENTIC], True) 159 | self.assertEqual(request.scope[ENV_PROTOCOL_VERSION], 2) 160 | return {"msg": "got it"} 161 | 162 | self.client.get("/v2_test", headers=headers_v2) 163 | 164 | @patch.object(LocalAuthenticator, "is_authentic") 165 | def test_downstream_can_receive_body(self, is_authentic_mock): 166 | is_authentic_mock.return_value = (True, 200, "") 167 | expected_body = {"msg": "test"} 168 | 169 | @self.app.post("/post_test") 170 | async def post_test(request: Request): 171 | body = await request.json() 172 | self.assertEqual(body, expected_body) 173 | return {"msg": "app can still read the body!"} 174 | 175 | self.client.post("/post_test", json=expected_body) 176 | 177 | def test_ignores_non_http_requests(self): 178 | @self.app.websocket_route("/ws") 179 | async def ws(websocket: WebSocket): 180 | await websocket.accept() 181 | await websocket.send_json({"msg": "helloes"}) 182 | await websocket.close() 183 | 184 | with self.client.websocket_connect("/ws") as websocket: 185 | data = websocket.receive_json() 186 | self.assertEqual(data, {"msg": "helloes"}) 187 | 188 | 189 | class TestMAuthASGIMiddlewareInSubApplication(unittest.TestCase): 190 | def setUp(self): 191 | self.app_uuid = str(uuid4()) 192 | Config.APP_UUID = self.app_uuid 193 | Config.MAUTH_URL = "https://mauth.com" 194 | Config.MAUTH_API_VERSION = "v1" 195 | Config.PRIVATE_KEY = "key" 196 | 197 | self.app = FastAPI() 198 | sub_app = FastAPI() 199 | sub_app.add_middleware(MAuthASGIMiddleware) 200 | 201 | @sub_app.get("/path") 202 | async def sub_app_path(): 203 | return {"msg": "sub app path"} 204 | 205 | self.app.mount("/sub_app", sub_app) 206 | 207 | self.client = TestClient(self.app) 208 | 209 | @patch.object(LocalAuthenticator, "is_authentic", autospec=True) 210 | def test_includes_base_application_path_in_signature_verification(self, is_authentic_mock): 211 | request_url = None 212 | 213 | def is_authentic_effect(self): 214 | nonlocal request_url 215 | request_url = self.signable.attributes_for_signing["request_url"] 216 | return True, 200, "" 217 | 218 | is_authentic_mock.side_effect = is_authentic_effect 219 | 220 | self.client.get("/sub_app/path") 221 | 222 | self.assertEqual(request_url, "/sub_app/path") 223 | 224 | 225 | class TestMAuthASGIMiddlewareInLongLivedConnections(unittest.IsolatedAsyncioTestCase): 226 | def setUp(self): 227 | self.app = FastAPI() 228 | Config.APP_UUID = str(uuid4()) 229 | Config.MAUTH_URL = "https://mauth.com" 230 | Config.MAUTH_API_VERSION = "v1" 231 | Config.PRIVATE_KEY = "key" 232 | 233 | @patch.object(LocalAuthenticator, "is_authentic") 234 | async def test_fake_receive_delegates_to_original_after_body_consumed(self, is_authentic_mock): 235 | """Test that after body events are consumed, _fake_receive delegates to original receive""" 236 | is_authentic_mock.return_value = (True, 200, "") 237 | 238 | # Track that original receive was called after body events exhausted 239 | call_order = [] 240 | 241 | async def mock_app(scope, receive, send): 242 | # First receive should get body event 243 | event1 = await receive() 244 | call_order.append(("body", event1["type"])) 245 | 246 | # Second receive should delegate to original receive 247 | event2 = await receive() 248 | call_order.append(("disconnect", event2["type"])) 249 | 250 | await send({"type": "http.response.start", "status": 200, "headers": []}) 251 | await send({"type": "http.response.body", "body": b""}) 252 | 253 | middleware = MAuthASGIMiddleware(mock_app) 254 | 255 | # Mock receive that returns body then disconnect 256 | receive_calls = 0 257 | 258 | async def mock_receive(): 259 | nonlocal receive_calls 260 | receive_calls += 1 261 | if receive_calls == 1: 262 | return {"type": "http.request", "body": b"test", "more_body": False} 263 | return {"type": "http.disconnect"} 264 | 265 | send_mock = AsyncMock() 266 | scope = { 267 | "type": "http", 268 | "method": "POST", 269 | "path": "/test", 270 | "query_string": b"", 271 | "headers": [] 272 | } 273 | 274 | await middleware(scope, mock_receive, send_mock) 275 | 276 | # Verify events were received in correct order 277 | self.assertEqual(len(call_order), 2) 278 | self.assertEqual(call_order[0], ("body", "http.request")) 279 | self.assertEqual(call_order[1], ("disconnect", "http.disconnect")) 280 | self.assertEqual(receive_calls, 2) # Called once for auth, once from app 281 | -------------------------------------------------------------------------------- /tests/authenticator_test.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import unittest 3 | import copy 4 | import logging 5 | from unittest.mock import MagicMock 6 | from io import StringIO 7 | import pytest 8 | import dateutil 9 | import requests_mock 10 | 11 | from mauth_client.authenticator import AbstractAuthenticator, LocalAuthenticator, RemoteAuthenticator 12 | from mauth_client.config import Config 13 | from mauth_client.signable import RequestSignable 14 | from mauth_client.signed import Signed 15 | from mauth_client.key_holder import KeyHolder 16 | from mauth_client.exceptions import InauthenticError, UnableToAuthenticateError, MAuthNotPresent 17 | 18 | from tests.common import load_key 19 | 20 | AUTHENTICATOR_APP_UUID = "2f746447-c212-483c-9eec-d9b0216f7613" 21 | APP_UUID = "f5af50b2-bf7d-4c29-81db-76d086d4808a" 22 | URL = "https://api_gateway.com/sandbox/path" 23 | UTC = dateutil.tz.tzutc() 24 | EPOCH = "1500854400" # 2017-07-24 09:00:00 UTC 25 | EPOCH_DATETIME = datetime.fromtimestamp(float(EPOCH), tz=UTC) 26 | BODY = "こんにちはÆ" 27 | 28 | X_MWS_SIGNATURE = ( 29 | "p0SNltF6B4G5z+nVNbLv2XCEdouimo/ECQ/Sum6YM+QgE1/LZLXY+hAcwe/TkaC/2d8I3Zot37Xgob3cftgSf9S1fPAi3euN0Fm" 30 | "v/OEkfUmsYvmqyOXawEWGpevoEX6KNpEAUrt48hFGomsWRgbEEjuUtN4iiPe9y3HlIjumUmDrM499RZxgZdyOhqtLVOv5ngNShDbFv2Ll" 31 | "jITl4sO0f7zU8wAYGfxLEPXvp8qgnzQ6usZwrD2ujSmXbZtksqgG1R0Vmb7LAd6P+uvtRkw8kGLz/wWwxRweSGliX/IwovGi/bMIIClDD" 32 | "faUAY9QDjcU1x7i0Yy1IEyQYyCWcnL1rA==" 33 | ) 34 | X_MWS_AUTHENTICATION = "MWS {}:{}".format(APP_UUID, X_MWS_SIGNATURE) 35 | X_MWS_HEADERS = {"X-MWS-Time": EPOCH, "X-MWS-Authentication": X_MWS_AUTHENTICATION} 36 | 37 | MWSV2_SIGNATURE = ( 38 | "Ub8CWA4rIWsG62PbzKeP33pBDXDk+yY5l3XdI35NSrS7LlwJMQ78C5y+yIAsDAZL3RqZTAd8zQJKdh3s1JXdd3ccc/hoJfs3B31" 39 | "qCzZffx685QoVpl+Az2AJHvGzOUcZi55ZsvArvdlTikNH7dVz3+K5y5Q5/c2i2D5CBiqD+76zRy6R43BoxxD9flVwhy6PCdgfygegyZo2" 40 | "g5F7MEgAH/Qvpc6omoVxkbGUmMdWbu00CkfVYh511L4RYss9lLMdd84/2OhV/uG/JtObSJuf5dObvAwKNwqxcmuuAVOE7Bo/qtUL5XBIl" 41 | "Kmst1b9CjoRn2sZzd/alvZtTdFqdC7DeQ==" 42 | ) 43 | MWSV2_AUTHENTICATION = "MWSV2 {}:{};".format(APP_UUID, MWSV2_SIGNATURE) 44 | MWSV2_HEADERS = {"MCC-Time": EPOCH, "MCC-Authentication": MWSV2_AUTHENTICATION} 45 | 46 | MAUTH_AUTHENTICATION_URL = "https://mauth.com/mauth/v1/security_tokens/authentication_tickets.json" 47 | 48 | 49 | class MockAuthenticator(AbstractAuthenticator): 50 | def __init__(self, headers, v2_only_authenticate=False, method="POST"): 51 | Config.V2_ONLY_AUTHENTICATE = v2_only_authenticate 52 | signable = RequestSignable(method=method, url=URL, body=BODY) 53 | super().__init__(signable, Signed.from_headers(headers), logging.getLogger()) 54 | 55 | def _signature_valid_v1(self): 56 | return True 57 | 58 | def _signature_valid_v2(self): 59 | return True 60 | 61 | 62 | class TestAuthenticator(unittest.TestCase): 63 | def setUp(self): 64 | Config.APP_UUID = AUTHENTICATOR_APP_UUID 65 | 66 | # redirect the output of stdout to self.captor 67 | self.captor = StringIO() 68 | self.logger = logging.getLogger() 69 | self.logger_handlers = self.logger.handlers 70 | self.logger.handlers = [logging.StreamHandler(self.captor)] 71 | 72 | self.v1_headers = copy.deepcopy(X_MWS_HEADERS) 73 | self.v2_headers = copy.deepcopy(MWSV2_HEADERS) 74 | 75 | self.mock_authenticator = None 76 | 77 | def tearDown(self): 78 | Config.V2_ONLY_AUTHENTICATE = False 79 | 80 | def test_is_authentic(self): 81 | self.logger.setLevel(logging.INFO) 82 | self.mock_authenticator = MockAuthenticator(self.v1_headers) 83 | self.mock_authenticator._authenticate = MagicMock(return_value=True) 84 | authentic, status, message = self.mock_authenticator.is_authentic() 85 | 86 | self.assertTrue(authentic) 87 | self.assertEqual(status, 200) 88 | self.assertEqual(message, "") 89 | 90 | self.assertEqual( 91 | self.captor.getvalue(), 92 | "Mauth-client attempting to authenticate request from app with mauth" 93 | " app uuid {} to app with mauth app uuid {}" 94 | " using version MWS.\n".format(APP_UUID, AUTHENTICATOR_APP_UUID), 95 | ) 96 | 97 | def test_is_authentic_mauth_not_present(self): 98 | self.logger.setLevel(logging.ERROR) 99 | self.mock_authenticator = MockAuthenticator({}) 100 | authentic, status, message = self.mock_authenticator.is_authentic() 101 | expected = ( 102 | "Authentication Failed. No mAuth signature present; " 103 | "X-MWS-Authentication header is blank, MCC-Authentication header is blank." 104 | ) 105 | 106 | self.assertFalse(authentic) 107 | self.assertEqual(status, 401) 108 | self.assertEqual(message, expected) 109 | self.assertEqual( 110 | self.captor.getvalue(), "mAuth signature not present on request. Exception: {}\n".format(expected) 111 | ) 112 | 113 | def test_is_authentic_v2_only_with_v1_headers(self): 114 | self.logger.setLevel(logging.ERROR) 115 | self.mock_authenticator = MockAuthenticator(self.v1_headers, True) 116 | authentic, status, message = self.mock_authenticator.is_authentic() 117 | expected = ( 118 | "This service requires mAuth v2 mcc-authentication header " "but only v1 x-mws-authentication is present" 119 | ) 120 | 121 | self.assertFalse(authentic) 122 | self.assertEqual(status, 401) 123 | self.assertEqual(message, expected) 124 | self.assertEqual( 125 | self.captor.getvalue(), "mAuth signature not present on request. Exception: {}\n".format(expected) 126 | ) 127 | 128 | def test_is_authentic_inauthentic_error(self): 129 | self.logger.setLevel(logging.ERROR) 130 | self.mock_authenticator = MockAuthenticator(self.v1_headers) 131 | self.mock_authenticator._authenticate = MagicMock(side_effect=InauthenticError("Boom!")) 132 | authentic, status, message = self.mock_authenticator.is_authentic() 133 | 134 | self.assertFalse(authentic) 135 | self.assertEqual(status, 401) 136 | self.assertEqual(message, "Boom!") 137 | 138 | self.assertEqual( 139 | self.captor.getvalue(), "mAuth signature authentication failed for request. Exception: Boom!\n" 140 | ) 141 | 142 | def test_is_authentic_unable_to_authenticate_error(self): 143 | self.logger.setLevel(logging.ERROR) 144 | self.mock_authenticator = MockAuthenticator(self.v1_headers) 145 | self.mock_authenticator._authenticate = MagicMock(side_effect=UnableToAuthenticateError("Boom!")) 146 | authentic, status, message = self.mock_authenticator.is_authentic() 147 | 148 | self.assertFalse(authentic) 149 | self.assertEqual(status, 500) 150 | self.assertEqual(message, "Boom!") 151 | 152 | self.assertEqual(self.captor.getvalue(), "Boom!\n") 153 | 154 | def test_authenticate_signature_missing(self): 155 | with self.assertRaises(MAuthNotPresent) as exc: 156 | MockAuthenticator({})._authenticate() 157 | self.assertEqual( 158 | str(exc.exception), 159 | "Authentication Failed. No mAuth signature present; " 160 | "X-MWS-Authentication header is blank, MCC-Authentication header is blank.", 161 | ) 162 | 163 | def test_time_valid_v1_missing_header(self): 164 | del self.v1_headers["X-MWS-Time"] 165 | with self.assertRaises(InauthenticError) as exc: 166 | MockAuthenticator(self.v1_headers)._authenticate() 167 | self.assertEqual(str(exc.exception), "Time verification failed. No X-MWS-Time present.") 168 | 169 | def test_time_valid_v1_bad_header(self): 170 | self.v1_headers["X-MWS-Time"] = "apple" 171 | with self.assertRaises(InauthenticError) as exc: 172 | MockAuthenticator(self.v1_headers)._authenticate() 173 | self.assertEqual(str(exc.exception), "Time verification failed. X-MWS-Time header format incorrect.") 174 | 175 | @pytest.mark.freeze_time(EPOCH_DATETIME) 176 | def test_token_valid_v1_bad_token(self): 177 | self.v1_headers["X-MWS-Authentication"] = "RWS {}:{}".format(APP_UUID, X_MWS_SIGNATURE) 178 | with self.assertRaises(InauthenticError) as exc: 179 | MockAuthenticator(self.v1_headers)._authenticate() 180 | self.assertEqual(str(exc.exception), "Token verification failed. Expected MWS; token was RWS.") 181 | 182 | @pytest.mark.freeze_time(EPOCH_DATETIME + timedelta(minutes=5, seconds=1)) 183 | def test_time_valid_v1_expired_header(self): 184 | with self.assertRaises(InauthenticError) as exc: 185 | MockAuthenticator(self.v1_headers)._authenticate() 186 | self.assertEqual( 187 | str(exc.exception), 188 | "Time verification failed. {} " 189 | "not within {}s of {}".format( 190 | datetime.fromtimestamp(int(EPOCH)), MockAuthenticator.ALLOWED_DRIFT_SECONDS, datetime.now() 191 | ), 192 | ) 193 | 194 | def test_time_valid_v2_missing_header(self): 195 | del self.v2_headers["MCC-Time"] 196 | with self.assertRaises(InauthenticError) as exc: 197 | MockAuthenticator(self.v2_headers)._authenticate() 198 | self.assertEqual(str(exc.exception), "Time verification failed. No MCC-Time present.") 199 | 200 | def test_time_valid_v2_bad_header(self): 201 | self.v2_headers["MCC-Time"] = "apple" 202 | with self.assertRaises(InauthenticError) as exc: 203 | MockAuthenticator(self.v2_headers)._authenticate() 204 | self.assertEqual(str(exc.exception), "Time verification failed. MCC-Time header format incorrect.") 205 | 206 | @pytest.mark.freeze_time(EPOCH_DATETIME) 207 | def test_token_valid_v2_bad_token(self): 208 | self.v2_headers["MCC-Authentication"] = "RWS {}:{}".format(APP_UUID, X_MWS_SIGNATURE) 209 | with self.assertRaises(InauthenticError) as exc: 210 | MockAuthenticator(self.v2_headers)._authenticate() 211 | self.assertEqual(str(exc.exception), "Token verification failed. Expected MWSV2.") 212 | 213 | @pytest.mark.freeze_time(EPOCH_DATETIME + timedelta(minutes=50, seconds=1)) 214 | def test_time_valid_v2_expired_header(self): 215 | with self.assertRaises(InauthenticError) as exc: 216 | MockAuthenticator(self.v2_headers)._authenticate() 217 | self.assertEqual( 218 | str(exc.exception), 219 | "Time verification failed. {} " 220 | "not within {}s of {}".format( 221 | datetime.fromtimestamp(int(EPOCH)), MockAuthenticator.ALLOWED_DRIFT_SECONDS, datetime.now() 222 | ), 223 | ) 224 | 225 | @pytest.mark.freeze_time(EPOCH_DATETIME) 226 | def test_fallback_to_v1_when_v2_fails(self): 227 | self.logger.setLevel(logging.INFO) 228 | self.mock_authenticator = MockAuthenticator({**self.v2_headers, **self.v1_headers}) 229 | self.mock_authenticator._signature_valid_v2 = MagicMock(side_effect=InauthenticError("Boom!")) 230 | authentic, status, message = self.mock_authenticator.is_authentic() 231 | 232 | self.assertTrue(authentic) 233 | self.assertEqual(status, 200) 234 | self.assertEqual(message, "") 235 | 236 | self.assertEqual( 237 | self.captor.getvalue(), 238 | "Mauth-client attempting to authenticate request from app with mauth" 239 | " app uuid {app_uuid} to app with mauth app uuid {auth_app_uuid}" 240 | " using version MWSV2.\n" 241 | "Mauth-client attempting to authenticate request from app with mauth" 242 | " app uuid {app_uuid} to app with mauth app uuid {auth_app_uuid}" 243 | " using version MWS.\n" 244 | "Completed successful authentication attempt after fallback to v1\n".format( 245 | app_uuid=APP_UUID, auth_app_uuid=AUTHENTICATOR_APP_UUID 246 | ), 247 | ) 248 | 249 | @pytest.mark.freeze_time(EPOCH_DATETIME) 250 | def test_does_not_fallback_to_v1_when_v2_only_flag_is_true(self): 251 | self.logger.setLevel(logging.INFO) 252 | self.mock_authenticator = MockAuthenticator({**self.v2_headers, **self.v1_headers}, True) 253 | self.mock_authenticator._signature_valid_v2 = MagicMock(side_effect=InauthenticError("Boom!")) 254 | authentic, status, message = self.mock_authenticator.is_authentic() 255 | 256 | self.assertFalse(authentic) 257 | self.assertEqual(status, 401) 258 | self.assertEqual(message, "Boom!") 259 | 260 | self.assertEqual( 261 | self.captor.getvalue(), 262 | "Mauth-client attempting to authenticate request from app with mauth" 263 | " app uuid {app_uuid} to app with mauth app uuid {auth_app_uuid}" 264 | " using version MWSV2.\n" 265 | "mAuth signature authentication failed for request. Exception: Boom!\n".format( 266 | app_uuid=APP_UUID, auth_app_uuid=AUTHENTICATOR_APP_UUID 267 | ), 268 | ) 269 | 270 | @pytest.mark.freeze_time(EPOCH_DATETIME) 271 | def test_does_not_fallback_to_v1_when_v1_signature_is_missing(self): 272 | self.logger.setLevel(logging.INFO) 273 | del self.v1_headers["X-MWS-Authentication"] 274 | self.mock_authenticator = MockAuthenticator({**self.v2_headers, **self.v1_headers}) 275 | self.mock_authenticator._signature_valid_v2 = MagicMock(side_effect=InauthenticError("Boom!")) 276 | authentic, status, message = self.mock_authenticator.is_authentic() 277 | 278 | self.assertFalse(authentic) 279 | self.assertEqual(status, 401) 280 | self.assertEqual(message, "Boom!") 281 | 282 | self.assertEqual( 283 | self.captor.getvalue(), 284 | "Mauth-client attempting to authenticate request from app with mauth" 285 | " app uuid {app_uuid} to app with mauth app uuid {auth_app_uuid}" 286 | " using version MWSV2.\n" 287 | "mAuth signature authentication failed for request. Exception: Boom!\n".format( 288 | app_uuid=APP_UUID, auth_app_uuid=AUTHENTICATOR_APP_UUID 289 | ), 290 | ) 291 | 292 | 293 | class TestLocalAuthenticator(unittest.TestCase): 294 | def setUp(self): 295 | self.__get_public_key__ = KeyHolder.get_public_key 296 | KeyHolder.get_public_key = MagicMock(return_value=load_key("rsapub")) 297 | 298 | Config.V2_ONLY_AUTHENTICATE = False 299 | self.logger = logging.getLogger() 300 | 301 | self.v1_headers = copy.deepcopy(X_MWS_HEADERS) 302 | self.v2_headers = copy.deepcopy(MWSV2_HEADERS) 303 | self.signable = RequestSignable(method="POST", url=URL, body=BODY) 304 | 305 | self.authenticator = LocalAuthenticator(self.signable, Signed.from_headers(self.v1_headers), self.logger) 306 | 307 | def tearDown(self): 308 | # reset the KeyHolder.get_public_key method 309 | KeyHolder.get_public_key = self.__get_public_key__ 310 | 311 | def test_authenticator_type(self): 312 | self.assertEqual(self.authenticator.authenticator_type, "LOCAL") 313 | 314 | @pytest.mark.freeze_time(EPOCH_DATETIME) 315 | def test_authentication_v1_happy_path(self): 316 | self.assertTrue(self.authenticator._authenticate()) 317 | 318 | @pytest.mark.freeze_time(EPOCH_DATETIME) 319 | def test_authentication_v1_happy_path_pub_key(self): 320 | KeyHolder.get_public_key = MagicMock(return_value=load_key("pub")) 321 | self.assertTrue(self.authenticator._authenticate()) 322 | 323 | @pytest.mark.freeze_time(EPOCH_DATETIME) 324 | def test_fail_to_retrieve_public_key_v1(self): 325 | KeyHolder.get_public_key = MagicMock(return_value="") 326 | with self.assertRaises(UnableToAuthenticateError) as exc: 327 | self.authenticator._authenticate() 328 | self.assertEqual(str(exc.exception), "Unable to identify Public Key type from Signature.") 329 | 330 | @pytest.mark.freeze_time(EPOCH_DATETIME) 331 | def test_authentication_v1_does_not_authenticate_a_false_message(self): 332 | self.authenticator.signable = RequestSignable(method="GET", url=URL, body=BODY) 333 | with self.assertRaises(InauthenticError) as exc: 334 | self.authenticator._authenticate() 335 | self.assertEqual(str(exc.exception), "Signature verification failed for request.") 336 | 337 | @pytest.mark.freeze_time(EPOCH_DATETIME) 338 | def test_authentication_v2_happy_path(self): 339 | self.authenticator.signed = Signed.from_headers(self.v2_headers) 340 | self.assertTrue(self.authenticator._authenticate()) 341 | 342 | @pytest.mark.freeze_time(EPOCH_DATETIME) 343 | def test_authentication_v2_happy_path_pub_key(self): 344 | self.authenticator.signed = Signed.from_headers(self.v2_headers) 345 | KeyHolder.get_public_key = MagicMock(return_value=load_key("pub")) 346 | self.assertTrue(self.authenticator._authenticate()) 347 | 348 | @pytest.mark.freeze_time(EPOCH_DATETIME) 349 | def test_authentication_v2_happy_path_multiple_versions(self): 350 | self.v2_headers["MCC-Authentication"] = "RWS {app_uuid}:ABC;{mwsv2_authentication};MWSV3 {app_uuid}:DEF".format( 351 | app_uuid=APP_UUID, mwsv2_authentication=MWSV2_AUTHENTICATION 352 | ) 353 | self.authenticator.signed = Signed.from_headers(self.v2_headers) 354 | self.assertTrue(self.authenticator._authenticate()) 355 | 356 | @pytest.mark.freeze_time(EPOCH_DATETIME) 357 | def test_fail_to_retrieve_public_key_v2(self): 358 | KeyHolder.get_public_key = MagicMock(return_value="") 359 | self.authenticator.signed = Signed.from_headers(self.v2_headers) 360 | with self.assertRaises(UnableToAuthenticateError) as exc: 361 | self.authenticator._authenticate() 362 | self.assertEqual(str(exc.exception), "Unable to identify Public Key type from Signature.") 363 | 364 | @pytest.mark.freeze_time(EPOCH_DATETIME) 365 | def test_authentication_v2_does_not_authenticate_a_false_message(self): 366 | self.authenticator.signed = Signed.from_headers(self.v2_headers) 367 | self.authenticator.signable = RequestSignable(method="GET", url=URL, body=BODY) 368 | with self.assertRaises(InauthenticError) as exc: 369 | self.authenticator._authenticate() 370 | self.assertEqual(str(exc.exception), "Signature verification failed for request.") 371 | 372 | 373 | class TestRemoteAuthenticator(unittest.TestCase): 374 | def setUp(self): 375 | Config.V2_ONLY_AUTHENTICATE = False 376 | RemoteAuthenticator._MAUTH = {"auth": MagicMock(), "url": MAUTH_AUTHENTICATION_URL} 377 | 378 | self.logger = logging.getLogger() 379 | 380 | self.v1_headers = copy.deepcopy(X_MWS_HEADERS) 381 | self.v2_headers = copy.deepcopy(MWSV2_HEADERS) 382 | self.signable = RequestSignable(method="POST", url=URL, body=BODY) 383 | self.signed = Signed.from_headers(self.v1_headers) 384 | 385 | self.authenticator = RemoteAuthenticator(self.signable, self.signed, self.logger) 386 | 387 | def test_authenticator_type(self): 388 | self.assertEqual(self.authenticator.authenticator_type, "REMOTE") 389 | 390 | @pytest.mark.freeze_time(EPOCH_DATETIME) 391 | def test_authentication_v1_happy_path(self): 392 | expected_ticket_v1 = { 393 | "authentication_ticket": { 394 | "verb": "POST", 395 | "app_uuid": APP_UUID, 396 | "client_signature": X_MWS_SIGNATURE, 397 | "request_url": "/sandbox/path", 398 | "request_time": EPOCH, 399 | "b64encoded_body": "44GT44KT44Gr44Gh44Gvw4Y=", 400 | } 401 | } 402 | 403 | with requests_mock.mock() as requests: 404 | requests.post(MAUTH_AUTHENTICATION_URL, status_code=200) 405 | result = self.authenticator._authenticate() 406 | 407 | self.assertTrue(result) 408 | self.assertEqual(requests.last_request.json(), expected_ticket_v1) 409 | 410 | @pytest.mark.freeze_time(EPOCH_DATETIME) 411 | def test_authentication_v1_does_not_authenticate_404(self): 412 | with requests_mock.mock() as requests: 413 | requests.post(MAUTH_AUTHENTICATION_URL, status_code=404) 414 | with self.assertRaises(InauthenticError) as exc: 415 | self.authenticator._authenticate() 416 | self.assertEqual(str(exc.exception), "The mAuth service responded with 404: ") 417 | 418 | @pytest.mark.freeze_time(EPOCH_DATETIME) 419 | def test_authentication_v1_does_not_authenticate_412(self): 420 | with requests_mock.mock() as requests: 421 | requests.post(MAUTH_AUTHENTICATION_URL, status_code=412) 422 | with self.assertRaises(InauthenticError) as exc: 423 | self.authenticator._authenticate() 424 | self.assertEqual(str(exc.exception), "The mAuth service responded with 412: ") 425 | 426 | @pytest.mark.freeze_time(EPOCH_DATETIME) 427 | def test_authentication_v1_does_not_authenticate_500(self): 428 | with requests_mock.mock() as requests: 429 | requests.post(MAUTH_AUTHENTICATION_URL, status_code=500) 430 | with self.assertRaises(UnableToAuthenticateError) as exc: 431 | self.authenticator._authenticate() 432 | self.assertEqual(str(exc.exception), "The mAuth service responded with 500: ") 433 | 434 | @pytest.mark.freeze_time(EPOCH_DATETIME) 435 | def test_authentication_v2_happy_path(self): 436 | expected_ticket_v2 = { 437 | "authentication_ticket": { 438 | "verb": "POST", 439 | "app_uuid": APP_UUID, 440 | "client_signature": MWSV2_SIGNATURE, 441 | "request_url": "/sandbox/path", 442 | "request_time": EPOCH, 443 | "b64encoded_body": "44GT44KT44Gr44Gh44Gvw4Y=", 444 | "query_string": "", 445 | "token": "MWSV2", 446 | } 447 | } 448 | self.authenticator.signed = Signed.from_headers(self.v2_headers) 449 | 450 | with requests_mock.mock() as requests: 451 | requests.post(MAUTH_AUTHENTICATION_URL, status_code=200) 452 | result = self.authenticator._authenticate() 453 | 454 | self.assertTrue(result) 455 | self.assertEqual(requests.last_request.json(), expected_ticket_v2) 456 | 457 | @pytest.mark.freeze_time(EPOCH_DATETIME) 458 | def test_authentication_v2_does_not_authenticate_404(self): 459 | self.authenticator.signed = Signed.from_headers(self.v2_headers) 460 | with requests_mock.mock() as requests: 461 | requests.post(MAUTH_AUTHENTICATION_URL, status_code=404) 462 | with self.assertRaises(InauthenticError) as exc: 463 | self.authenticator._authenticate() 464 | self.assertEqual(str(exc.exception), "The mAuth service responded with 404: ") 465 | 466 | @pytest.mark.freeze_time(EPOCH_DATETIME) 467 | def test_authentication_v2_does_not_authenticate_412(self): 468 | self.authenticator.signed = Signed.from_headers(self.v2_headers) 469 | with requests_mock.mock() as requests: 470 | requests.post(MAUTH_AUTHENTICATION_URL, status_code=412) 471 | with self.assertRaises(InauthenticError) as exc: 472 | self.authenticator._authenticate() 473 | self.assertEqual(str(exc.exception), "The mAuth service responded with 412: ") 474 | 475 | @pytest.mark.freeze_time(EPOCH_DATETIME) 476 | def test_authentication_v2_does_not_authenticate_500(self): 477 | self.authenticator.signed = Signed.from_headers(self.v2_headers) 478 | with requests_mock.mock() as requests: 479 | requests.post(MAUTH_AUTHENTICATION_URL, status_code=500) 480 | with self.assertRaises(UnableToAuthenticateError) as exc: 481 | self.authenticator._authenticate() 482 | self.assertEqual(str(exc.exception), "The mAuth service responded with 500: ") 483 | --------------------------------------------------------------------------------