Skip to content

Commit 7de97a6

Browse files
Merge pull request #55 from mdsol/feature/httpx
Add `MAuthHttpx` custom authentication scheme for HTTPX
2 parents 219dfe6 + e0e2e90 commit 7de97a6

File tree

10 files changed

+838
-540
lines changed

10 files changed

+838
-540
lines changed

.github/workflows/check.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ jobs:
1111
steps:
1212
- uses: actions/checkout@v4
1313
- name: Install poetry
14-
run: pipx install poetry==1.7.1
15-
- name: Set up Python 3.11
14+
run: pipx install poetry==2.2.1
15+
- name: Set up Python
1616
uses: actions/setup-python@v5
1717
with:
18-
python-version: "3.11"
18+
python-version: "3.13"
1919
cache: 'poetry'
2020
- name: Install dependencies
2121
run: poetry install --no-interaction
@@ -27,7 +27,7 @@ jobs:
2727
strategy:
2828
matrix:
2929
os: [Ubuntu, macOS, Windows]
30-
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
30+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
3131
include:
3232
- os: Ubuntu
3333
image: ubuntu-latest
@@ -41,7 +41,7 @@ jobs:
4141
with:
4242
submodules: true
4343
- name: Install poetry
44-
run: pipx install poetry==1.7.1
44+
run: pipx install poetry==2.2.1
4545
- name: Set up Python ${{ matrix.python-version }}
4646
uses: actions/setup-python@v5
4747
id: python-setup

.github/workflows/release.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ jobs:
1111
steps:
1212
- uses: actions/checkout@v4
1313
- name: Install poetry
14-
run: pipx install poetry==1.7.1
15-
- name: Set up Python 3.11
14+
run: pipx install poetry==2.2.1
15+
- name: Set up Python
1616
uses: actions/setup-python@v5
1717
with:
18-
python-version: "3.11"
18+
python-version: "3.13"
1919
cache: 'poetry'
2020
- name: Publish
2121
env:

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 1.7.0
2+
- Add `MAuthHttpx` custom authentication scheme for HTTPX.
3+
- Remove Support for EOL Python 3.8
4+
15
# 1.6.6
26
- Support long-lived connections in ASGI middleware
37

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ mauth-client==<latest version>
4141

4242
### Signing Outgoing Requests
4343

44+
#### With [Requests library](https://requests.readthedocs.io/en/latest/)
45+
4446
```python
4547
import requests
4648
from mauth_client.requests_mauth import MAuth
@@ -64,6 +66,21 @@ if result.status_code == 200:
6466
print(result.text)
6567
```
6668

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 = "<MAUTH_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+
6784
The `mauth_sign_versions` option can be set as an environment variable to specify protocol versions to sign outgoing requests:
6885

6986
| Key | Value |
@@ -75,6 +92,8 @@ This option can also be passed to the constructor:
7592
```python
7693
mauth_sign_versions = "v1,v2"
7794
mauth = MAuth(APP_UUID, private_key, mauth_sign_versions)
95+
96+
auth = MAuthHttpx(app_uuid=APP_UUID, private_key_data=private_key, sign_versions=mauth_sign_versions)
7897
```
7998

8099

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .client import MAuthHttpx

mauth_client/httpx_mauth/client.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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,
19+
private_key_data: str,
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

poetry.lock

Lines changed: 722 additions & 527 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "mauth-client"
3-
version = "1.6.6"
3+
version = "1.7.0"
44
description = "MAuth Client for Python"
55
repository = "https://github.com/mdsol/mauth-client-python"
66
authors = ["Medidata Solutions <[email protected]>"]
@@ -13,24 +13,25 @@ classifiers = [
1313
"License :: OSI Approved :: MIT License",
1414
"Operating System :: OS Independent",
1515
"Programming Language :: Python",
16-
"Programming Language :: Python :: 3.8",
1716
"Programming Language :: Python :: 3.9",
1817
"Programming Language :: Python :: 3.10",
1918
"Programming Language :: Python :: 3.11",
2019
"Programming Language :: Python :: 3.12",
20+
"Programming Language :: Python :: 3.13",
2121
"Topic :: Internet :: WWW/HTTP",
2222
"Topic :: Software Development :: Libraries :: Python Modules",
2323
]
2424

2525
[tool.poetry.dependencies]
26-
python = "^3.8"
26+
python = "^3.9"
2727
requests = "^2.31.0"
2828
cachetools = "^5.3.3"
2929
rsa = "^4.9"
3030
asgiref = "^3.8.1"
3131
charset-normalizer = "^3.3.2"
32+
importlib = "^1.0.4"
3233

33-
[tool.poetry.dev-dependencies]
34+
[tool.poetry.group.dev.dependencies]
3435
boto3 = "^1.34.106"
3536
flask = "^2.3.3"
3637
python-dateutil = "^2.9.0.post0"
@@ -40,7 +41,7 @@ pytest-cov = "^4.1.0"
4041
pytest-freezer = "^0.4"
4142
pytest-randomly = "^3.15.0"
4243
pytest-subtests = "^0.10"
43-
flake8 = "^3.9.2"
44+
flake8 = "^7.3.0"
4445
tox = "^4.15.0"
4546
fastapi = "^0.109.0"
4647
httpx = "^0.26.0"

tests/httpx_mauth/__init__.py

Whitespace-only changes.

tests/httpx_mauth/client_test.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import unittest
2+
import os
3+
import httpx
4+
from mauth_client.httpx_mauth import MAuthHttpx
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+
def handler(request):
11+
return httpx.Response(200, json={"text": "Hello, world!"})
12+
13+
14+
class MAuthHttpxBaseTest(unittest.TestCase):
15+
def setUp(self):
16+
with open(os.path.join(os.path.dirname(__file__), "..", "keys", "fake_mauth.priv.key"), "r") as key_file:
17+
self.example_private_key = key_file.read()
18+
19+
def test_call(self):
20+
auth = MAuthHttpx(APP_UUID, self.example_private_key, sign_versions="v1,v2")
21+
with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client:
22+
response = client.get(URL)
23+
24+
for header in ["mcc-authentication", "mcc-time", "x-mws-authentication", "x-mws-time"]:
25+
self.assertIn(header, response.request.headers)
26+
27+
def test_call_v1_only(self):
28+
auth = MAuthHttpx(APP_UUID, self.example_private_key)
29+
with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client:
30+
response = client.get(URL)
31+
32+
for header in ["x-mws-authentication", "x-mws-time"]:
33+
self.assertIn(header, response.request.headers)
34+
35+
def test_call_v2_only(self):
36+
auth = MAuthHttpx(APP_UUID, self.example_private_key, sign_versions="v2")
37+
with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client:
38+
response = client.get(URL)
39+
40+
for header in ["mcc-authentication", "mcc-time"]:
41+
self.assertIn(header, response.request.headers)

0 commit comments

Comments
 (0)