Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install poetry
run: pipx install poetry==1.7.1
- name: Set up Python 3.11
run: pipx install poetry==2.2.1
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.13"
cache: 'poetry'
- name: Install dependencies
run: poetry install --no-interaction
Expand All @@ -27,7 +27,7 @@ jobs:
strategy:
matrix:
os: [Ubuntu, macOS, Windows]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
include:
- os: Ubuntu
image: ubuntu-latest
Expand All @@ -41,7 +41,7 @@ jobs:
with:
submodules: true
- name: Install poetry
run: pipx install poetry==1.7.1
run: pipx install poetry==2.2.1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
id: python-setup
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install poetry
run: pipx install poetry==1.7.1
- name: Set up Python 3.11
run: pipx install poetry==2.2.1
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.13"
cache: 'poetry'
- name: Publish
env:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 1.7.0
- Add `MAuthHttpx` custom authentication scheme for HTTPX.
- Remove Support for EOL Python 3.8

# 1.6.6
- Support long-lived connections in ASGI middleware

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ mauth-client==<latest version>

### Signing Outgoing Requests

#### With [Requests library](https://requests.readthedocs.io/en/latest/)

```python
import requests
from mauth_client.requests_mauth import MAuth
Expand All @@ -64,6 +66,21 @@ if result.status_code == 200:
print(result.text)
```

#### With [HTTPX](https://www.python-httpx.org/) library

```python
import httpx
from mauth_client.httpx_mauth import MAuthHttpx

# MAuth configuration
APP_UUID = "<MAUTH_APP_UUID>"
private_key = open("private.key", "r").read()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is a copy of the requests mauth example but usually we take our secrets directly from env these days so i wonder if it's more fitting to show that in the example


auth = MAuthHttpx(app_uuid=APP_UUID, private_key_data=private_key)
client = httpx.Client(auth=auth)
response = client.get("https://api.example.com/endpoint")
```

The `mauth_sign_versions` option can be set as an environment variable to specify protocol versions to sign outgoing requests:

| Key | Value |
Expand All @@ -75,6 +92,8 @@ This option can also be passed to the constructor:
```python
mauth_sign_versions = "v1,v2"
mauth = MAuth(APP_UUID, private_key, mauth_sign_versions)

auth = MAuthHttpx(app_uuid=APP_UUID, private_key_data=private_key, sign_versions=mauth_sign_versions)
```


Expand Down
1 change: 1 addition & 0 deletions mauth_client/httpx_mauth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .client import MAuthHttpx
37 changes: 37 additions & 0 deletions mauth_client/httpx_mauth/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import httpx
from mauth_client.config import Config
from mauth_client.signable import RequestSignable
from mauth_client.signer import Signer


class MAuthHttpx(httpx.Auth):
"""
HTTPX authentication for MAuth.
Adds MAuth headers based on method, URL, and body bytes.
"""

# We need the body bytes to sign the request
requires_request_body = True

def __init__(
self,
app_uuid: str,
private_key_data: str,
sign_versions: str = Config.SIGN_VERSIONS,
):
self.signer = Signer(app_uuid, private_key_data, sign_versions)

def _make_headers(self, request: httpx.Request) -> dict[str, str]:
# With requires_request_body=True, httpx ensures the content is buffered.
body = request.content or b""
req_signable = RequestSignable(
method=request.method,
url=str(request.url),
body=body,
)
return self.signer.signed_headers(req_signable)

def auth_flow(self, request: httpx.Request):
# Body is already read due to requires_request_body=True.
request.headers.update(self._make_headers(request))
yield request
1,249 changes: 722 additions & 527 deletions poetry.lock

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "mauth-client"
version = "1.6.6"
version = "1.7.0"
description = "MAuth Client for Python"
repository = "https://github.com/mdsol/mauth-client-python"
authors = ["Medidata Solutions <support@mdsol.com>"]
Expand All @@ -13,24 +13,25 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Software Development :: Libraries :: Python Modules",
]

[tool.poetry.dependencies]
python = "^3.8"
python = "^3.9"
requests = "^2.31.0"
cachetools = "^5.3.3"
rsa = "^4.9"
asgiref = "^3.8.1"
charset-normalizer = "^3.3.2"
importlib = "^1.0.4"

[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
boto3 = "^1.34.106"
flask = "^2.3.3"
python-dateutil = "^2.9.0.post0"
Expand All @@ -40,7 +41,7 @@ pytest-cov = "^4.1.0"
pytest-freezer = "^0.4"
pytest-randomly = "^3.15.0"
pytest-subtests = "^0.10"
flake8 = "^3.9.2"
flake8 = "^7.3.0"
tox = "^4.15.0"
fastapi = "^0.109.0"
httpx = "^0.26.0"
Expand Down
Empty file added tests/httpx_mauth/__init__.py
Empty file.
41 changes: 41 additions & 0 deletions tests/httpx_mauth/client_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import unittest
import os
import httpx
from mauth_client.httpx_mauth import MAuthHttpx

APP_UUID = "5ff4257e-9c16-11e0-b048-0026bbfffe5e"
URL = "https://innovate.imedidata.com/api/v2/users/10ac3b0e-9fe2-11df-a531-12313900d531/studies.json"


def handler(request):
return httpx.Response(200, json={"text": "Hello, world!"})


class MAuthHttpxBaseTest(unittest.TestCase):
def setUp(self):
with open(os.path.join(os.path.dirname(__file__), "..", "keys", "fake_mauth.priv.key"), "r") as key_file:
self.example_private_key = key_file.read()

def test_call(self):
auth = MAuthHttpx(APP_UUID, self.example_private_key, sign_versions="v1,v2")
with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client:
response = client.get(URL)

for header in ["mcc-authentication", "mcc-time", "x-mws-authentication", "x-mws-time"]:
self.assertIn(header, response.request.headers)

def test_call_v1_only(self):
auth = MAuthHttpx(APP_UUID, self.example_private_key)
with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client:
response = client.get(URL)

for header in ["x-mws-authentication", "x-mws-time"]:
self.assertIn(header, response.request.headers)

def test_call_v2_only(self):
auth = MAuthHttpx(APP_UUID, self.example_private_key, sign_versions="v2")
with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client:
response = client.get(URL)

for header in ["mcc-authentication", "mcc-time"]:
self.assertIn(header, response.request.headers)