From 949252300c04fad7a1773ee25ba345ea780c40a8 Mon Sep 17 00:00:00 2001 From: Yohei Kitamura Date: Tue, 16 Dec 2025 19:06:04 +0900 Subject: [PATCH 1/6] Add to_rsa_format function to normalize private key --- mauth_client/config.py | 15 ++++++++------- mauth_client/utils.py | 22 ++++++++++++++++++++++ tests/utils_test.py | 22 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 tests/utils_test.py diff --git a/mauth_client/config.py b/mauth_client/config.py index 1fa3ae0..9cd7074 100644 --- a/mauth_client/config.py +++ b/mauth_client/config.py @@ -1,11 +1,12 @@ import os +from .utils import to_rsa_format class Config: - APP_UUID = os.environ.get("APP_UUID") - MAUTH_URL = os.environ.get("MAUTH_URL") - MAUTH_API_VERSION = os.environ.get("MAUTH_API_VERSION", "v1") - MAUTH_MODE = os.environ.get("MAUTH_MODE", "local") - PRIVATE_KEY = os.environ.get("PRIVATE_KEY") - V2_ONLY_AUTHENTICATE = str(os.environ.get("V2_ONLY_AUTHENTICATE")).lower() == "true" - SIGN_VERSIONS = os.environ.get("MAUTH_SIGN_VERSIONS", "v1") + APP_UUID = os.getenv("APP_UUID", os.getenv("MAUTH_APP_UUID")) + MAUTH_URL = os.getenv("MAUTH_URL") + MAUTH_API_VERSION = os.getenv("MAUTH_API_VERSION", "v1") + MAUTH_MODE = os.getenv("MAUTH_MODE", "local") + PRIVATE_KEY = to_rsa_format(os.getenv("PRIVATE_KEY", os.getenv("MAUTH_PRIVATE_KEY", ""))) + V2_ONLY_AUTHENTICATE = str(os.getenv("V2_ONLY_AUTHENTICATE")).lower() == "true" + SIGN_VERSIONS = os.getenv("MAUTH_SIGN_VERSIONS", "v1") diff --git a/mauth_client/utils.py b/mauth_client/utils.py index 055e18d..f34af5a 100644 --- a/mauth_client/utils.py +++ b/mauth_client/utils.py @@ -1,7 +1,11 @@ import base64 import charset_normalizer +import re from hashlib import sha512 +HEADER = '-----BEGIN RSA PRIVATE KEY-----' +FOOTER = '-----END RSA PRIVATE KEY-----' + def make_bytes(val): """ @@ -32,3 +36,21 @@ def decode(byte_string: bytes) -> str: except UnicodeDecodeError: encoding = charset_normalizer.detect(byte_string)["encoding"] return byte_string.decode(encoding) + + +def to_rsa_format(key: str) -> str: + """Convert a private key to RSA format with proper newlines.""" + + if "\n" in key: + return key + + body = key.strip() + body = body.replace(HEADER, "").replace(FOOTER, "").strip() + + # Replace whitespace with newlines or chunk into 64-char lines + if " " in body or "\t" in body: + body = re.sub(r'\s+', '\n', body) + else: + body = '\n'.join(body[i:i + 64] for i in range(0, len(body), 64)) + + return f"{HEADER}\n{body}\n{FOOTER}" diff --git a/tests/utils_test.py b/tests/utils_test.py new file mode 100644 index 0000000..81673d9 --- /dev/null +++ b/tests/utils_test.py @@ -0,0 +1,22 @@ +import unittest + +from .common import load_key +from mauth_client.utils import to_rsa_format + +PRIVATE_KEY = load_key("priv").strip() + + +class TestToRsaFormat(unittest.TestCase): + def test_proper_format(self): + key = to_rsa_format(PRIVATE_KEY) + self.assertEqual(key, PRIVATE_KEY) + + def test_newlines_replaced_with_spaces(self): + key_no_newlines = PRIVATE_KEY.replace("\n", " ") + key = to_rsa_format(key_no_newlines) + self.assertEqual(key, PRIVATE_KEY) + + def test_newlines_removed(self): + key_no_newlines = PRIVATE_KEY.replace("\n", "") + key = to_rsa_format(key_no_newlines) + self.assertEqual(key, PRIVATE_KEY) From 0e0c4a338e596f90731ddff76ec797bbaf4a1840 Mon Sep 17 00:00:00 2001 From: Yohei Kitamura Date: Tue, 16 Dec 2025 19:21:29 +0900 Subject: [PATCH 2/6] Update requests_mauth and httpx_mauth to support reading configuration from environment variables --- mauth_client/httpx_mauth/client.py | 4 ++-- mauth_client/requests_mauth/client.py | 7 ++++++- tests/httpx_mauth/client_test.py | 13 +++++-------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/mauth_client/httpx_mauth/client.py b/mauth_client/httpx_mauth/client.py index 22bf446..79780c8 100644 --- a/mauth_client/httpx_mauth/client.py +++ b/mauth_client/httpx_mauth/client.py @@ -15,8 +15,8 @@ class MAuthHttpx(httpx.Auth): def __init__( self, - app_uuid: str, - private_key_data: str, + app_uuid: str = Config.APP_UUID, + private_key_data: str = Config.PRIVATE_KEY, sign_versions: str = Config.SIGN_VERSIONS, ): self.signer = Signer(app_uuid, private_key_data, sign_versions) diff --git a/mauth_client/requests_mauth/client.py b/mauth_client/requests_mauth/client.py index 2b3b198..3036f67 100644 --- a/mauth_client/requests_mauth/client.py +++ b/mauth_client/requests_mauth/client.py @@ -9,7 +9,12 @@ class MAuth(requests.auth.AuthBase): Custom requests authorizer for MAuth """ - def __init__(self, app_uuid, private_key_data, sign_versions=Config.SIGN_VERSIONS): + def __init__( + self, + app_uuid=Config.APP_UUID, + private_key_data=Config.PRIVATE_KEY, + sign_versions=Config.SIGN_VERSIONS + ): """ Create a new MAuth Instance diff --git a/tests/httpx_mauth/client_test.py b/tests/httpx_mauth/client_test.py index 2f30707..9866327 100644 --- a/tests/httpx_mauth/client_test.py +++ b/tests/httpx_mauth/client_test.py @@ -1,9 +1,10 @@ import unittest -import os import httpx from mauth_client.httpx_mauth import MAuthHttpx +from ..common import load_key APP_UUID = "5ff4257e-9c16-11e0-b048-0026bbfffe5e" +PRIVATE_KEY = load_key("priv") URL = "https://innovate.imedidata.com/api/v2/users/10ac3b0e-9fe2-11df-a531-12313900d531/studies.json" @@ -12,12 +13,8 @@ def handler(request): 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") + auth = MAuthHttpx(APP_UUID, PRIVATE_KEY, sign_versions="v1,v2") with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client: response = client.get(URL) @@ -25,7 +22,7 @@ def test_call(self): self.assertIn(header, response.request.headers) def test_call_v1_only(self): - auth = MAuthHttpx(APP_UUID, self.example_private_key) + auth = MAuthHttpx(APP_UUID, PRIVATE_KEY) with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client: response = client.get(URL) @@ -33,7 +30,7 @@ def test_call_v1_only(self): self.assertIn(header, response.request.headers) def test_call_v2_only(self): - auth = MAuthHttpx(APP_UUID, self.example_private_key, sign_versions="v2") + auth = MAuthHttpx(APP_UUID, PRIVATE_KEY, sign_versions="v2") with httpx.Client(transport=httpx.MockTransport(handler), auth=auth) as client: response = client.get(URL) From 73703300e928e0f8390c57f2bedb9ea484ecc79b Mon Sep 17 00:00:00 2001 From: Yohei Kitamura Date: Tue, 16 Dec 2025 21:58:28 +0900 Subject: [PATCH 3/6] update docs and version --- CHANGELOG.md | 4 ++++ README.md | 17 ++++++++++++----- pyproject.toml | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0767087..c765171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 1.8.0 +- Add `to_rsa_format` function to normalize private key +- Update requests_mauth and httpx_mauth to support reading configuration from environment variables + # 1.7.0 - Add `MAuthHttpx` custom authentication scheme for HTTPX. - Remove Support for EOL Python 3.8 diff --git a/README.md b/README.md index 44c2ba5..03d3bb9 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,13 @@ client = httpx.Client(auth=auth) response = client.get("https://api.example.com/endpoint") ``` +The following variables can be configured in the environment variables: + +| Key | Value | +| ------------------------------------ | ---------------------------------- | +| `APP_UUID` or `MAUTH_APP_UUID` | APP_UUID for signing requests | +| `PRIVATE_KEY` or `MAUTH_PRIVATE_KEY` | MAuth private key for the APP_UUID | + The `mauth_sign_versions` option can be set as an environment variable to specify protocol versions to sign outgoing requests: | Key | Value | @@ -103,11 +110,11 @@ MAuth Client Python supports AWS Lambda functions and Flask applications to auth The following variables are **required** to be configured in the environment variables: -| Key | Value | -| -------------- | ------------------------------------------------------------- | -| `APP_UUID` | APP_UUID for the AWS Lambda function | -| `PRIVATE_KEY` | Encrypted private key for the APP_UUID | -| `MAUTH_URL` | MAuth service URL (e.g. https://mauth-innovate.imedidata.com) | +| Key | Value | +| ------------------------------------ | ------------------------------------------------------------- | +| `APP_UUID` or `MAUTH_APP_UUID` | APP_UUID for the AWS Lambda function | +| `PRIVATE_KEY` or `MAUTH_PRIVATE_KEY` | Encrypted private key for the APP_UUID | +| `MAUTH_URL` | MAuth service URL (e.g. https://mauth-innovate.imedidata.com) | The following variables can optionally be set in the environment variables: diff --git a/pyproject.toml b/pyproject.toml index 5bab690..922accf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "mauth-client" -version = "1.7.0" +version = "1.8.0" description = "MAuth Client for Python" repository = "https://github.com/mdsol/mauth-client-python" authors = ["Medidata Solutions "] From cc90ef14ffe970587ef977b708f0d61014f06e78 Mon Sep 17 00:00:00 2001 From: Yohei Kitamura Date: Wed, 17 Dec 2025 00:20:00 +0900 Subject: [PATCH 4/6] Update mauth_client/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- mauth_client/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mauth_client/utils.py b/mauth_client/utils.py index f34af5a..b177a67 100644 --- a/mauth_client/utils.py +++ b/mauth_client/utils.py @@ -51,6 +51,7 @@ def to_rsa_format(key: str) -> str: if " " in body or "\t" in body: body = re.sub(r'\s+', '\n', body) else: + # PEM-encoded keys are typically split into lines of 64 characters as per RFC 7468 (section 2) body = '\n'.join(body[i:i + 64] for i in range(0, len(body), 64)) return f"{HEADER}\n{body}\n{FOOTER}" From 0d8cf707d5ef9f5e0798b9d1a87a35e1d7f0f053 Mon Sep 17 00:00:00 2001 From: Yohei Kitamura Date: Wed, 17 Dec 2025 00:20:12 +0900 Subject: [PATCH 5/6] Update mauth_client/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- mauth_client/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mauth_client/utils.py b/mauth_client/utils.py index b177a67..80e19f8 100644 --- a/mauth_client/utils.py +++ b/mauth_client/utils.py @@ -41,7 +41,7 @@ def decode(byte_string: bytes) -> str: def to_rsa_format(key: str) -> str: """Convert a private key to RSA format with proper newlines.""" - if "\n" in key: + if "\n" in key and HEADER in key and FOOTER in key: return key body = key.strip() From f367c0f79eabea96bec4516e01effa151c000bd6 Mon Sep 17 00:00:00 2001 From: Yohei Kitamura Date: Wed, 17 Dec 2025 00:20:21 +0900 Subject: [PATCH 6/6] Update mauth_client/config.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- mauth_client/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mauth_client/config.py b/mauth_client/config.py index 9cd7074..7c7d692 100644 --- a/mauth_client/config.py +++ b/mauth_client/config.py @@ -7,6 +7,7 @@ class Config: MAUTH_URL = os.getenv("MAUTH_URL") MAUTH_API_VERSION = os.getenv("MAUTH_API_VERSION", "v1") MAUTH_MODE = os.getenv("MAUTH_MODE", "local") - PRIVATE_KEY = to_rsa_format(os.getenv("PRIVATE_KEY", os.getenv("MAUTH_PRIVATE_KEY", ""))) + _private_key_env = os.getenv("PRIVATE_KEY", os.getenv("MAUTH_PRIVATE_KEY", "")) + PRIVATE_KEY = to_rsa_format(_private_key_env) if _private_key_env else None V2_ONLY_AUTHENTICATE = str(os.getenv("V2_ONLY_AUTHENTICATE")).lower() == "true" SIGN_VERSIONS = os.getenv("MAUTH_SIGN_VERSIONS", "v1")